manifest_validator.py
Rama main · réplica de lectura del repositorio original.
"""Validator for Pieria **Manifest v2** (see docs/manifest-v2.md).
Executable source of truth for the federation manifest format. Pure-Python, no dependency — it runs
both offline (in the catalog builder) and at runtime (when the app fetches a subscribed manifest).
Design rules:
- STRICT on required fields, types, enums, and per-asset licensing/attribution.
- TOLERANT of unknown fields (forward-compatibility — future docent/marketplace keys must not break
an older validator; new *required* fields only ever arrive under a bumped manifest_version).
`validate_manifest(obj)` returns a list of human-readable error strings ([] means valid).
"""
from __future__ import annotations
import re
from core.licensing import normalize_license, safe_http_url
from core.licensing import requires_attribution as _pack_requires_attribution
MANIFEST_VERSION = 2
_ACCESS_RE = re.compile(r"^(free|entitlement:[a-z0-9_-]+:.+)$", re.IGNORECASE)
def _requires_attribution(license_str: str) -> bool:
"""CC-BY and CC-BY-SA require attribution; CC0/PD/proprietary do not.
Federation manifests aren't limited to core.licensing.PACK_ALLOWED (a subscribed feed may declare
CC-BY-SA, which normalize_license() rightly refuses as *pack-ship* since it's not in PACK_ALLOWED —
but it still requires attribution here). So: use core.licensing for the ids it does recognize, and
fall back to a normalized prefix check (fixes the old `^cc-by` regex missing "CC BY 4.0" — the
space) for everything else that starts with cc-by, including cc-by-sa.
"""
norm = normalize_license(license_str)
if norm is not None:
return _pack_requires_attribution(norm)
s = re.sub(r"[\s_]+", "-", (license_str or "").strip().lower())
return s.startswith("cc-by")
def _is_str(v) -> bool:
return isinstance(v, str) and v.strip() != ""
def _check_asset_license(asset: dict, path: str, errors: list, *, license_required: bool):
lic = asset.get("license")
if lic is None:
if license_required:
errors.append(f"{path}.license is required (no manifest default_license set)")
return
if not _is_str(lic):
errors.append(f"{path}.license must be a non-empty string")
return
if _requires_attribution(lic) and not _is_str(asset.get("attribution")):
errors.append(f"{path}.attribution is required when license is '{lic}'")
def _validate_image(image, path, errors, *, has_default_license):
if not isinstance(image, dict):
errors.append(f"{path} must be an object")
return
# An image asset is addressed by EITHER a remote `full_url` (third-party feeds the app fetches) OR
# a `local_file` naming bytes shipped inside the pack (a first-party pack resolved under _Library/,
# zero network). At least one is required. Relaxation only — existing full_url manifests still pass.
if not _is_str(image.get("full_url")) and not _is_str(image.get("local_file")):
errors.append(f"{path}: one of full_url or local_file is required (non-empty string)")
for k in ("width", "height"):
if k in image and not isinstance(image[k], int):
errors.append(f"{path}.{k} must be an integer")
fp = image.get("focal_point")
if fp is not None and not (isinstance(fp, (list, tuple)) and len(fp) == 2
and all(isinstance(n, (int, float)) and 0.0 <= n <= 1.0 for n in fp)):
errors.append(f"{path}.focal_point must be [x, y] with each value a number in 0..1")
ac = image.get("aspect_crops")
if ac is not None:
if not isinstance(ac, dict):
errors.append(f"{path}.aspect_crops must be an object")
else:
for k, box in ac.items():
if not (isinstance(box, (list, tuple)) and len(box) == 4
and all(isinstance(n, (int, float)) and 0.0 <= n <= 1.0 for n in box)):
errors.append(f"{path}.aspect_crops[{k!r}] must be [x0,y0,x1,y1] with each value "
"a number in 0..1")
# ADR-142 review: these three fields become an href server-side (routers/library.py's /art/{id},
# the e-ink pull headers, Admin -> Credits) — a non-http(s) value (javascript:, data:, ...) is a
# stored-XSS vector, so the manifest is rejected outright rather than silently defanged at render
# time. Every real catalog/pack manifest already uses http(s) here (checked against static/catalog).
for k in ("license_url", "attribution_url", "origin_url"):
if k in image and not _is_str(image[k]):
errors.append(f"{path}.{k} must be a non-empty string when present")
elif k in image and safe_http_url(image[k]) is None:
errors.append(f"{path}.{k} must be an http:// or https:// URL")
_check_asset_license(image, path, errors, license_required=not has_default_license)
# Image-specific (ADR-142): a CC BY image also needs its licence linked and its source page, on top
# of the base `attribution` string _check_asset_license already requires above.
lic = image.get("license")
if _is_str(lic) and _requires_attribution(lic):
if not _is_str(image.get("license_url")):
errors.append(f"{path}.license_url is required when license is '{lic}'")
if not _is_str(image.get("attribution_url")):
errors.append(f"{path}.attribution_url is required when license is '{lic}'")
def _validate_interpretation(interp, path, errors):
if not isinstance(interp, dict):
errors.append(f"{path} must be an object")
return
# Interpretation is authored & licensed independently of the image.
if not _is_str(interp.get("author")):
errors.append(f"{path}.author is required (interpretation is separately authored)")
_check_asset_license(interp, path, errors, license_required=True)
sections = interp.get("sections")
if sections is not None:
if not isinstance(sections, list):
errors.append(f"{path}.sections must be an array")
else:
for i, s in enumerate(sections):
if not isinstance(s, dict) or not _is_str(s.get("body")):
errors.append(f"{path}.sections[{i}] must be an object with a non-empty 'body'")
pois = interp.get("points_of_interest")
if pois is not None:
if not isinstance(pois, list):
errors.append(f"{path}.points_of_interest must be an array")
else:
for i, poi in enumerate(pois):
p = f"{path}.points_of_interest[{i}]"
if not isinstance(poi, dict):
errors.append(f"{p} must be an object")
continue
if not _is_str(poi.get("commentary")):
errors.append(f"{p}.commentary is required (non-empty string)")
bbox = poi.get("bbox")
if (not isinstance(bbox, (list, tuple)) or len(bbox) != 4
or not all(isinstance(n, (int, float)) and 0.0 <= n <= 1.0 for n in bbox)):
errors.append(f"{p}.bbox must be [x, y, w, h] with each value a number in 0..1")
audio = interp.get("audio")
if audio is not None:
if not isinstance(audio, list):
errors.append(f"{path}.audio must be an array")
else:
for i, track in enumerate(audio):
p = f"{path}.audio[{i}]"
if not isinstance(track, dict):
errors.append(f"{p} must be an object")
continue
if not _is_str(track.get("url")):
errors.append(f"{p}.url is required (non-empty string)")
_check_asset_license(track, p, errors, license_required=True)
def _validate_item(item, idx, errors, *, has_default_license):
path = f"items[{idx}]"
if not isinstance(item, dict):
errors.append(f"{path} must be an object")
return
if not _is_str(item.get("id")):
errors.append(f"{path}.id is required (non-empty string)")
if not _is_str(item.get("title")):
errors.append(f"{path}.title is required (non-empty string)")
if "tags" in item and not (isinstance(item["tags"], list) and all(isinstance(t, str) for t in item["tags"])):
errors.append(f"{path}.tags must be an array of strings")
if "access" in item and not (isinstance(item["access"], str) and _ACCESS_RE.match(item["access"])):
errors.append(f"{path}.access must be 'free' or 'entitlement:<provider>:<id>'")
if "image" not in item:
errors.append(f"{path}.image is required")
else:
_validate_image(item["image"], f"{path}.image", errors, has_default_license=has_default_license)
if "interpretation" in item:
_validate_interpretation(item["interpretation"], f"{path}.interpretation", errors)
def validate_publisher(pub, errors):
if not isinstance(pub, dict):
errors.append("publisher must be an object")
return
for k in ("id", "name"):
if not _is_str(pub.get(k)):
errors.append(f"publisher.{k} is required (non-empty string)")
def validate_manifest(obj) -> list[str]:
"""Validate a Manifest v2 collection document. Returns [] if valid, else a list of errors."""
errors: list[str] = []
if not isinstance(obj, dict):
return ["manifest must be a JSON object"]
if obj.get("manifest_version") != MANIFEST_VERSION:
errors.append(f"manifest_version must be {MANIFEST_VERSION} (got {obj.get('manifest_version')!r})")
if not _is_str(obj.get("id")):
errors.append("id is required (non-empty string)")
if not _is_str(obj.get("title")):
errors.append("title is required (non-empty string)")
if "cover_image" in obj and not _is_str(obj["cover_image"]):
errors.append("cover_image must be a non-empty string (URL) when present")
if "publisher" in obj:
validate_publisher(obj["publisher"], errors)
items = obj.get("items")
if not isinstance(items, list):
errors.append("items is required (array)")
else:
has_default_license = _is_str(obj.get("default_license"))
for idx, item in enumerate(items):
_validate_item(item, idx, errors, has_default_license=has_default_license)
return errors
def is_valid(obj) -> bool:
return not validate_manifest(obj)