Skip to content

fix(presets): escape installed preset metadata in Rich output - #3826

Merged
mnriem merged 2 commits into
github:mainfrom
Noor-ul-ain001:fix/preset-installed-metadata-markup
Jul 29, 2026
Merged

fix(presets): escape installed preset metadata in Rich output#3826
mnriem merged 2 commits into
github:mainfrom
Noor-ul-ain001:fix/preset-installed-metadata-markup

Conversation

@Noor-ul-ain001

Copy link
Copy Markdown
Contributor

Problem

preset.yml is user-editable, but the installed-preset display paths interpolate its fields straight into console.print, where Rich parses [...] as a style tag.

#3773 escaped the catalog branch of these commands; the local branch was left behind — so the same field renders correctly from a catalog and incorrectly once installed.

Silent data loss. Description Does [stuff] nicely:

  Description: Does  nicely

Hard crash. An unbalanced tag like Broken [/red] tag raises rich.errors.MarkupError, aborting with a traceback and exit 1 — the preset can't be inspected at all:

$ specify preset info markup-pack
  ID:          markup-pack
  Version:     1.0.0
MarkupError: closing tag '[/red]' at position 22 doesn't match any open tag

preset resolve was unescaped throughout, and it echoes its own argument, so this crashes on user input alone with no preset installed:

$ specify preset resolve 'no[/red]such'
MarkupError: closing tag '[/red]' at position 12 doesn't match any open tag

Separately, the composition chain's literal [{strategy_label}] was itself consumed as a style tag, so every chain line printed a blank label:

  Composition chain:
    1.  base-pack v1.0.0 → ...      # should read "1. [base] ..."
    2.  app-pack v1.0.0 → ...       # should read "2. [append] ..."

Fix

Route the untrusted fields through the already-imported _escape_markup:

  • preset list (installed branch) — name, id, version, description
  • preset info (installed branch) — name, id, version, description, author, tags, repository, license, and the per-template description
  • preset info (catalog branch) — the tags join the earlier sweep missed
  • preset resolve — the template_name argument, resolved paths, layer sources, composition-error message
  • composition chain — escaped the literal bracket as \[, matching the step-graph line in workflow info

No behaviour change beyond rendering: fields now display verbatim instead of being swallowed or crashing.

Tests

TestInstalledPresetRichMarkup in tests/test_presets.py — 5 tests covering markup fields in list/info, the template description line, the unbalanced-tag crash, the preset resolve argument crash, and the composition strategy labels.

Test-the-test: with the source fix stashed, all 5 fail (4 on markup assertions, 1 on the swallowed template description); with it applied, all 5 pass.

pytest tests/test_presets.py475 passed, 7 failed, 1 error, matching the pre-existing baseline on clean main for this Windows box (symlink tests requiring elevation + one unrelated GHES-asset error). None are in the touched paths.

🤖 Generated with Claude Code

`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 github#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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Escapes user-controlled preset metadata so Rich renders it literally instead of swallowing tags or crashing.

Changes:

  • Escapes installed preset and catalog metadata.
  • Safely renders resolve paths, sources, errors, and strategy labels.
  • Adds Rich-markup regression tests.
Show a summary per file
File Description
src/specify_cli/presets/_commands.py Escapes untrusted Rich output.
tests/test_presets.py Adds installed-preset markup tests.

Review details

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 2/2 changed files
  • Comments generated: 2
  • Review effort level: Medium

Comment thread src/specify_cli/presets/_commands.py
Comment thread tests/test_presets.py

@mnriem mnriem left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please address Copilot feedback

Addresses Copilot review feedback on github#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)
@Noor-ul-ain001

Copy link
Copy Markdown
Contributor Author

Thanks — both Copilot comments addressed in eb667ec (tests only, no source change).

Catalog tags assertion. test_info_escapes_catalog_markup now asserts the rendered tags join, so the catalog-branch escape is covered by its own branch's test rather than riding on the installed-preset one.

Untested preset resolve escapes. Added three cases patching PresetResolver:

  • test_resolve_escapes_layer_path_and_source — markup through the top-layer path/source line
  • test_resolve_escapes_fallback_path_and_source — the no-layer resolve_with_source branch
  • test_resolve_escapes_composition_error — a markup-bearing resolve_content exception

One note on the path assertion: a closing tag can't be embedded in a mocked Path (the / in [/red] is read as a separator), so the path carries an opening tag for the swallowing case and the unbalanced tag rides on the adjacent source field printed on the same line.

Test-the-test: with _commands.py reverted to the pre-fix revision, 9 of the 10 markup tests now fail (was 5); with the fix applied all 10 pass. Full tests/test_presets.py → 478 passed, 7 failed, 1 error — unchanged pre-existing baseline on this Windows box (symlink tests needing elevation + one unrelated GHES-asset error).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 2/2 changed files
  • Comments generated: 0 new
  • Review effort level: Medium

@mnriem
mnriem self-requested a review July 29, 2026 12:47
@mnriem
mnriem merged commit b048e33 into github:main Jul 29, 2026
14 checks passed
@mnriem

mnriem commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants