170 lines
9.5 KiB
Python
170 lines
9.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Discover and verify optional template page examples; never write or activate a template."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from copy import deepcopy
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import sys
|
|
from urllib.parse import unquote, urlsplit
|
|
from xml.etree import ElementTree as ET
|
|
|
|
from template_ref import (InputError, VERSION, digest, file_path, identifier, input_hashes,
|
|
load_manifest, read_json, require, string, strings)
|
|
|
|
|
|
def checked_file(root: Path, record: object, label: str) -> Path:
|
|
require(isinstance(record, dict), f"{label}: expected path/sha256 object")
|
|
path = file_path(root, record.get("path"), label)
|
|
checksum = string(record.get("sha256"), f"{label}.sha256")
|
|
require(bool(re.fullmatch(r"[0-9a-f]{64}", checksum)), f"{label}: invalid SHA-256")
|
|
require(digest(path) == checksum, f"{label}: SHA-256 mismatch: {record['path']}")
|
|
return path
|
|
|
|
|
|
def check_source(source: Path, declared: set[Path]) -> None:
|
|
text = source.read_text(encoding="utf-8")
|
|
require("<!DOCTYPE" not in text.upper() and "<!ENTITY" not in text.upper(),
|
|
"library SVG: document entities are unsupported")
|
|
svg = ET.fromstring(text)
|
|
require(svg.tag.rsplit("}", 1)[-1] == "svg", "library source must be SVG")
|
|
for node in svg.iter():
|
|
for key, href in node.attrib.items():
|
|
if key.rsplit("}", 1)[-1] != "href" or href.startswith("#"):
|
|
continue
|
|
if href.startswith(("data:image/png;base64,", "data:image/jpeg;base64,")):
|
|
continue
|
|
uri = urlsplit(href)
|
|
require(not uri.scheme and not uri.netloc and not uri.query,
|
|
f"library SVG: external resource {href}")
|
|
raw = unquote(uri.path)
|
|
require(raw and not Path(raw).is_absolute() and "\\" not in raw,
|
|
f"library SVG: invalid resource {href}")
|
|
resolved = (source.parent / raw).resolve()
|
|
require(resolved in declared, f"library SVG: undeclared resource {href}")
|
|
|
|
|
|
def load_library(manifest_path: Path) -> tuple[dict, Path | None, dict | None]:
|
|
manifest = load_manifest(manifest_path)
|
|
if "page_library" not in manifest:
|
|
return manifest, None, None
|
|
root = manifest_path.parent
|
|
path = file_path(root, manifest["page_library"], "page_library")
|
|
catalog = read_json(path, (1,))
|
|
require(catalog.get("template_id") == manifest["template_id"], "library template_id mismatch")
|
|
entries = catalog.get("entries")
|
|
require(isinstance(entries, list), "library entries: expected array")
|
|
seen = set()
|
|
for entry in entries:
|
|
require(isinstance(entry, dict), "library entry: expected object")
|
|
key = identifier(entry.get("id"), "library entry ID")
|
|
require(key not in seen, f"Duplicate library entry: {key}")
|
|
seen.add(key)
|
|
string(entry.get("title"), "entry.title")
|
|
require(entry.get("status") in ("candidate", "reference", "deprecated"), "entry.status invalid")
|
|
versions = strings(entry.get("template_versions"), "entry.template_versions", nonempty=True)
|
|
require(all(VERSION.fullmatch(v) for v in versions), "entry.template_versions: expected exact SemVer values")
|
|
require(entry.get("layout") in {item["id"] for item in manifest["layouts"]}, "Unknown library layout")
|
|
strings(entry.get("tags"), "entry.tags")
|
|
checked_file(root, entry.get("design"), f"{key}.design")
|
|
string(entry.get("origin"), f"{key}.origin")
|
|
variants = entry.get("variants")
|
|
require(isinstance(variants, dict) and bool(variants), f"{key}.variants: expected non-empty object")
|
|
for theme, variant in variants.items():
|
|
require(theme in manifest.get("themes", manifest.get("modes", {})), f"Unknown library theme: {theme}")
|
|
require(isinstance(variant, dict), f"{key}.{theme}: expected object")
|
|
files = variant.get("files")
|
|
require(isinstance(files, list) and bool(files), f"{key}.{theme}.files: expected non-empty array")
|
|
paths = [checked_file(root, item, f"{key}.{theme}.files") for item in files]
|
|
require(len(paths) == len(set(paths)), f"{key}.{theme}: duplicate files")
|
|
source = file_path(root, variant.get("source"), "variant.source")
|
|
preview = file_path(root, variant.get("preview"), "variant.preview")
|
|
require(source in paths and preview in paths, "source and preview must be declared files")
|
|
require(source.suffix.lower() == ".svg", "library source must be .svg")
|
|
require(preview.suffix.lower() in (".png", ".jpg", ".jpeg"), "library preview must be PNG/JPEG")
|
|
evidence = variant.get("evidence")
|
|
require(isinstance(evidence, dict), "variant.evidence: expected object")
|
|
require(evidence.get("level") in ("source-only", "svg-rendered", "powerpoint-rendered", "powerpoint-edited"),
|
|
"variant.evidence.level invalid")
|
|
string(evidence.get("source_version"), "evidence.source_version")
|
|
string(evidence.get("notes"), "evidence.notes")
|
|
check_source(source, set(paths))
|
|
return manifest, path, catalog
|
|
|
|
|
|
def inspect_entry(manifest_path: Path, manifest: dict, path: Path, entry: dict,
|
|
theme: str, for_use: bool) -> dict:
|
|
compatible = manifest["version"] in entry["template_versions"]
|
|
eligible = entry["status"] == "reference" and compatible and theme in entry["variants"]
|
|
if for_use:
|
|
require(manifest["status"] == "released", "Library reuse requires a released template")
|
|
require(eligible, "Entry is not a compatible reference for the selected theme")
|
|
require(theme in entry["variants"], f"Entry has no variant for theme {theme}")
|
|
root = manifest_path.parent
|
|
variant = deepcopy(entry["variants"][theme])
|
|
inputs = {manifest_path, path, file_path(root, entry["design"]["path"], "design")}
|
|
inputs.update(file_path(root, item["path"], "variant file") for item in variant["files"])
|
|
return {"template_id": manifest["template_id"], "template_version": manifest["version"],
|
|
"entry_id": entry["id"], "title": entry["title"], "status": entry["status"],
|
|
"layout": entry["layout"], "theme": theme, "compatible": compatible, "eligible": eligible,
|
|
"design": str(file_path(root, entry["design"]["path"], "design")),
|
|
"source": str(file_path(root, variant["source"], "source")),
|
|
"preview": str(file_path(root, variant["preview"], "preview")),
|
|
"variant": variant, "inputs": input_hashes(root, inputs),
|
|
"verification_scope": "declared metadata, file hashes and SVG href closure; preview provenance is declared, not rerendered"}
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
for name in ("list", "check", "inspect"):
|
|
item = sub.add_parser(name)
|
|
item.add_argument("--manifest", type=Path, required=True, action="append")
|
|
if name == "list":
|
|
item.add_argument("--theme", action="append")
|
|
item.add_argument("--layout", action="append")
|
|
if name == "inspect":
|
|
item.add_argument("--entry", required=True, action="append")
|
|
item.add_argument("--theme", required=True, action="append")
|
|
item.add_argument("--for-use", action="store_true")
|
|
args = parser.parse_args()
|
|
try:
|
|
for key in ("manifest", "theme", "layout", "entry"):
|
|
values = getattr(args, key, None)
|
|
if values is not None:
|
|
require(len(values) == 1, f"Only one --{key} may be active")
|
|
setattr(args, key, values[0])
|
|
manifest_path = args.manifest.resolve()
|
|
manifest, path, catalog = load_library(manifest_path)
|
|
if args.command == "inspect":
|
|
require(catalog is not None, "Template has no page library")
|
|
entry = next((item for item in catalog["entries"] if item["id"] == args.entry), None)
|
|
require(entry is not None, f"Unknown library entry: {args.entry}")
|
|
output = inspect_entry(manifest_path, manifest, path, entry, args.theme, args.for_use)
|
|
else:
|
|
entries = [] if catalog is None else catalog["entries"]
|
|
theme, layout = getattr(args, "theme", None), getattr(args, "layout", None)
|
|
if theme:
|
|
require(theme in manifest.get("themes", manifest.get("modes", {})), "Unknown theme filter")
|
|
if layout:
|
|
require(layout in {item["id"] for item in manifest["layouts"]}, "Unknown layout filter")
|
|
entries = [{"id": entry["id"], "title": entry["title"], "status": entry["status"],
|
|
"layout": entry["layout"], "themes": list(entry["variants"]), "tags": entry["tags"],
|
|
"compatible": manifest["version"] in entry["template_versions"]}
|
|
for entry in entries if (not theme or theme in entry["variants"])
|
|
and (not layout or layout == entry["layout"])]
|
|
output = {"template_id": manifest["template_id"], "available": catalog is not None,
|
|
"catalog": str(path) if path else None, "entries": entries, "count": len(entries)}
|
|
print(json.dumps(output, ensure_ascii=False, indent=2))
|
|
return 0
|
|
except (InputError, OSError, ValueError, ET.ParseError) as error:
|
|
print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr)
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|