Skip to content

Commit a6743ab

Browse files
jawwad-aliclaude
andauthored
fix(integrations): validate cached catalog shape before returning it (#3627)
* fix(integrations): validate cached catalog shape before returning it The catalog cache-read branch returned json.loads(cache_file) directly, skipping the shape validation the fresh-fetch branch enforces (dict root + 'integrations' mapping). A poisoned or older-format cache (e.g. {"integrations": []}) was therefore returned as-is and later crashed with 'AttributeError: list object has no attribute items' when the caller iterated integrations. Validate the cached object the same way; the raised ValueError is already caught by the surrounding handler, which drops the corrupt cache and refetches from source. Test: a fresh-but-mis-shaped cache is dropped and the valid source refetched (fails before: AttributeError). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(integrations): share one catalog-shape validator across cache and fetch Address review: the cache-read path checked only that the payload was a dict with a dict 'integrations', while the fresh-fetch path also required 'schema_version'. That asymmetry let an older/poisoned cache such as {"integrations": {}} (no schema_version) bypass the format contract instead of being dropped and refetched. Introduce a shared `_catalog_shape_error()` helper and use it in both paths so they enforce the same contract (dict + schema_version + dict integrations). The fresh path still raises IntegrationCatalogError with the "Invalid catalog format from <url>" prefix; the cache path still raises ValueError (caught to drop+refetch). Add a test for the missing-schema_version cache case. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(integrations): unit-test the shared catalog-shape validator directly Replace the integration-level missing-schema_version cache test (which was masked by multi-source merging — a sibling catalog source still supplied the entry, so it passed regardless of the fix) with a direct unit test of _catalog_shape_error. This deterministically proves both paths now reject a payload missing schema_version, a non-dict integrations, or a non-dict payload, and accept a well-formed one. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3b9deec commit a6743ab

2 files changed

Lines changed: 101 additions & 14 deletions

File tree

src/specify_cli/integrations/catalog.py

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,25 @@ class IntegrationDescriptorError(Exception):
4040
"""Raised when an integration.yml descriptor is invalid."""
4141

4242

43+
def _catalog_shape_error(payload: Any) -> Optional[str]:
44+
"""Return a human-readable reason if *payload* is not a valid integration
45+
catalog document, else ``None``.
46+
47+
Shared by the fresh-fetch and cache-read paths so both enforce the same
48+
format contract: a JSON object carrying ``schema_version`` and a mapping
49+
``integrations``. Keeping a single validator prevents the two paths from
50+
drifting (e.g. a cache that skips the ``schema_version`` check and lets an
51+
older/poisoned payload bypass validation).
52+
"""
53+
if not isinstance(payload, dict):
54+
return "expected a JSON object"
55+
if "schema_version" not in payload or "integrations" not in payload:
56+
return "missing required 'schema_version' or 'integrations' key"
57+
if not isinstance(payload.get("integrations"), dict):
58+
return "'integrations' must be a JSON object"
59+
return None
60+
61+
4362
# ---------------------------------------------------------------------------
4463
# IntegrationCatalogEntry
4564
# ---------------------------------------------------------------------------
@@ -153,7 +172,18 @@ def _fetch_single_catalog(
153172
cached_at = cached_at.replace(tzinfo=timezone.utc)
154173
age = (datetime.now(timezone.utc) - cached_at).total_seconds()
155174
if age < self.CACHE_DURATION:
156-
return json.loads(cache_file.read_text(encoding="utf-8"))
175+
cached = json.loads(cache_file.read_text(encoding="utf-8"))
176+
# A poisoned/older-format cache must clear the SAME shape
177+
# contract as a fresh fetch (via the shared validator) —
178+
# otherwise a payload like [], {"integrations": []}, or one
179+
# missing "schema_version" is returned and later crashes on
180+
# .items()/.get() or silently bypasses the format contract.
181+
# The ValueError is caught just below, which drops the
182+
# corrupt cache and refetches from source.
183+
shape_error = _catalog_shape_error(cached)
184+
if shape_error is not None:
185+
raise ValueError(f"cached catalog has invalid shape: {shape_error}")
186+
return cached
157187
except (json.JSONDecodeError, ValueError, KeyError, TypeError, AttributeError, OSError, UnicodeError):
158188
# Cache is invalid or stale metadata; delete and refetch from source.
159189
try:
@@ -172,20 +202,10 @@ def _fetch_single_catalog(
172202
self._validate_catalog_url(final_url)
173203
catalog_data = json.loads(resp.read())
174204

175-
if not isinstance(catalog_data, dict):
176-
raise IntegrationCatalogError(
177-
f"Invalid catalog format from {entry.url}: expected a JSON object"
178-
)
179-
if (
180-
"schema_version" not in catalog_data
181-
or "integrations" not in catalog_data
182-
):
183-
raise IntegrationCatalogError(
184-
f"Invalid catalog format from {entry.url}"
185-
)
186-
if not isinstance(catalog_data.get("integrations"), dict):
205+
shape_error = _catalog_shape_error(catalog_data)
206+
if shape_error is not None:
187207
raise IntegrationCatalogError(
188-
f"Invalid catalog format from {entry.url}: 'integrations' must be a JSON object"
208+
f"Invalid catalog format from {entry.url}: {shape_error}"
189209
)
190210

191211
try:

tests/integrations/test_integration_catalog.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,34 @@
1313
IntegrationDescriptor,
1414
IntegrationDescriptorError,
1515
IntegrationValidationError,
16+
_catalog_shape_error,
1617
)
1718

1819

20+
class TestCatalogShapeValidator:
21+
"""The shared shape validator used by BOTH the fresh-fetch and cache-read
22+
paths, so a poisoned/older cache can't bypass the format contract the fresh
23+
fetch enforces (dict + 'schema_version' + dict 'integrations')."""
24+
25+
def test_valid_payload_returns_none(self):
26+
assert _catalog_shape_error({"schema_version": "1.0", "integrations": {}}) is None
27+
28+
def test_missing_schema_version_is_rejected(self):
29+
# The exact bypass the two paths used to disagree on: a dict with a dict
30+
# 'integrations' but no 'schema_version'.
31+
assert _catalog_shape_error({"integrations": {}}) is not None
32+
33+
def test_missing_integrations_is_rejected(self):
34+
assert _catalog_shape_error({"schema_version": "1.0"}) is not None
35+
36+
def test_non_dict_integrations_is_rejected(self):
37+
assert _catalog_shape_error({"schema_version": "1.0", "integrations": []}) is not None
38+
39+
@pytest.mark.parametrize("payload", [[], "x", 5, None])
40+
def test_non_dict_payload_is_rejected(self, payload):
41+
assert _catalog_shape_error(payload) is not None
42+
43+
1944
# ---------------------------------------------------------------------------
2045
# IntegrationCatalogEntry
2146
# ---------------------------------------------------------------------------
@@ -251,6 +276,48 @@ def test_fetch_and_search_all(self, tmp_path, monkeypatch):
251276
ids = [r["id"] for r in results]
252277
assert "acme-coder" in ids
253278

279+
def test_poisoned_cache_shape_is_dropped_and_refetched(self, tmp_path, monkeypatch):
280+
"""A fresh-but-mis-shaped cache (e.g. integrations as a list) must be
281+
dropped and refetched, not returned — otherwise it later crashes on
282+
.items(). The cache path must clear the same shape checks as a fresh
283+
fetch."""
284+
monkeypatch.setenv("HOME", str(tmp_path))
285+
monkeypatch.setenv("USERPROFILE", str(tmp_path))
286+
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
287+
(tmp_path / ".specify").mkdir()
288+
cat = IntegrationCatalog(tmp_path)
289+
290+
catalog = {
291+
"schema_version": "1.0",
292+
"updated_at": "2026-01-01T00:00:00Z",
293+
"integrations": {
294+
"acme-coder": {
295+
"id": "acme-coder", "name": "Acme Coder", "version": "2.0.0",
296+
"description": "Community integration", "author": "acme-org",
297+
"tags": ["cli"],
298+
},
299+
},
300+
}
301+
self._patch_urlopen(monkeypatch, catalog)
302+
cat.search() # populate the cache legitimately
303+
304+
# Poison the cached payload (integrations as a list), keeping the fresh
305+
# metadata so the age check passes and the cache branch is taken.
306+
cache_dir = tmp_path / ".specify" / "integrations" / ".cache"
307+
data_files = [
308+
f for f in cache_dir.glob("catalog-*.json")
309+
if not f.name.endswith("-metadata.json")
310+
]
311+
assert data_files, "cache was not populated"
312+
data_files[0].write_text(
313+
json.dumps({"schema_version": "1.0", "integrations": []}),
314+
encoding="utf-8",
315+
)
316+
317+
# The poisoned cache is dropped and the (valid) source is refetched.
318+
results = cat.search()
319+
assert "acme-coder" in [r["id"] for r in results]
320+
254321
def test_search_by_tag(self, tmp_path, monkeypatch):
255322
monkeypatch.setenv("HOME", str(tmp_path))
256323
monkeypatch.setenv("USERPROFILE", str(tmp_path))

0 commit comments

Comments
 (0)