fix(bundle): escape catalog metadata in discovery output (#3774)

* fix(bundle): escape catalog metadata in discovery output

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(bundle): escape provides fallback values

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Marsel Safin
2026-07-28 18:35:49 +02:00
committed by GitHub
parent 8bfe6e14d1
commit 054fb7723d
2 changed files with 171 additions and 14 deletions

View File

@@ -12,6 +12,7 @@ import re
from pathlib import Path
import typer
from rich.markup import escape as _escape_markup
from ..._console import console, err_console
from ..._download_security import MAX_DOWNLOAD_BYTES, read_response_limited
@@ -185,11 +186,16 @@ def bundle_search(
else ""
)
console.print(
f" [bold]{r.entry.id}[/bold] v{r.entry.version}{r.entry.name} "
f"[dim]({r.entry.role})[/dim] {_trust_badge(r.entry.verified)} {policy}"
f" [bold]{_escape_markup(str(r.entry.id))}[/bold] "
f"v{_escape_markup(str(r.entry.version))} "
f"{_escape_markup(str(r.entry.name))} "
f"[dim]({_escape_markup(str(r.entry.role))})[/dim] "
f"{_trust_badge(r.entry.verified)} {policy}"
)
console.print(f" {_escape_markup(str(r.entry.description))}")
console.print(
f" [dim]source: {_escape_markup(str(r.source.id))}[/dim]"
)
console.print(f" {r.entry.description}")
console.print(f" [dim]source: {r.source.id}[/dim]")
@bundle_app.command("info")
@@ -242,16 +248,31 @@ def bundle_info(
print(_json.dumps(payload, indent=2))
return
console.print(f"\n[bold cyan]{entry.id}[/bold cyan] v{entry.version}{entry.name}")
console.print(f" Role: {entry.role}")
console.print(f" {entry.description}")
console.print(f" Author: {entry.author} License: {entry.license}")
console.print(f" Source: {resolved.source.id} ({resolved.source.install_policy.value})")
console.print(
f"\n[bold cyan]{_escape_markup(str(entry.id))}[/bold cyan] "
f"v{_escape_markup(str(entry.version))}"
f"{_escape_markup(str(entry.name))}"
)
console.print(f" Role: {_escape_markup(str(entry.role))}")
console.print(f" {_escape_markup(str(entry.description))}")
console.print(
f" Author: {_escape_markup(str(entry.author))} "
f"License: {_escape_markup(str(entry.license))}"
)
console.print(
f" Source: {_escape_markup(str(resolved.source.id))} "
f"({resolved.source.install_policy.value})"
)
console.print(f" Trust: {_trust_badge(entry.verified)}")
if entry.requires_speckit_version:
console.print(f" Requires Spec Kit: {entry.requires_speckit_version}")
console.print(
f" Requires Spec Kit: "
f"{_escape_markup(str(entry.requires_speckit_version))}"
)
if manifest and manifest.integration:
console.print(f" Integration: {manifest.integration.id}")
console.print(
f" Integration: {_escape_markup(str(manifest.integration.id))}"
)
if components:
console.print("\n [bold]Components[/bold] (added on install):")
@@ -261,18 +282,22 @@ def bundle_info(
continue
console.print(f" [bold]{kind}:[/bold]")
for item in items:
console.print(f" - {_format_component(item)}")
console.print(
f" - {_escape_markup(_format_component(item))}"
)
else:
console.print("\n [bold]Provides:[/bold]")
for kind in ("extensions", "presets", "steps", "workflows"):
count = entry.provides.get(kind, 0)
if count:
console.print(f" {kind}: {count}")
console.print(f" {kind}: {_escape_markup(str(count))}")
if overlaps:
console.print("\n [yellow]Overlaps with already-installed bundles:[/yellow]")
for overlap in overlaps:
console.print(f" [yellow]-[/yellow] {overlap}")
console.print(
f" [yellow]-[/yellow] {_escape_markup(str(overlap))}"
)
if not resolved.install_allowed:
console.print(

View File

@@ -17,6 +17,7 @@ from typer.testing import CliRunner
from specify_cli import app
from specify_cli.bundler.services.packager import build_bundle
from tests.conftest import strip_ansi
from tests.bundler_helpers import (
catalog_entry_dict,
valid_manifest_dict,
@@ -25,6 +26,42 @@ from tests.bundler_helpers import (
runner = CliRunner()
MARKUP_BUNDLE_ID = "[red]markup-id[/red]"
MARKUP_SOURCE_ID = "[underline]markup-source[/underline]"
def _configure_markup_catalog(project: Path, **overrides: object) -> dict:
entry = catalog_entry_dict(
MARKUP_BUNDLE_ID,
name="[green]Markup Name[/green]",
version="[blue]1.0.0[/blue]",
role="[magenta]Markup Role[/magenta]",
description="[yellow]Markup Description[/yellow]",
author="[cyan]Markup Author[/cyan]",
license="[bold]Markup License[/bold]",
download_url="https://example.com/markup-bundle.zip",
requires={"speckit_version": "[italic]>=0.1.0[/italic]"},
**overrides,
)
catalog = project / "markup-catalog.json"
write_catalog_file(catalog, {MARKUP_BUNDLE_ID: entry})
config = {
"schema_version": "1.0",
"catalogs": [
{
"id": MARKUP_SOURCE_ID,
"url": str(catalog),
"priority": 1,
"install_policy": "install-allowed",
}
],
}
(project / ".specify" / "bundle-catalogs.yml").write_text(
yaml.safe_dump(config),
encoding="utf-8",
)
return entry
@pytest.fixture()
def project(tmp_path: Path, monkeypatch) -> Path:
@@ -124,6 +161,24 @@ def test_search_works_without_a_project(tmp_path: Path, monkeypatch):
assert result.output.strip().startswith("[")
def test_search_escapes_catalog_markup(project: Path):
entry = _configure_markup_catalog(project)
result = runner.invoke(app, ["bundle", "search", "--offline"])
assert result.exit_code == 0, result.output
output = " ".join(strip_ansi(result.output).split())
for value in (
entry["id"],
entry["name"],
entry["version"],
entry["role"],
entry["description"],
MARKUP_SOURCE_ID,
):
assert value in output
def test_info_unknown_bundle_without_project_reports_not_found(tmp_path: Path, monkeypatch):
monkeypatch.chdir(tmp_path) # no .specify/
result = runner.invoke(app, ["bundle", "info", "does-not-exist", "--offline"])
@@ -261,6 +316,83 @@ def test_info_expands_full_component_set(project: Path, monkeypatch):
assert "Trust" in text.output
def test_info_escapes_catalog_markup(project: Path, monkeypatch):
entry = _configure_markup_catalog(project)
bundle_dir = project / "markup-bundle"
bundle_dir.mkdir()
manifest_data = valid_manifest_dict()
manifest_data["bundle"]["id"] = MARKUP_BUNDLE_ID
manifest_data["integration"] = {
"id": "[conceal]markup-integration[/conceal]"
}
manifest_path = bundle_dir / "bundle.yml"
manifest_path.write_text(yaml.safe_dump(manifest_data), encoding="utf-8")
_mock_manifest_download(monkeypatch, manifest_path)
monkeypatch.setattr(
"specify_cli.commands.bundle._manifest_component_view",
lambda manifest: [
{
"kind": "extensions",
"id": "[reverse]markup-component[/reverse]",
"version": "[strike]2.0.0[/strike]",
}
],
)
monkeypatch.setattr(
"specify_cli.commands.bundle._bundle_overlaps",
lambda project_root, manifest, *, offline: [
"[blink]markup-overlap[/blink]"
],
)
result = runner.invoke(
app,
["bundle", "info", MARKUP_BUNDLE_ID, "--offline"],
)
assert result.exit_code == 0, result.output
output = " ".join(strip_ansi(result.output).split())
for value in (
entry["id"],
entry["name"],
entry["version"],
entry["role"],
entry["description"],
entry["author"],
entry["license"],
entry["requires"]["speckit_version"],
MARKUP_SOURCE_ID,
"[conceal]markup-integration[/conceal]",
"[reverse]markup-component[/reverse]",
"[strike]2.0.0[/strike]",
"[blink]markup-overlap[/blink]",
):
assert value in output
def test_info_escapes_catalog_provides_fallback_markup(project: Path, monkeypatch):
markup_count = "[bold]markup-count[/bold]"
_configure_markup_catalog(
project,
provides={"extensions": markup_count},
)
bundle_dir = project / "markup-bundle"
bundle_dir.mkdir()
manifest_data = valid_manifest_dict(provides={})
manifest_data["bundle"]["id"] = MARKUP_BUNDLE_ID
manifest_path = bundle_dir / "bundle.yml"
manifest_path.write_text(yaml.safe_dump(manifest_data), encoding="utf-8")
_mock_manifest_download(monkeypatch, manifest_path)
result = runner.invoke(
app,
["bundle", "info", MARKUP_BUNDLE_ID, "--offline"],
)
assert result.exit_code == 0, result.output
assert markup_count in strip_ansi(result.output)
def test_info_expands_discovery_only_bundle(project: Path, monkeypatch):
# Discovery-only bundles must still be fully inspectable via `info`;
# only `install` is refused for them.