Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion scripts/doc_parser/docopt_ng/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ def parse(cls, option_description: str) -> _Option:
argcount = 1
if argcount:
matched = re.findall(r"\[default: (.*)\]", description, flags=re.I)
value = matched[0] if matched else None
value = expand_env_vars(matched[0]) if matched else None
return cls(short, longer, argcount, value)

def single_match(self, left: list[_LeafPattern]) -> _SingleMatch:
Expand Down Expand Up @@ -950,6 +950,27 @@ def show_log(message: str) -> None:
print(f"{filename}:{line_number} - {message}", file=sys.stderr)


# cli customization:
def expand_env_vars(value: str) -> str:
"""Expand `$VAR` and `${VAR}` in a docstring default against the environment.

Defaults come from the (trusted) docstring, so expanding them is safe. This is a pure
string substitution — it never invokes a shell — so command substitution (`$(...)`,
backticks) and bash-only forms (`${VAR:-default}`) are left untouched, and the expanded
result still flows through `bash_quote()` before `eval`. An unset variable expands to the
empty string (like an unquoted `$VAR` in the shell). User-supplied values are parsed
elsewhere and are never expanded.
"""
import os

return re.sub(
r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)",
lambda m: os.environ.get(m.group(1) or m.group(2), ""),
value,
flags=re.ASCII,
)
Comment thread
Copilot marked this conversation as resolved.


# cli customization:
def bash_quote(value: str) -> str:
"""Return a single-quoted bash literal that is safe to `eval`.
Expand Down
33 changes: 33 additions & 0 deletions tests/core/helpers/test_cli_parse_args.sh
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,39 @@ pos2='CDE'"
assertEquals "$expected" "$result"
}

test_parse_args_expands_env_vars_in_defaults() {
local help_text result

# `$VAR`/`${VAR}` in a `[default: ...]` are expanded against the environment (defaults come
# from the trusted docstring). An unset variable expands to the empty string; `$(...)` and
# backticks stay literal, and a user-supplied value is never expanded.
# shellcheck disable=SC2016 # single quotes intentional: `$VAR` must reach docopt literally
help_text='Usage:
anything cmd [--foo=<x>]

Options:
--foo=<x> Some parameter [default: $MYCLI_TEST_DEFAULT]'

result=$(MYCLI_TEST_DEFAULT='from env' parse_args "$help_text" 'cmd')
assertContains "env var in default is expanded" "$result" "export foo='from env'"

# shellcheck disable=SC2016 # single quotes intentional: value must reach docopt literally
result=$(MYCLI_TEST_DEFAULT='from env' parse_args "$help_text" 'cmd' '--foo=$MYCLI_TEST_DEFAULT')
assertContains "user-supplied value is not expanded" "$result" "export foo='\$MYCLI_TEST_DEFAULT'"

result=$(unset MYCLI_TEST_DEFAULT; parse_args "$help_text" 'cmd')
assertContains "unset var in default expands to empty" "$result" "export foo=''"

# shellcheck disable=SC2016 # single quotes intentional: `$(id)` must reach docopt literally
help_text='Usage:
anything cmd [--foo=<x>]

Options:
--foo=<x> Some parameter [default: $(id)]'
result=$(parse_args "$help_text" 'cmd')
assertContains "command substitution in default stays inert" "$result" "export foo='\$(id)'"
}

test_parse_help_does_not_evaluate_injected_code() {
local result

Expand Down
Loading