Skip to content

Commit 964324a

Browse files
fix(catalogs): validate cached extension and preset payload shape
Addresses Copilot review feedback on this PR (round 2). The earlier commits in this branch added payload-shape validation on the network fetch path. The cache-hit path still returned ``json.loads(cache_file.read_text())`` directly without re-checking the shape, so a cache poisoned by an older spec-kit version (or a manual edit, or an upstream that briefly served a bad payload before the network guards landed) would re-crash every invocation of ``_get_merged_extensions`` / ``_get_merged_packs`` with ``AttributeError: 'list' object has no attribute 'items'`` despite the cache being "valid" by age. Extracts the shape validation into ``_validate_catalog_payload`` on both ``ExtensionCatalog`` and ``PresetCatalog``, and calls it from both the cache-load and network-fetch branches of ``_fetch_single_catalog``. If the cached payload fails validation, the cache read is treated like a ``json.JSONDecodeError`` — the cached value is discarded and the function falls through to the network fetch, which refreshes the cache with a clean payload on success. Never propagates ``AttributeError`` to the caller. Regression tests parametrize the four root-bad-type variants plus three ``extensions``/``presets``-bad-type variants per file, asserting that a poisoned cache silently recovers via network refetch and returns the freshly-fetched payload.
1 parent df3823f commit 964324a

4 files changed

Lines changed: 244 additions & 51 deletions

File tree

src/specify_cli/extensions.py

Lines changed: 50 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1702,6 +1702,44 @@ def _open_url(self, url: str, timeout: int = 10):
17021702
from specify_cli.authentication.http import open_url
17031703
return open_url(url, timeout)
17041704

