diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index 07a62efeed..ec06b78a2e 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 @@ -206,7 +207,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) @@ -217,7 +218,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 a6a3807498..75db6142c3 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -2733,6 +2733,55 @@ def test_unbalanced_quote_exits_cleanly(self, capsys): assert excinfo.value.exit_code == 1 assert "Error: Could not parse integration options: No closing quotation." in capsys.readouterr().out + def test_bad_option_token_with_rich_markup_exits_cleanly(self): + """A bad option token carrying Rich markup must exit cleanly, not crash. + + The token is user-controlled and gets interpolated into console.print. + A value like '[/red]foo' parses fine through shlex but is an unexpected + value / unknown option — and an unbalanced Rich tag would raise + rich.errors.MarkupError inside console.print, leaking a traceback + instead of the intended typer.Exit(1). The token must be escaped.""" + 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 + + # Unexpected value token carrying markup. + with pytest.raises(typer.Exit): + _parse_integration_options(integration, "[/red]foo") + + # Unknown option token carrying markup. + with pytest.raises(typer.Exit): + _parse_integration_options(integration, "--[/red]bad") + + 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):