fix(extensions): guard non-numeric catalog downloads in search/info rendering (#3710)

* fix(extensions): guard non-numeric catalog downloads in search/info rendering

`specify extension search` and `specify extension info <id>` format a catalog
entry's `downloads` field with the `:,` thousands separator, guarded only by
`is not None`. Catalog payloads are only shape-validated -- individual fields
are never type-checked and `_get_merged_extensions` returns raw catalog dicts
-- so an entry with a non-numeric `downloads` (e.g. the JSON string "1500",
realistic from a community / SPECKIT_CATALOG_URL / project catalog) makes the
`:,` format raise `ValueError: Cannot specify ',' with 's'`, aborting the
whole command with an uncaught traceback.

Group-format `downloads` only when it is actually numeric; otherwise render it
as-is. Numeric values (int/float, incl. bool) format identically, so correct
catalogs are byte-for-byte unchanged. Every other field in these two renderers
is already `str()`-wrapped; this closes the one unguarded field.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(extensions): escape the non-numeric downloads fallback for Rich markup

Address review feedback: the fallback interpolated the untrusted catalog value
straight into a Rich-rendered string, so guarding the ``:,`` ValueError just
traded it for a MarkupError -- a catalog entry with downloads "[/red]foo" still
aborted `extension search`/`info`, and balanced tags could restyle the output.

Wrap the fallback in _escape_markup(str(...)) at both sites, matching how every
other catalog field in these renderers is already escaped. Numeric values keep
the identical ``:,`` branch, so correct catalogs are unchanged.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(extensions): escape 'stars' too, in the same stats string

Follow-up to the downloads escaping: `stars` is the other catalog-controlled
value joined into the same Rich-rendered stats line, and it was still raw --
verified that stars "[/red]x" raises the same MarkupError and aborts
`extension info`/`search`. Hardening one of the two adjacent values would have
left the reported defect reachable through the sibling field.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ali jawwad
2026-07-27 21:25:01 +05:00
committed by GitHub
parent 9e150cd3b2
commit c1028e5506
2 changed files with 121 additions and 8 deletions

View File

@@ -790,10 +790,24 @@ def extension_search(
# Stats
stats = []
if ext.get('downloads') is not None:
stats.append(f"Downloads: {ext['downloads']:,}")
if ext.get('stars') is not None:
stats.append(f"Stars: {ext['stars']}")
downloads = ext.get('downloads')
if downloads is not None:
# Catalog fields are untrusted; a non-numeric ``downloads``
# (e.g. the JSON string "1500") would crash the ``:,`` format
# with "Cannot specify ',' with 's'". Only group-format numbers,
# and escape the fallback: the joined stats are rendered as Rich
# markup, so a value like "[/red]foo" would raise MarkupError
# (matching how every other catalog field here is escaped).
stats.append(
f"Downloads: {downloads:,}"
if isinstance(downloads, (int, float))
else f"Downloads: {_escape_markup(str(downloads))}"
)
stars = ext.get('stars')
if stars is not None:
# Same untrusted-value/Rich-markup hazard as `downloads` above,
# in the same joined string.
stats.append(f"Stars: {_escape_markup(str(stars))}")
if stats:
console.print(f" [dim]{' | '.join(stats)}[/dim]")
@@ -971,10 +985,24 @@ def _print_extension_info(ext_info: dict, manager):
# Statistics
stats = []
if ext_info.get('downloads') is not None:
stats.append(f"Downloads: {ext_info['downloads']:,}")
if ext_info.get('stars') is not None:
stats.append(f"Stars: {ext_info['stars']}")
downloads = ext_info.get('downloads')
if downloads is not None:
# Catalog fields are untrusted; a non-numeric ``downloads`` (e.g. the
# JSON string "1500") would crash the ``:,`` format with "Cannot
# specify ',' with 's'". Only group-format numbers, and escape the
# fallback: the joined stats are rendered as Rich markup, so a value
# like "[/red]foo" would raise MarkupError (matching how every other
# catalog field here is escaped).
stats.append(
f"Downloads: {downloads:,}"
if isinstance(downloads, (int, float))
else f"Downloads: {_escape_markup(str(downloads))}"
)
stars = ext_info.get('stars')
if stars is not None:
# Same untrusted-value/Rich-markup hazard as `downloads` above, in the
# same joined string.
stats.append(f"Stars: {_escape_markup(str(stars))}")
if stats:
console.print(f"[bold]Statistics:[/bold] {' | '.join(stats)}")
console.print()