Skip to content

Commit b048e33

Browse files
fix(presets): escape installed preset metadata in Rich output (#3826)
* fix(presets): escape installed preset metadata in Rich output `preset.yml` is user-editable, but the installed-preset display paths interpolated its fields straight into `console.print`, where Rich parses `[...]` as a style tag. PR #3773 escaped the *catalog* branch of these commands; the local branch was left behind, so the same field rendered correctly from a catalog and incorrectly once installed. Two failure modes: - Silent data loss: a description `Does [stuff] nicely` renders as `Does nicely`. - Hard crash: an unbalanced tag such as `Broken [/red] tag` raises `rich.errors.MarkupError`, aborting `preset list`/`preset info` with a traceback and exit code 1 — the preset cannot be inspected at all. Escaped the installed branch of `preset list` (name/id/version/ description) and `preset info` (name/id/version/description/author/tags/ repository/license plus the per-template description), and the catalog branch's tags join that the earlier sweep missed. `preset resolve` was unescaped throughout: it echoes its own `template_name` argument, so `preset resolve 'no[/red]such'` crashed on user input alone. Also escaped the resolved paths, layer sources, and composition-error message. Separately, the composition chain's `[{strategy_label}]` was consumed as a style tag, so every chain line printed a blank label instead of `[base]`/`[append]`. Escaped the literal bracket as `\[`, matching the step-graph line in `workflow info`. Regression tests in `TestInstalledPresetRichMarkup` cover all five behaviours; each fails before this change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(presets): cover catalog tags and resolve escapes Addresses Copilot review feedback on #3826: two escapes added by the previous commit had no regression assertion, so they could be reverted with the suite still green. - `test_info_escapes_catalog_markup` asserted every catalog field except `tags`; the new tag assertion only exercised an installed preset. Assert the rendered tags join in the catalog branch too. - The escapes on `preset resolve`'s resolved path, layer source, and composition-error message were untested. Add three cases patching `PresetResolver` to feed markup through the top-layer line, the no-layer `resolve_with_source` fallback, and a markup-bearing `resolve_content` exception. Test-the-test: with `_commands.py` reverted to the pre-fix revision, 9 of the 10 markup tests fail (was 5); with the fix applied all 10 pass. A closing tag cannot be embedded in the mocked path — `Path` treats the `/` as a separator — so the path assertion uses an opening tag for the swallowing case and the unbalanced tag rides on the adjacent `source` field on the same line. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Assisted-by: Claude Code (model: claude-opus-5, under direct human supervision) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d99170f commit b048e33

2 files changed

Lines changed: 263 additions & 19 deletions

File tree

src/specify_cli/presets/_commands.py

Lines changed: 53 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,11 @@ def preset_list():
5959
for pack in installed:
6060
status = "[green]enabled[/green]" if pack.get("enabled", True) else "[red]disabled[/red]"
6161
pri = pack.get('priority', 10)
62-
console.print(f" [bold]{pack['name']}[/bold] ({pack['id']}) v{pack['version']}{status} — priority {pri}")
63-
console.print(f" {pack['description']}")
62+
name = _escape_markup(str(pack['name']))
63+
pack_id = _escape_markup(str(pack['id']))
64+
version = _escape_markup(str(pack['version']))
65+
console.print(f" [bold]{name}[/bold] ({pack_id}) v{version}{status} — priority {pri}")
66+
console.print(f" {_escape_markup(str(pack['description']))}")
6467
tags = pack.get("tags", [])
6568
if isinstance(tags, list) and tags:
6669
tags_str = _escape_markup(", ".join(str(t) for t in tags))
@@ -317,13 +320,20 @@ def preset_resolve(
317320
project_root = _require_specify_project()
318321
resolver = PresetResolver(project_root)
319322
layers = resolver.collect_all_layers(template_name)
323+
safe_template_name = _escape_markup(str(template_name))
320324

321325
if layers:
322326
# Use the highest-priority layer for display because the final output
323327
# may be composed and may not map to resolve_with_source()'s single path.
324328
display_layer = layers[0]
325-
console.print(f" [bold]{template_name}[/bold]: {display_layer['path']}")
326-
console.print(f" [dim](top layer from: {display_layer['source']})[/dim]")
329+
console.print(
330+
f" [bold]{safe_template_name}[/bold]: "
331+
f"{_escape_markup(str(display_layer['path']))}"
332+
)
333+
console.print(
334+
f" [dim](top layer from: "
335+
f"{_escape_markup(str(display_layer['source']))})[/dim]"
336+
)
327337

328338
has_composition = (
329339
layers[0]["strategy"] != "replace"
@@ -335,7 +345,10 @@ def preset_resolve(
335345
composed = resolver.resolve_content(template_name)
336346
except Exception as exc:
337347
composed = None
338-
console.print(f" [yellow]Warning: composition error: {exc}[/yellow]")
348+
console.print(
349+
f" [yellow]Warning: composition error: "
350+
f"{_escape_markup(str(exc))}[/yellow]"
351+
)
339352
if composed is None:
340353
console.print(" [yellow]Warning: composition cannot produce output (no base layer with 'replace' strategy)[/yellow]")
341354
else:
@@ -358,15 +371,27 @@ def preset_resolve(
358371
strategy_label = layer["strategy"]
359372
if strategy_label == "replace" and i == 0:
360373
strategy_label = "base"
361-
console.print(f" {i + 1}. [{strategy_label}] {layer['source']}{layer['path']}")
374+
# Escape the literal bracket (\[) so Rich renders `[<strategy>]`
375+
# instead of parsing it as a style tag and swallowing the label,
376+
# mirroring `workflow info`'s step-graph line.
377+
console.print(
378+
f" {i + 1}. \\[{_escape_markup(str(strategy_label))}] "
379+
f"{_escape_markup(str(layer['source']))} → "
380+
f"{_escape_markup(str(layer['path']))}"
381+
)
362382
else:
363383
# No layers found — fall back to resolve_with_source for non-composition cases
364384
result = resolver.resolve_with_source(template_name)
365385
if result:
366-
console.print(f" [bold]{template_name}[/bold]: {result['path']}")
367-
console.print(f" [dim](from: {result['source']})[/dim]")
386+
console.print(
387+
f" [bold]{safe_template_name}[/bold]: "
388+
f"{_escape_markup(str(result['path']))}"
389+
)
390+
console.print(
391+
f" [dim](from: {_escape_markup(str(result['source']))})[/dim]"
392+
)
368393
else:
369-
console.print(f" [yellow]{template_name}[/yellow]: not found")
394+
console.print(f" [yellow]{safe_template_name}[/yellow]: not found")
370395
console.print(" [dim]No template with this name exists in the resolution stack[/dim]")
371396

372397

@@ -386,24 +411,32 @@ def preset_info(
386411
local_pack = manager.get_pack(preset_id)
387412

388413
if local_pack:
389-
console.print(f"\n[bold cyan]Preset: {local_pack.name}[/bold cyan]\n")
390-
console.print(f" ID: {local_pack.id}")
391-
console.print(f" Version: {local_pack.version}")
392-
console.print(f" Description: {local_pack.description}")
414+
console.print(
415+
f"\n[bold cyan]Preset: {_escape_markup(str(local_pack.name))}[/bold cyan]\n"
416+
)
417+
console.print(f" ID: {_escape_markup(str(local_pack.id))}")
418+
console.print(f" Version: {_escape_markup(str(local_pack.version))}")
419+
console.print(
420+
f" Description: {_escape_markup(str(local_pack.description))}"
421+
)
393422
if local_pack.author:
394-
console.print(f" Author: {local_pack.author}")
423+
console.print(f" Author: {_escape_markup(str(local_pack.author))}")
395424
local_tags = local_pack.tags
396425
if isinstance(local_tags, list) and local_tags:
397-
console.print(f" Tags: {', '.join(str(t) for t in local_tags)}")
426+
tags_str = _escape_markup(", ".join(str(t) for t in local_tags))
427+
console.print(f" Tags: {tags_str}")
398428
console.print(f" Templates: {len(local_pack.templates)}")
399429
for tmpl in local_pack.templates:
400-
console.print(f" - {tmpl['name']} ({tmpl['type']}): {tmpl.get('description', '')}")
430+
tmpl_name = _escape_markup(str(tmpl['name']))
431+
tmpl_type = _escape_markup(str(tmpl['type']))
432+
tmpl_desc = _escape_markup(str(tmpl.get('description', '')))
433+
console.print(f" - {tmpl_name} ({tmpl_type}): {tmpl_desc}")
401434
repo = local_pack.data.get("preset", {}).get("repository")
402435
if repo:
403-
console.print(f" Repository: {repo}")
436+
console.print(f" Repository: {_escape_markup(str(repo))}")
404437
license_val = local_pack.data.get("preset", {}).get("license")
405438
if license_val:
406-
console.print(f" License: {license_val}")
439+
console.print(f" License: {_escape_markup(str(license_val))}")
407440
console.print("\n [green]Status: installed[/green]")
408441
# Get priority from registry
409442
pack_metadata = manager.registry.get(preset_id)
@@ -438,7 +471,8 @@ def preset_info(
438471
)
439472
catalog_tags = pack_info.get("tags", [])
440473
if isinstance(catalog_tags, list) and catalog_tags:
441-
console.print(f" Tags: {', '.join(str(t) for t in catalog_tags)}")
474+
catalog_tags_str = _escape_markup(", ".join(str(t) for t in catalog_tags))
475+
console.print(f" Tags: {catalog_tags_str}")
442476
if pack_info.get("repository"):
443477
console.print(
444478
f" Repository: {_escape_markup(str(pack_info['repository']))}"

tests/test_presets.py

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12209,3 +12209,213 @@ def test_info_escapes_catalog_markup(self, project_dir):
1220912209
):
1221012210
value = self.MARKUP_PRESET[field]
1221112211
assert value in output
12212+
# Tags are joined into a single line, so assert on the rendered join.
12213+
assert ", ".join(self.MARKUP_PRESET["tags"]) in output
12214+
12215+
12216+
class TestInstalledPresetRichMarkup:
12217+
"""Locally installed preset metadata must render as literal text.
12218+
12219+
``preset.yml`` is user-editable, so its fields can contain ``[...]``.
12220+
``TestPresetCatalogRichMarkup`` covers the catalog branch of these
12221+
commands; the installed-preset branch of ``preset list``/``preset info``
12222+
and all of ``preset resolve`` were left unescaped, so a field like
12223+
``Does [stuff] nicely`` silently rendered as ``Does nicely`` and an
12224+
unbalanced tag such as ``[/red]`` raised ``rich.errors.MarkupError``,
12225+
aborting the command with a traceback.
12226+
"""
12227+
12228+
MARKUP_FIELDS = {
12229+
"name": "[green]Markup Name[/green]",
12230+
"version": "1.0.0",
12231+
"description": "[yellow]Markup Description[/yellow]",
12232+
"author": "[magenta]Markup Author[/magenta]",
12233+
"repository": "[bold]Markup Repository[/bold]",
12234+
"license": "[cyan]Markup License[/cyan]",
12235+
}
12236+
12237+
def _install(self, temp_dir, project_dir, preset_overrides=None, strategy=None,
12238+
pack_id="markup-pack", priority=10, tmpl_description=None):
12239+
"""Install a preset from a directory built with the given manifest fields."""
12240+
from specify_cli.presets import PresetManager
12241+
12242+
src = temp_dir / f"src-{pack_id}"
12243+
(src / "templates").mkdir(parents=True)
12244+
(src / "templates" / "spec-template.md").write_text("# tmpl\n")
12245+
12246+
preset_section = {
12247+
"id": pack_id,
12248+
"name": pack_id,
12249+
"version": "1.0.0",
12250+
"description": "plain description",
12251+
}
12252+
preset_section.update(preset_overrides or {})
12253+
tmpl = {
12254+
"type": "template",
12255+
"name": "spec-template",
12256+
"file": "templates/spec-template.md",
12257+
}
12258+
if tmpl_description is not None:
12259+
tmpl["description"] = tmpl_description
12260+
if strategy:
12261+
tmpl["strategy"] = strategy
12262+
(src / "preset.yml").write_text(yaml.dump({
12263+
"schema_version": "1.0",
12264+
"preset": preset_section,
12265+
"requires": {"speckit_version": ">=0.0.1"},
12266+
"provides": {"templates": [tmpl]},
12267+
"tags": ["[italic]markup-tag[/italic]"],
12268+
}))
12269+
12270+
manager = PresetManager(project_dir)
12271+
manager.install_from_directory(src, "9.9.9", priority)
12272+
return manager
12273+
12274+
def _invoke(self, project_dir, args):
12275+
from typer.testing import CliRunner
12276+
from unittest.mock import patch
12277+
from specify_cli import app
12278+
12279+
with patch.object(Path, "cwd", return_value=project_dir):
12280+
return CliRunner().invoke(app, args)
12281+
12282+
def test_list_and_info_escape_installed_markup(self, temp_dir, project_dir):
12283+
"""Every ``preset.yml`` field must survive verbatim in list/info output."""
12284+
self._install(temp_dir, project_dir, preset_overrides=self.MARKUP_FIELDS)
12285+
12286+
for args in (["preset", "list"], ["preset", "info", "markup-pack"]):
12287+
result = self._invoke(project_dir, args)
12288+
assert result.exit_code == 0, result.output
12289+
output = " ".join(strip_ansi(result.output).split())
12290+
# `preset list` does not render repository/license.
12291+
fields = ("name", "description") if args[1] == "list" else self.MARKUP_FIELDS
12292+
for field in fields:
12293+
assert self.MARKUP_FIELDS[field] in output, (field, args, output)
12294+
assert "[italic]markup-tag[/italic]" in output, (args, output)
12295+
12296+
def test_info_does_not_swallow_template_description(self, temp_dir, project_dir):
12297+
"""The per-template line in ``preset info`` must escape the template description.
12298+
12299+
``name``/``type`` are format-restricted by manifest validation, but
12300+
``description`` is free-form, so it is the field that can carry markup.
12301+
"""
12302+
self._install(
12303+
temp_dir,
12304+
project_dir,
12305+
tmpl_description="Template [desc] here",
12306+
)
12307+
result = self._invoke(project_dir, ["preset", "info", "markup-pack"])
12308+
assert result.exit_code == 0, result.output
12309+
output = " ".join(strip_ansi(result.output).split())
12310+
assert "spec-template (template): Template [desc] here" in output, output
12311+
12312+
def test_unbalanced_markup_does_not_crash_list_or_info(self, temp_dir, project_dir):
12313+
"""An unbalanced tag must not raise MarkupError and abort the command."""
12314+
self._install(
12315+
temp_dir,
12316+
project_dir,
12317+
preset_overrides={"description": "Broken [/red] tag"},
12318+
)
12319+
12320+
for args in (["preset", "list"], ["preset", "info", "markup-pack"]):
12321+
result = self._invoke(project_dir, args)
12322+
assert result.exit_code == 0, (args, result.output, result.exception)
12323+
assert "Broken [/red] tag" in strip_ansi(result.output)
12324+
12325+
def test_resolve_escapes_template_name(self, project_dir):
12326+
"""``preset resolve`` echoes its argument; an unbalanced tag must not crash."""
12327+
result = self._invoke(project_dir, ["preset", "resolve", "no[/red]such"])
12328+
assert result.exit_code == 0, (result.output, result.exception)
12329+
assert "no[/red]such" in strip_ansi(result.output)
12330+
12331+
def test_resolve_escapes_layer_path_and_source(self, project_dir):
12332+
"""The top-layer path/source lines must render markup literally.
12333+
12334+
A preset can be installed from any directory, so the resolved path can
12335+
contain ``[...]``; the layer source carries the pack id and version.
12336+
"""
12337+
from unittest.mock import patch
12338+
from specify_cli.presets import PresetResolver
12339+
12340+
# A closing tag cannot live inside a path segment: `Path` treats its
12341+
# `/` as a separator on POSIX and rewrites it to `\` on Windows. The
12342+
# opening tag covers the swallowing case for the path; the unbalanced
12343+
# closing tag rides on `source`, which is a plain string.
12344+
layer = {
12345+
"path": Path("/tmp/[red]dir/spec-template.md"),
12346+
"source": "pack [/red] v1.0.0",
12347+
"strategy": "replace",
12348+
}
12349+
with patch.object(PresetResolver, "collect_all_layers", return_value=[layer]):
12350+
result = self._invoke(project_dir, ["preset", "resolve", "spec-template"])
12351+
12352+
assert result.exit_code == 0, (result.output, result.exception)
12353+
output = " ".join(strip_ansi(result.output).split())
12354+
assert "[red]dir" in output, output
12355+
assert "pack [/red] v1.0.0" in output, output
12356+
12357+
def test_resolve_escapes_fallback_path_and_source(self, project_dir):
12358+
"""The no-layer fallback branch must escape ``resolve_with_source`` output."""
12359+
from unittest.mock import patch
12360+
from specify_cli.presets import PresetResolver
12361+
12362+
with patch.object(
12363+
PresetResolver, "collect_all_layers", return_value=[]
12364+
), patch.object(
12365+
PresetResolver,
12366+
"resolve_with_source",
12367+
return_value={
12368+
"path": "/tmp/[blue]fallback[/blue]/spec-template.md",
12369+
"source": "fallback [/red] source",
12370+
},
12371+
):
12372+
result = self._invoke(project_dir, ["preset", "resolve", "spec-template"])
12373+
12374+
assert result.exit_code == 0, (result.output, result.exception)
12375+
output = " ".join(strip_ansi(result.output).split())
12376+
assert "[blue]fallback[/blue]" in output, output
12377+
assert "fallback [/red] source" in output, output
12378+
12379+
def test_resolve_escapes_composition_error(self, project_dir):
12380+
"""A composition exception message must not be parsed as markup."""
12381+
from unittest.mock import patch
12382+
from specify_cli.presets import PresetResolver
12383+
12384+
layers = [
12385+
{
12386+
"path": Path("/tmp/top/spec-template.md"),
12387+
"source": "top-pack v1.0.0",
12388+
"strategy": "append",
12389+
},
12390+
{
12391+
"path": Path("/tmp/base/spec-template.md"),
12392+
"source": "base-pack v1.0.0",
12393+
"strategy": "append",
12394+
},
12395+
]
12396+
with patch.object(
12397+
PresetResolver, "collect_all_layers", return_value=layers
12398+
), patch.object(
12399+
PresetResolver,
12400+
"resolve_content",
12401+
side_effect=RuntimeError("compose failed: [/red] bad layer"),
12402+
):
12403+
result = self._invoke(project_dir, ["preset", "resolve", "spec-template"])
12404+
12405+
assert result.exit_code == 0, (result.output, result.exception)
12406+
output = " ".join(strip_ansi(result.output).split())
12407+
assert "compose failed: [/red] bad layer" in output, output
12408+
12409+
def test_resolve_renders_composition_strategy_labels(self, temp_dir, project_dir):
12410+
"""The composition chain's ``[<strategy>]`` label must not be eaten as a tag."""
12411+
self._install(temp_dir, project_dir, strategy="replace",
12412+
pack_id="base-pack", priority=20)
12413+
self._install(temp_dir, project_dir, strategy="append",
12414+
pack_id="app-pack", priority=5)
12415+
12416+
result = self._invoke(project_dir, ["preset", "resolve", "spec-template"])
12417+
assert result.exit_code == 0, (result.output, result.exception)
12418+
output = strip_ansi(result.output)
12419+
assert "Composition chain" in output, output
12420+
assert "[base]" in output, output
12421+
assert "[append]" in output, output

0 commit comments

Comments
 (0)