diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index d1bf051f77..65dbeb944e 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -6,6 +6,7 @@ from typing import Any, Callable import typer +from rich.markup import escape from .._agent_config import SCRIPT_TYPE_CHOICES from .._console import console @@ -190,7 +191,20 @@ def _parse_integration_options(integration: Any, raw_options: str) -> dict[str, """ import shlex parsed: dict[str, Any] = {} - tokens = shlex.split(raw_options) + try: + tokens = shlex.split(raw_options) + except ValueError as error: + # shlex raises on malformed input (e.g. an unbalanced quote in + # --integration-options='--commands-dir "foo'). Surface the clean CLI + # error every other bad-input path here produces instead of leaking a + # raw ValueError traceback. raw_options is user-controlled, so escape + # it before interpolating — an unbalanced Rich tag like '[/red]' would + # otherwise raise MarkupError inside console.print and leak a traceback. + console.print( + f"[red]Error:[/red] Could not parse integration options " + f"'{escape(raw_options)}': {escape(str(error))}." + ) + raise typer.Exit(1) from error declared_options = list(integration.options()) declared = {opt.name.lstrip("-"): opt for opt in declared_options} allowed = ", ".join(sorted(opt.name for opt in declared_options)) @@ -198,7 +212,7 @@ def _parse_integration_options(integration: Any, raw_options: str) -> dict[str, while i < len(tokens): token = tokens[i] if not token.startswith("-"): - console.print(f"[red]Error:[/red] Unexpected integration option value '{token}'.") + console.print(f"[red]Error:[/red] Unexpected integration option value '{escape(token)}'.") if allowed: console.print(f"Allowed options: {allowed}") raise typer.Exit(1) @@ -209,7 +223,7 @@ def _parse_integration_options(integration: Any, raw_options: str) -> dict[str, name, value = name.split("=", 1) opt = declared.get(name) if not opt: - console.print(f"[red]Error:[/red] Unknown integration option '{token}'.") + console.print(f"[red]Error:[/red] Unknown integration option '{escape(token)}'.") if allowed: console.print(f"Allowed options: {allowed}") raise typer.Exit(1) diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index 56f338cc82..8ec8706bdc 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -2675,6 +2675,49 @@ def test_equals_form_parsed(self): assert result_space["commands_dir"] == "./mydir" assert result_equals["commands_dir"] == "./mydir" + def test_malformed_quoting_exits_cleanly(self): + """An unbalanced quote must raise a clean typer.Exit, not a ValueError. + + shlex.split raises ValueError('No closing quotation') on malformed + input. Every other bad-input path in this function exits via + typer.Exit(1); a raw ValueError traceback would be inconsistent and + user-hostile for a plain CLI-flag typo.""" + import typer + + from specify_cli.integrations._commands import _parse_integration_options + from specify_cli.integrations import get_integration + + integration = get_integration("generic") + assert integration is not None + + with pytest.raises(typer.Exit): + _parse_integration_options(integration, '--commands-dir "foo') + + def test_malformed_quoting_with_rich_markup_exits_cleanly(self): + """A malformed value carrying Rich markup must still exit cleanly. + + raw_options is user-controlled. A value like '--commands-dir "[/red]foo' + first trips the shlex ValueError path, but the error message then + interpolates the raw value into console.print — an unbalanced Rich tag + such as '[/red]' would raise rich.errors.MarkupError there and leak a + traceback anyway. The value must be escaped so the clean typer.Exit + survives.""" + import typer + + from specify_cli.integrations._commands import _parse_integration_options + from specify_cli.integrations import get_integration + + integration = get_integration("generic") + assert integration is not None + + # Unbalanced quote (shlex path) + markup injection in one value. + with pytest.raises(typer.Exit): + _parse_integration_options(integration, '--commands-dir "[/red]foo') + + # Markup injection in a token that parses but is unexpected/unknown. + with pytest.raises(typer.Exit): + _parse_integration_options(integration, "[/red]foo") + class TestUninstallNoManifestClearsInitOptions: def test_init_options_cleared_on_no_manifest_uninstall(self, tmp_path):