1705+
def _validate_catalog_payload(self, catalog_data: Any, url: str) -> None:
1706+
"""Validate a parsed catalog payload's shape.
1707+
1708+
Applied to both network-fetched and cache-loaded payloads so a
1709+
once-poisoned cache (older spec-kit version, manual edit, upstream
1710+
served a bad payload before the network-side guards were added)
1711+
cannot re-crash ``_get_merged_extensions`` on subsequent calls.
1712+
1713+
Checking only key presence would let a payload like
1714+
``{"extensions": []}`` or ``{"extensions": null}`` slip through
1715+
here and then crash with ``AttributeError: 'list' object has no
1716+
attribute 'items'`` deep inside ``_get_merged_extensions``. The
1717+
sibling integration catalog reader already guards both the root
1718+
object and the nested mapping (see ``integrations/catalog.py``);
1719+
the extension catalog must stay consistent so a malformed payload
1720+
surfaces as the user-facing ``Invalid catalog format`` error
1721+
instead of a raw Python traceback.
1722+
1723+
Args:
1724+
catalog_data: Parsed JSON payload from the catalog source.
1725+
url: Source URL — used in the error message so the user can
1726+
tell which catalog in a multi-catalog stack is malformed.
1727+
1728+
Raises:
1729+
ExtensionError: If the payload's shape is invalid.
1730+
"""
1731+
if not isinstance(catalog_data, dict):
1732+
raise ExtensionError(
1733+
f"Invalid catalog format from {url}: expected a JSON object"
1734+
)
1735+
if "schema_version" not in catalog_data or "extensions" not in catalog_data:
1736+
raise ExtensionError(f"Invalid catalog format from {url}")
1737+
if not isinstance(catalog_data.get("extensions"), dict):
1738+
raise ExtensionError(
1739+
f"Invalid catalog format from {url}: "
1740+
"'extensions' must be a JSON object"
1741+
)
1742+
17051743
def get_active_catalogs(self) -> List[CatalogEntry]:
17061744
"""Get the ordered list of active catalogs.
17071745
@@ -1827,39 +1865,27 @@ def _fetch_single_catalog(self, entry: CatalogEntry, force_refresh: bool = False
18271865
# If metadata is invalid or missing expected fields, treat cache as invalid
18281866
pass
18291867

1830-
# Use cache if valid
1868+
# Use cache if valid. A previously-cached payload must clear the
1869+
# same shape checks as a freshly-fetched one — otherwise a once-
1870+
# poisoned cache (older spec-kit version, manual edit, upstream
1871+
# served a bad payload before the network-side guards were added)
1872+
# would re-crash on every invocation despite the cache being
1873+
# "valid" by age. If validation fails on the cached read, fall
1874+
# through to the network fetch path so the cache gets refreshed.
18311875
if is_valid:
18321876
try:
1833-
return json.loads(cache_file.read_text())
1834-
except json.JSONDecodeError:
1877+
cached_data = json.loads(cache_file.read_text())
1878+
self._validate_catalog_payload(cached_data, entry.url)
1879+
return cached_data
1880+
except (json.JSONDecodeError, ExtensionError):
18351881
pass
18361882

18371883
# Fetch from network
18381884
try:
18391885
with self._open_url(entry.url, timeout=10) as response:
18401886
catalog_data = json.loads(response.read())
18411887

1842-
# Validate payload shape before iteration. Checking only key
1843-
# presence would let a payload like ``{"extensions": []}`` or
1844-
# ``{"extensions": null}`` slip through here and then crash with
1845-
# ``AttributeError: 'list' object has no attribute 'items'`` deep
1846-
# inside ``_get_merged_extensions``. The sibling integration
1847-
# catalog reader already guards both the root object and the
1848-
# nested mapping (see ``integrations/catalog.py``); the extension
1849-
# catalog must stay consistent so a malformed upstream surfaces as
1850-
# the user-facing ``Invalid catalog format`` error instead of a
1851-
# raw Python traceback.
1852-
if not isinstance(catalog_data, dict):
1853-
raise ExtensionError(
1854-
f"Invalid catalog format from {entry.url}: expected a JSON object"
1855-
)
1856-
if "schema_version" not in catalog_data or "extensions" not in catalog_data:
1857-
raise ExtensionError(f"Invalid catalog format from {entry.url}")
1858-
if not isinstance(catalog_data.get("extensions"), dict):
1859-
raise ExtensionError(
1860-
f"Invalid catalog format from {entry.url}: "
1861-
"'extensions' must be a JSON object"
1862-
)
1888+
self._validate_catalog_payload(catalog_data, entry.url)
18631889

18641890
# Save to cache
18651891
self.cache_dir.mkdir(parents=True, exist_ok=True)

src/specify_cli/presets.py

Lines changed: 53 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1860,6 +1860,48 @@ def _open_url(self, url: str, timeout: int = 10):
18601860
from specify_cli.authentication.http import open_url
18611861
return open_url(url, timeout)
18621862

1863+
def _validate_catalog_payload(self, catalog_data: Any, url: str) -> None:
1864+
"""Validate a parsed preset-catalog payload's shape.
1865+
1866+
Applied to both network-fetched and cache-loaded payloads so a
1867+
once-poisoned cache (older spec-kit version, manual edit, upstream
1868+
served a bad payload before the network-side guards were added)
1869+
cannot re-crash ``_get_merged_packs`` on subsequent calls.
1870+
1871+
Checking only key presence would let a payload like
1872+
``{"presets": []}`` or ``{"presets": null}`` slip through here and
1873+
then crash with ``AttributeError: 'list' object has no attribute
1874+
'items'`` deep inside ``_get_merged_packs``. The sibling
1875+
integration catalog reader already guards both the root object and
1876+
the nested mapping (see ``integrations/catalog.py``); the preset
1877+
catalog must stay consistent so a malformed payload surfaces as
1878+
the user-facing ``Invalid preset catalog format`` error instead of
1879+
a raw Python traceback.
1880+
1881+
Args:
1882+
catalog_data: Parsed JSON payload from the catalog source.
1883+
url: Source URL — used in the error message so the user can
1884+
tell which catalog in a multi-catalog stack is malformed.
1885+
1886+
Raises:
1887+
PresetError: If the payload's shape is invalid.
1888+
"""
1889+
if not isinstance(catalog_data, dict):
1890+
raise PresetError(
1891+
f"Invalid preset catalog format from {url}: "
1892+
"expected a JSON object"
1893+
)
1894+
if (
1895+
"schema_version" not in catalog_data
1896+
or "presets" not in catalog_data
1897+
):
1898+
raise PresetError("Invalid preset catalog format")
1899+
if not isinstance(catalog_data.get("presets"), dict):
1900+
raise PresetError(
1901+
f"Invalid preset catalog format from {url}: "
1902+
"'presets' must be a JSON object"
1903+
)
1904+
18631905
def _load_catalog_config(self, config_path: Path) -> Optional[List[PresetCatalogEntry]]:
18641906
"""Load catalog stack configuration from a YAML file.
18651907
@@ -2035,41 +2077,25 @@ def _fetch_single_catalog(self, entry: PresetCatalogEntry, force_refresh: bool =
20352077
"""
20362078
cache_file, metadata_file = self._get_cache_paths(entry.url)
20372079

2080+
# Use cache if valid. A previously-cached payload must clear the
2081+
# same shape checks as a freshly-fetched one — otherwise a once-
2082+
# poisoned cache would re-crash on every invocation despite the
2083+
# cache being "valid" by age. If validation fails on the cached
2084+
# read, fall through to the network fetch path so the cache gets
2085+
# refreshed.
20382086
if not force_refresh and self._is_url_cache_valid(entry.url):
20392087
try:
2040-
return json.loads(cache_file.read_text())
2041-
except json.JSONDecodeError:
2088+
cached_data = json.loads(cache_file.read_text())
2089+
self._validate_catalog_payload(cached_data, entry.url)
2090+
return cached_data
2091+
except (json.JSONDecodeError, PresetError):
20422092
pass
20432093

20442094
try:
20452095
with self._open_url(entry.url, timeout=10) as response:
20462096
catalog_data = json.loads(response.read())
20472097

2048-
# Validate payload shape before iteration. Checking only key
2049-
# presence would let a payload like ``{"presets": []}`` or
2050-
# ``{"presets": null}`` slip through here and then crash with
2051-
# ``AttributeError: 'list' object has no attribute 'items'`` deep
2052-
# inside ``_get_merged_packs``. The sibling integration catalog
2053-
# reader already guards both the root object and the nested
2054-
# mapping (see ``integrations/catalog.py``); the preset catalog
2055-
# must stay consistent so a malformed upstream surfaces as the
2056-
# user-facing ``Invalid preset catalog format`` error instead of
2057-
# a raw Python traceback.
2058-
if not isinstance(catalog_data, dict):
2059-
raise PresetError(
2060-
f"Invalid preset catalog format from {entry.url}: "
2061-
"expected a JSON object"
2062-
)
2063-
if (
2064-
"schema_version" not in catalog_data
2065-
or "presets" not in catalog_data
2066-
):
2067-
raise PresetError("Invalid preset catalog format")
2068-
if not isinstance(catalog_data.get("presets"), dict):
2069-
raise PresetError(
2070-
f"Invalid preset catalog format from {entry.url}: "
2071-
"'presets' must be a JSON object"
2072-
)
2098+
self._validate_catalog_payload(catalog_data, entry.url)
20732099

20742100
self.cache_dir.mkdir(parents=True, exist_ok=True)
20752101
cache_file.write_text(json.dumps(catalog_data, indent=2))

tests/test_extensions.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2622,6 +2622,76 @@ def test_fetch_single_catalog_rejects_malformed_payload(self, temp_dir, payload)
26222622
with pytest.raises(ExtensionError, match="Invalid catalog format"):
26232623
catalog._fetch_single_catalog(entry, force_refresh=True)
26242624

2625+
@pytest.mark.parametrize(
2626+
"cached_payload",
2627+
[
2628+
[],
2629+
"oops",
2630+
42,
2631+
None,
2632+
{"schema_version": "1.0", "extensions": []},
2633+
{"schema_version": "1.0", "extensions": "oops"},
2634+
{"schema_version": "1.0", "extensions": None},
2635+
],
2636+
)
2637+
def test_fetch_single_catalog_rejects_malformed_cached_payload(
2638+
self, temp_dir, cached_payload
2639+
):
2640+
"""A poisoned cache silently falls back to the network instead of
2641+
crashing — cached payloads pass through the same shape validation
2642+
as freshly-fetched ones.
2643+
2644+
Without this, a cache poisoned by an older spec-kit version (or a
2645+
manual edit, or an upstream that briefly served a bad payload
2646+
before the network guards landed) would re-crash every invocation
2647+
of ``_get_merged_extensions`` despite the cache being "valid" by
2648+
age. The recovery contract is: if the cached payload fails
2649+
validation, drop it and refetch — never propagate
2650+
``AttributeError`` to the caller.
2651+
"""
2652+
from unittest.mock import patch, MagicMock
2653+
2654+
catalog = self._make_catalog(temp_dir)
2655+
2656+
# Poison the default-URL cache. ``DEFAULT_CATALOG_URL`` is the
2657+
# branch that goes through ``is_cache_valid()`` (the non-default
2658+
# branch uses per-URL hashed cache files but the same code path
2659+
# below).
2660+
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
2661+
catalog.cache_file.write_text(json.dumps(cached_payload))
2662+
catalog.cache_metadata_file.write_text(
2663+
json.dumps(
2664+
{
2665+
"cached_at": datetime.now(timezone.utc).isoformat(),
2666+
"catalog_url": ExtensionCatalog.DEFAULT_CATALOG_URL,
2667+
}
2668+
)
2669+
)
2670+
2671+
# Network refetch returns a valid payload so the recovery path
2672+
# can complete.
2673+
valid = {
2674+
"schema_version": "1.0",
2675+
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
2676+
}
2677+
mock_response = MagicMock()
2678+
mock_response.read.return_value = json.dumps(valid).encode()
2679+
mock_response.__enter__ = lambda s: s
2680+
mock_response.__exit__ = MagicMock(return_value=False)
2681+
2682+
entry = CatalogEntry(
2683+
url=ExtensionCatalog.DEFAULT_CATALOG_URL,
2684+
name="default",
2685+
priority=1,
2686+
install_allowed=True,
2687+
)
2688+
2689+
with patch.object(catalog, "_open_url", return_value=mock_response):
2690+
result = catalog._fetch_single_catalog(entry, force_refresh=False)
2691+
2692+
# The poisoned cache was discarded and the network payload returned.
2693+
assert result == valid
2694+
26252695
def test_get_merged_extensions_skips_non_mapping_entries(self, temp_dir):
26262696
"""Per-entry guard: one malformed entry shouldn't poison the merge.
26272697

tests/test_presets.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1559,6 +1559,77 @@ def test_fetch_single_catalog_rejects_malformed_payload(self, project_dir, paylo
15591559
with pytest.raises(PresetError, match="Invalid preset catalog format"):
15601560
catalog._fetch_single_catalog(entry, force_refresh=True)
15611561

1562+
@pytest.mark.parametrize(
1563+
"cached_payload",
1564+
[
1565+
[],
1566+
"oops",
1567+
42,
1568+
None,
1569+
{"schema_version": "1.0", "presets": []},
1570+
{"schema_version": "1.0", "presets": "oops"},
1571+
{"schema_version": "1.0", "presets": None},
1572+
],
1573+
)
1574+
def test_fetch_single_catalog_rejects_malformed_cached_payload(
1575+
self, project_dir, cached_payload
1576+
):
1577+
"""A poisoned cache silently falls back to the network instead of
1578+
crashing — cached payloads pass through the same shape validation
1579+
as freshly-fetched ones.
1580+
1581+
Without this, a cache poisoned by an older spec-kit version (or a
1582+
manual edit, or an upstream that briefly served a bad payload
1583+
before the network guards landed) would re-crash every invocation
1584+
of ``_get_merged_packs`` despite the cache being "valid" by age.
1585+
The recovery contract is: if the cached payload fails validation,
1586+
drop it and refetch — never propagate ``AttributeError`` to the
1587+
caller.
1588+
"""
1589+
from unittest.mock import patch, MagicMock
1590+
1591+
catalog = PresetCatalog(project_dir)
1592+
1593+
# Poison the default-URL cache. ``DEFAULT_CATALOG_URL`` and
1594+
# non-default URLs both flow through the same cache-load branch.
1595+
cache_file, metadata_file = catalog._get_cache_paths(
1596+
catalog.DEFAULT_CATALOG_URL
1597+
)
1598+
cache_file.parent.mkdir(parents=True, exist_ok=True)
1599+
cache_file.write_text(json.dumps(cached_payload))
1600+
metadata_file.write_text(
1601+
json.dumps(
1602+
{
1603+
"cached_at": datetime.now(timezone.utc).isoformat(),
1604+
"catalog_url": catalog.DEFAULT_CATALOG_URL,
1605+
}
1606+
)
1607+
)
1608+
1609+
# Network refetch returns a valid payload so the recovery path
1610+
# can complete.
1611+
valid = {
1612+
"schema_version": "1.0",
1613+
"presets": {"foo": {"name": "Foo", "version": "1.0.0"}},
1614+
}
1615+
mock_response = MagicMock()
1616+
mock_response.read.return_value = json.dumps(valid).encode()
1617+
mock_response.__enter__ = lambda s: s
1618+
mock_response.__exit__ = MagicMock(return_value=False)
1619+
1620+
entry = PresetCatalogEntry(
1621+
url=catalog.DEFAULT_CATALOG_URL,
1622+
name="default",
1623+
priority=1,
1624+
install_allowed=True,
1625+
)
1626+
1627+
with patch.object(catalog, "_open_url", return_value=mock_response):
1628+
result = catalog._fetch_single_catalog(entry, force_refresh=False)
1629+
1630+
# The poisoned cache was discarded and the network payload returned.
1631+
assert result == valid
1632+
15621633
def test_get_merged_packs_skips_non_mapping_entries(self, project_dir):
15631634
"""Per-entry guard: one malformed entry shouldn't poison the merge.
15641635

0 commit comments

Comments
 (0)