mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
fix: escape Rich markup in catalog list output (#3738)
The `catalog list` subcommands for workflows, workflow steps, presets, and integrations printed user-editable catalog fields (name/url/ description from the `*-catalogs.yml` files) through `console.print` with Rich markup enabled. Any bracketed content such as a description `Does [stuff] nicely` was parsed as a style tag and silently swallowed, and a malformed tag could raise while rendering. Route each untrusted field through the module's already-imported `escape` helper, matching the pattern already used by `extension catalog list`. Adds regression tests for all four commands that inject bracketed name/url/description and assert the brackets survive verbatim in the output. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -489,13 +489,14 @@ def integration_catalog_list():
|
||||
display_name = str(raw_name).strip() if raw_name is not None else ""
|
||||
if not display_name:
|
||||
display_name = f"catalog-{i + 1}"
|
||||
safe_name = _rich_escape(display_name)
|
||||
if env_override or project_configs is None:
|
||||
console.print(f" - [bold]{display_name}[/bold] — {install_status}")
|
||||
console.print(f" - [bold]{safe_name}[/bold] — {install_status}")
|
||||
else:
|
||||
console.print(f" [{i}] [bold]{display_name}[/bold] — {install_status}")
|
||||
console.print(f" {cfg.get('url', '')}")
|
||||
console.print(f" [{i}] [bold]{safe_name}[/bold] — {install_status}")
|
||||
console.print(f" {_rich_escape(str(cfg.get('url', '')))}")
|
||||
if cfg.get("description"):
|
||||
console.print(f" [dim]{cfg['description']}[/dim]")
|
||||
console.print(f" [dim]{_rich_escape(str(cfg['description']))}[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
|
||||
@@ -580,10 +580,10 @@ def preset_catalog_list():
|
||||
if entry.install_allowed
|
||||
else "[yellow]discovery only[/yellow]"
|
||||
)
|
||||
console.print(f" [bold]{entry.name}[/bold] (priority {entry.priority})")
|
||||
console.print(f" [bold]{_escape_markup(str(entry.name))}[/bold] (priority {entry.priority})")
|
||||
if entry.description:
|
||||
console.print(f" {entry.description}")
|
||||
console.print(f" URL: {entry.url}")
|
||||
console.print(f" {_escape_markup(str(entry.description))}")
|
||||
console.print(f" URL: {_escape_markup(str(entry.url))}")
|
||||
console.print(f" Install: {install_str}")
|
||||
console.print()
|
||||
|
||||
|
||||
@@ -2424,10 +2424,10 @@ def workflow_catalog_list():
|
||||
console.print("\n[bold cyan]Workflow Catalog Sources:[/bold cyan]\n")
|
||||
for i, cfg in enumerate(configs):
|
||||
install_status = "[green]install allowed[/green]" if cfg["install_allowed"] else "[yellow]discovery only[/yellow]"
|
||||
console.print(f" [{i}] [bold]{cfg['name']}[/bold] — {install_status}")
|
||||
console.print(f" {cfg['url']}")
|
||||
console.print(f" [{i}] [bold]{_escape_markup(str(cfg['name']))}[/bold] — {install_status}")
|
||||
console.print(f" {_escape_markup(str(cfg['url']))}")
|
||||
if cfg.get("description"):
|
||||
console.print(f" [dim]{cfg['description']}[/dim]")
|
||||
console.print(f" [dim]{_escape_markup(str(cfg['description']))}[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
@@ -3067,10 +3067,10 @@ def workflow_step_catalog_list():
|
||||
if cfg["install_allowed"]
|
||||
else "[yellow]discovery only[/yellow]"
|
||||
)
|
||||
console.print(f" [{i}] [bold]{cfg['name']}[/bold] — {install_status}")
|
||||
console.print(f" {cfg['url']}")
|
||||
console.print(f" [{i}] [bold]{_escape_markup(str(cfg['name']))}[/bold] — {install_status}")
|
||||
console.print(f" {_escape_markup(str(cfg['url']))}")
|
||||
if cfg.get("description"):
|
||||
console.print(f" [dim]{cfg['description']}[/dim]")
|
||||
console.print(f" [dim]{_escape_markup(str(cfg['description']))}[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
|
||||
@@ -633,6 +633,40 @@ class TestIntegrationListCatalog:
|
||||
assert "copilot" in result.output
|
||||
assert "installed" in result.output
|
||||
|
||||
def test_catalog_list_escapes_rich_markup(self, tmp_path, monkeypatch):
|
||||
"""User-editable catalog name/url/description must not be parsed as Rich markup."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
from specify_cli.integrations.catalog import IntegrationCatalog
|
||||
runner = CliRunner()
|
||||
project = self._init_project(tmp_path)
|
||||
|
||||
configs = [
|
||||
{
|
||||
"name": "Bracket [Catalog]",
|
||||
"url": "https://example.com/[cat].json",
|
||||
"description": "desc [with] brackets",
|
||||
"install_allowed": True,
|
||||
},
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
IntegrationCatalog,
|
||||
"get_project_catalog_configs",
|
||||
lambda self: [dict(c) for c in configs],
|
||||
)
|
||||
|
||||
old = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, ["integration", "catalog", "list"])
|
||||
finally:
|
||||
os.chdir(old)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Bracket [Catalog]" in result.output
|
||||
assert "https://example.com/[cat].json" in result.output
|
||||
assert "desc [with] brackets" in result.output
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI: integration upgrade
|
||||
|
||||
@@ -2637,6 +2637,28 @@ class TestPresetCatalogMultiCatalog:
|
||||
assert active[1].priority == 2
|
||||
assert active[1].install_allowed is False
|
||||
|
||||
def test_catalog_list_escapes_rich_markup(self, project_dir):
|
||||
"""User-editable catalog name/url/description must not be parsed as Rich markup."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
entry = PresetCatalogEntry(
|
||||
url="https://example.com/[cat].json",
|
||||
name="Bracket [Catalog]",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
description="desc [with] brackets",
|
||||
)
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch.object(PresetCatalog, "get_active_catalogs", return_value=[entry]):
|
||||
result = runner.invoke(app, ["preset", "catalog", "list"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Bracket [Catalog]" in result.output
|
||||
assert "https://example.com/[cat].json" in result.output
|
||||
assert "desc [with] brackets" in result.output
|
||||
|
||||
def test_env_var_overrides_catalogs(self, project_dir, monkeypatch):
|
||||
"""Test that SPECKIT_PRESET_CATALOG_URL env var overrides defaults."""
|
||||
monkeypatch.setenv(
|
||||
|
||||
@@ -10077,6 +10077,60 @@ steps:
|
||||
assert "desc [with] brackets" in result.output
|
||||
assert "tag[1]" in result.output
|
||||
|
||||
def test_catalog_list_escapes_rich_markup(self, project_dir, monkeypatch):
|
||||
"""User-editable catalog name/url/description must not be parsed as Rich markup."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
from specify_cli.workflows.catalog import WorkflowCatalog
|
||||
|
||||
monkeypatch.chdir(project_dir)
|
||||
configs = [
|
||||
{
|
||||
"name": "Bracket [Catalog]",
|
||||
"url": "https://example.com/[cat].json",
|
||||
"description": "desc [with] brackets",
|
||||
"install_allowed": True,
|
||||
},
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
WorkflowCatalog,
|
||||
"get_catalog_configs",
|
||||
lambda self: [dict(c) for c in configs],
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["workflow", "catalog", "list"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Bracket [Catalog]" in result.output
|
||||
assert "https://example.com/[cat].json" in result.output
|
||||
assert "desc [with] brackets" in result.output
|
||||
|
||||
def test_step_catalog_list_escapes_rich_markup(self, project_dir, monkeypatch):
|
||||
"""User-editable step-catalog name/url/description must not be parsed as Rich markup."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
from specify_cli.workflows.catalog import StepCatalog
|
||||
|
||||
monkeypatch.chdir(project_dir)
|
||||
configs = [
|
||||
{
|
||||
"name": "Bracket [Step]",
|
||||
"url": "https://example.com/[step].json",
|
||||
"description": "step [with] brackets",
|
||||
"install_allowed": True,
|
||||
},
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
StepCatalog,
|
||||
"get_catalog_configs",
|
||||
lambda self: [dict(c) for c in configs],
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["workflow", "step", "catalog", "list"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Bracket [Step]" in result.output
|
||||
assert "https://example.com/[step].json" in result.output
|
||||
assert "step [with] brackets" in result.output
|
||||
|
||||
# -- update ----------------------------------------------------------
|
||||
|
||||
def test_update_no_workflows_installed(self, project_dir, monkeypatch):
|
||||
|
||||
Reference in New Issue
Block a user