"""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::'") 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)