mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
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>
This commit is contained in:
@@ -40,6 +40,25 @@ class IntegrationDescriptorError(Exception):
|
||||
"""Raised when an integration.yml descriptor is invalid."""
|
||||
|
||||
|
||||
def _catalog_shape_error(payload: Any) -> Optional[str]:
|
||||
"""Return a human-readable reason if *payload* is not a valid integration
|
||||
catalog document, else ``None``.
|
||||
|
||||
Shared by the fresh-fetch and cache-read paths so both enforce the same
|
||||
format contract: a JSON object carrying ``schema_version`` and a mapping
|
||||
``integrations``. Keeping a single validator prevents the two paths from
|
||||
drifting (e.g. a cache that skips the ``schema_version`` check and lets an
|
||||
older/poisoned payload bypass validation).
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return "expected a JSON object"
|
||||
if "schema_version" not in payload or "integrations" not in payload:
|
||||
return "missing required 'schema_version' or 'integrations' key"
|
||||
if not isinstance(payload.get("integrations"), dict):
|
||||
return "'integrations' must be a JSON object"
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IntegrationCatalogEntry
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -153,7 +172,18 @@ class IntegrationCatalog(CatalogStackBase):
|
||||
cached_at = cached_at.replace(tzinfo=timezone.utc)
|
||||
age = (datetime.now(timezone.utc) - cached_at).total_seconds()
|
||||
if age < self.CACHE_DURATION:
|
||||
return json.loads(cache_file.read_text(encoding="utf-8"))
|
||||
cached = json.loads(cache_file.read_text(encoding="utf-8"))
|
||||
# A poisoned/older-format cache must clear the SAME shape
|
||||
# contract as a fresh fetch (via the shared validator) —
|
||||
# otherwise a payload like [], {"integrations": []}, or one
|
||||
# missing "schema_version" is returned and later crashes on
|
||||
# .items()/.get() or silently bypasses the format contract.
|
||||
# The ValueError is caught just below, which drops the
|
||||
# corrupt cache and refetches from source.
|
||||
shape_error = _catalog_shape_error(cached)
|
||||
if shape_error is not None:
|
||||
raise ValueError(f"cached catalog has invalid shape: {shape_error}")
|
||||
return cached
|
||||
except (json.JSONDecodeError, ValueError, KeyError, TypeError, AttributeError, OSError, UnicodeError):
|
||||
# Cache is invalid or stale metadata; delete and refetch from source.
|
||||
try:
|
||||
@@ -172,20 +202,10 @@ class IntegrationCatalog(CatalogStackBase):
|
||||
self._validate_catalog_url(final_url)
|
||||
catalog_data = json.loads(resp.read())
|
||||
|
||||
if not isinstance(catalog_data, dict):
|
||||
shape_error = _catalog_shape_error(catalog_data)
|
||||
if shape_error is not None:
|
||||
raise IntegrationCatalogError(
|
||||
f"Invalid catalog format from {entry.url}: expected a JSON object"
|
||||
)
|
||||
if (
|
||||
"schema_version" not in catalog_data
|
||||
or "integrations" not in catalog_data
|
||||
):
|
||||
raise IntegrationCatalogError(
|
||||
f"Invalid catalog format from {entry.url}"
|
||||
)
|
||||
if not isinstance(catalog_data.get("integrations"), dict):
|
||||
raise IntegrationCatalogError(
|
||||
f"Invalid catalog format from {entry.url}: 'integrations' must be a JSON object"
|
||||
f"Invalid catalog format from {entry.url}: {shape_error}"
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user