#!/usr/bin/env python3 # -*- coding: utf-8 -*- """check-family-gate.py — the family gate's template fields are safe to publish (`09` §3 decisions 63/64, controller 0.287.0). A template with `family_gate: true` is published ONLY behind the household's family gate. Three ways that goes wrong, each refused here: 1. an exception (`family_gate_except:`) that is not a LITERAL path prefix — a regex, a traefik matcher, `..`, `//`, or `/` itself. The controller anchors each prefix at a path-segment boundary (`^/prefix(/|$)`, finding F1 of the spike: an unanchored `PathPrefix(/api/v1/opds)` let `/api/v1/opdsx` past the gate) and refuses the install on anything else — this gate refuses it at push time instead of at a household's install; 2. `family_gate: true` without `min_controller: "0.287.0"` (or newer) — the controller refuses a template that needs a newer box, but only if the template SAYS so; 3. `family_gate: true` while the newest baked golden is older than 0.287.0 — a box installed from that golden runs a controller that does not know `family_gate` and would publish the app OPEN. Read from the sibling `felhom.eu` checkout (`documentation/tests/golden--/` holding a bake log with a `GOLDEN_SHA256=` line — a directory NAME is not a bake, R-410). The sibling absent (CI's single clone) → printed as NOT CHECKED, never as a pass of rule 3. Also refused: `family_gate_except:` on a template without `family_gate: true` (an exception list with no gate is a label without the fact). Line-based on purpose: the catalog's CI has NO PyYAML. Only TOP-LEVEL, uncommented keys count; a key inside a comment, a README or a tagline is not the field. Run from the repo root: python3 scripts/check-family-gate.py [--root=] [--felhom-eu=] Exit 0 clean · 1 refused. Decoys: scripts/test_gate_decoys.py (family-gate). """ import io import os import re import sys MIN_VERSION = (0, 287, 0) LITERAL = re.compile(r"^/[A-Za-z0-9._~/-]*$") TOP_TRUE = re.compile(r"^family_gate:\s*true\s*(#.*)?$") TOP_EXCEPT = re.compile(r"^family_gate_except:\s*(#.*)?$") TOP_MINC = re.compile(r'^min_controller:\s*"?([0-9]+\.[0-9]+\.[0-9]+)"?\s*(#.*)?$') ITEM = re.compile(r'^\s+-\s*(?:"([^"]*)"|\'([^\']*)\'|(\S+))\s*(#.*)?$') GOLDEN_DIR = re.compile(r"^golden-(\d+)\.(\d+)\.(\d+)-\d{4}-\d{2}-\d{2}$") GOLDEN_SHA = re.compile(r"GOLDEN_SHA256=[0-9a-f]{64}") BAKE_LOGS = ("bake.log", "06-bake.log", "bake-clean.log", "06-bake-clean.log") def arg(name, default): for a in sys.argv[1:]: if a.startswith(name + "="): return a.split("=", 1)[1] return default def parse(text): """(family_gate, excepts or None, min_controller tuple or None) from a .felhom.yml text.""" gate, excepts, minc = False, None, None lines = text.splitlines() for i, ln in enumerate(lines): if TOP_TRUE.match(ln): gate = True m = TOP_MINC.match(ln) if m: minc = tuple(int(x) for x in m.group(1).split(".")) if TOP_EXCEPT.match(ln): excepts = [] for nxt in lines[i + 1:]: if not nxt.strip() or nxt.lstrip().startswith("#"): continue mi = ITEM.match(nxt) if not mi: break excepts.append(next(g for g in mi.groups()[:3] if g is not None)) return gate, excepts, minc def literal_ok(p): if not LITERAL.match(p) or "//" in p or "/../" in p or p.endswith("/..") or p.strip("/") == "": return False return True def newest_golden(sibling): tests = os.path.join(sibling, "documentation", "tests") if not os.path.isdir(tests): return None best = None for name in os.listdir(tests): m = GOLDEN_DIR.match(name) if not m: continue d = os.path.join(tests, name) baked = False for log in BAKE_LOGS: p = os.path.join(d, log) if os.path.isfile(p) and GOLDEN_SHA.search(io.open(p, encoding="utf-8", errors="replace").read()): baked = True break if baked: v = tuple(int(x) for x in m.groups()) best = v if best is None or v > best else best return best def main(): root = arg("--root", os.getcwd()) sibling = arg("--felhom-eu", os.path.join(os.path.dirname(os.path.abspath(root)), "felhom.eu")) tdir = os.path.join(root, "templates") fails, gated, unchecked = [], [], False for app in sorted(os.listdir(tdir)): fy = os.path.join(tdir, app, ".felhom.yml") if not os.path.isfile(fy): continue gate, excepts, minc = parse(io.open(fy, encoding="utf-8").read()) if excepts is not None and not gate: fails.append("%s: family_gate_except without family_gate: true — an exception list with no gate" % app) if not gate: continue gated.append(app) for p in excepts or []: if not literal_ok(p): fails.append("%s: family_gate_except %r is not a literal path prefix (the controller anchors " "^/prefix(/|$) and refuses anything else — F1)" % (app, p)) if minc is None or minc < MIN_VERSION: fails.append("%s: family_gate needs min_controller: \"%d.%d.%d\" or newer (got %s)" % ((app,) + MIN_VERSION + (minc,))) if gated: g = newest_golden(sibling) if g is None: print("family-gate: rule 3 NOT CHECKED — no felhom.eu sibling with a baked golden at %s" % sibling) unchecked = True elif g < MIN_VERSION: fails.append("family_gate on %s while the newest baked golden is %s — a box installed from it would publish " "the app OPEN; bake a golden >= %d.%d.%d first" % ((", ".join(gated), "%d.%d.%d" % g) + MIN_VERSION)) else: print("family-gate: newest baked golden %d.%d.%d >= %d.%d.%d" % (g + MIN_VERSION)) for f in fails: print(" REFUSED " + f) if fails: print("family-gate gate: %d refusal(s)" % len(fails)) return 1 # The summary line carries the gap: an "OK" read alone must not stand for rule 3 when rule 3 was not checked (the # 2026-10-02 bench run read exactly that). Exit stays 0 — CI's single clone can never check it; the hook does. print("family-gate gate OK: %d family-gated template(s) %s%s" % (len(gated), gated, " — rule 3 (golden >= 0.287.0) NOT CHECKED here" if unchecked else "")) return 0 if __name__ == "__main__": sys.exit(main())