diff --git a/scripts/doc_parser/docopt_ng/__init__.py b/scripts/doc_parser/docopt_ng/__init__.py index 96209ef..7956283 100644 --- a/scripts/doc_parser/docopt_ng/__init__.py +++ b/scripts/doc_parser/docopt_ng/__init__.py @@ -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: @@ -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, + ) + + # cli customization: def bash_quote(value: str) -> str: """Return a single-quoted bash literal that is safe to `eval`. diff --git a/tests/core/helpers/test_cli_parse_args.sh b/tests/core/helpers/test_cli_parse_args.sh index 15a0214..c775c06 100755 --- a/tests/core/helpers/test_cli_parse_args.sh +++ b/tests/core/helpers/test_cli_parse_args.sh @@ -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=] + +Options: + --foo= 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=] + +Options: + --foo= 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