Harden federated compose against malformed member indexes (REUSE-WP-0020)
Repointing the production hub's 50 Gitea-hosted federation sources to Forgejo ahead of the 2026-08-31 CoulombCore retirement took /v1/federated to HTTP 500. One member index (evidence-binder) has capability rows with no `id`, and compose_federated_index dereferenced item["id"] unguarded. Its Gitea copy was a stale snapshot returning a non-mapping, so those rows had never been parsed. A single malformed member index must not take down the whole endpoint. Extract _read_index_entries(): unparseable YAML, a non-mapping body, an empty file, and a non-list `capabilities` each degrade to a warning and an empty row list, and rows without an `id` are skipped individually. A failed source stays listed with count 0 so it remains visible to operators rather than silently disappearing. Also fix wall-clock rot in tests/test_plan_check.py, which was already failing at clean HEAD: three tests pinned the compose date to a literal that has now aged past STALE_DAYS. Add workplan REUSE-WP-0020 covering the full cutover. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
fa1235474f
commit
0c6b1e2538
5 changed files with 403 additions and 16 deletions
|
|
@ -183,6 +183,27 @@ def resolve_source_index_path(
|
|||
return _write_remote_cache(source["repo"], url, content, cache_dir), warnings
|
||||
|
||||
|
||||
def _read_index_entries(
|
||||
index_path: Path, repo: str
|
||||
) -> tuple[list[Any], list[str]]:
|
||||
"""Read the capability rows of one member index.
|
||||
|
||||
A malformed member index must not abort the whole compose, so every
|
||||
failure here degrades to a warning and an empty row list.
|
||||
"""
|
||||
try:
|
||||
with index_path.open(encoding="utf-8") as handle:
|
||||
index_data = yaml.safe_load(handle)
|
||||
except yaml.YAMLError as exc:
|
||||
return [], [f"{repo}: unparseable index, skipped ({exc.__class__.__name__})"]
|
||||
if not isinstance(index_data, dict):
|
||||
return [], [f"{repo}: index is not a mapping, skipped"]
|
||||
entries = index_data.get("capabilities") or []
|
||||
if not isinstance(entries, list):
|
||||
return [], [f"{repo}: capabilities is not a list, skipped"]
|
||||
return entries, []
|
||||
|
||||
|
||||
def compose_federated_index(
|
||||
manifest: dict[str, Any] | None = None,
|
||||
*,
|
||||
|
|
@ -204,11 +225,17 @@ def compose_federated_index(
|
|||
warnings.extend(source_warnings)
|
||||
if index_path is None:
|
||||
continue
|
||||
with index_path.open(encoding="utf-8") as handle:
|
||||
index_data = yaml.safe_load(handle)
|
||||
entries, read_warnings = _read_index_entries(index_path, source["repo"])
|
||||
warnings.extend(read_warnings)
|
||||
count = 0
|
||||
for item in index_data.get("capabilities", []):
|
||||
cap_id = item["id"]
|
||||
for position, item in enumerate(entries):
|
||||
if not isinstance(item, dict):
|
||||
warnings.append(f"{source['repo']}: capability #{position} is not a mapping, skipped")
|
||||
continue
|
||||
cap_id = item.get("id")
|
||||
if not cap_id:
|
||||
warnings.append(f"{source['repo']}: capability #{position} has no id, skipped")
|
||||
continue
|
||||
if cap_id in seen_ids:
|
||||
warnings.append(
|
||||
f"duplicate id {cap_id}: {seen_ids[cap_id]} and {source['repo']}"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue