diff --git a/forgetest/forgetest/manifest.py b/forgetest/forgetest/manifest.py index 4942ca3..e70e7fe 100644 --- a/forgetest/forgetest/manifest.py +++ b/forgetest/forgetest/manifest.py @@ -221,7 +221,8 @@ def coverage_report(manifest, tests, allow=NON_BEHAVIORAL): """Which manifest paths no test covers. tests: iterable with .covers. allow: iterable of (component, glob) - that need no coverage (docs, CI, licenses...). Returns + that need no coverage (docs, CI, licenses...); a BEHAVIORAL path is + never allowed away, as it is never taken out of a fingerprint. Returns {component: [uncovered paths]} for the non-dev-only components. """ covered = {} @@ -235,11 +236,14 @@ def coverage_report(manifest, tests, allow=NON_BEHAVIORAL): for comp in manifest.component_names(): if comp in DEV_ONLY_COMPONENTS: continue - rxs = covered.get(comp, []) + allowed.get(comp, []) - star = allowed.get("*", []) + rxs = covered.get(comp, []) + free = allowed.get(comp, []) + allowed.get("*", []) + behavior = [glob_to_regex(pat) for c, pat in BEHAVIORAL if c == comp] missing = [] for p, _b in manifest.files(comp): - if any(rx.match(p) for rx in rxs) or any(rx.match(p) for rx in star): + if any(rx.match(p) for rx in rxs): + continue + if any(rx.match(p) for rx in free) and not any(rx.match(p) for rx in behavior): continue missing.append(p) if missing: diff --git a/forgetest/forgetest/suite/__init__.py b/forgetest/forgetest/suite/__init__.py index 45e0a06..759b0e2 100644 --- a/forgetest/forgetest/suite/__init__.py +++ b/forgetest/forgetest/suite/__init__.py @@ -26,3 +26,4 @@ from . import extcall # noqa: F401,E402 from . import extdest # noqa: F401,E402 from . import extlife # noqa: F401,E402 from . import evmore # noqa: F401,E402 +from . import extcat # noqa: F401,E402 diff --git a/forgetest/forgetest/suite/extcat.py b/forgetest/forgetest/suite/extcat.py new file mode 100644 index 0000000..d90bcd3 --- /dev/null +++ b/forgetest/forgetest/suite/extcat.py @@ -0,0 +1,170 @@ +# Copyright 2026 514 LLC d/b/a OpenGlow +# Written by Scott Wiederhold +# https://community.openglow.org +# SPDX-License-Identifier: MIT + +"""The catalog: the signed index, on the machine. + +Its own module, for the reason extcore.py gives. The endorsement is shown +on a scratch root under /tmp with a throwaway key standing in for the +OpenGlow extension key, because only OpenGlow holds the real one; the +machine's own root is where every index that key did not sign is refused. +""" + +import hashlib +import io +import json +import os +import shutil +import subprocess +import tarfile +import tempfile + +from ..catalog import test +from .exthost import FWUP, _forgeext, _write +from .setup import request + +INDEX_URL = "https://github.com/openglow-org/forgefirm-extensions/releases/latest/download/index.ffi" +STAGE_DIR = "/data/forgefirm/tmp" +PROBE_ID, OTHER_ID = "org.example.catprobe", "org.example.catother" + + +def _key(work, name): + subprocess.run([FWUP, "-g", "-o", os.path.join(work, name)], check=True, capture_output=True, timeout=60, cwd=work) + return os.path.join(work, name) + + +def _archive(work, name, product, members, version, key): + """An fwup archive of the extension container's form: payload.tar.gz of members {name: text}, signed with key.""" + payload = os.path.join(work, name + ".tar.gz") + with tarfile.open(payload, "w:gz") as t: + for member, text in members.items(): + info = tarfile.TarInfo(member) + data = text.encode() + info.size, info.mode = len(data), 0o644 + t.addfile(info, io.BytesIO(data)) + conf = os.path.join(work, name + ".conf") + _write(conf, 'meta-product = "%s"\nmeta-description = "%s"\nmeta-version = "%s"\nmeta-platform = "forgefirm-ext"\n' + 'file-resource payload.tar.gz {\n host-path = "%s"\n}\n' % (product, name, version, payload)) + raw, signed = os.path.join(work, name + ".raw"), os.path.join(work, name + ".signed") + subprocess.run([FWUP, "-c", "-f", conf, "-o", raw], check=True, capture_output=True, timeout=60, cwd=work) + subprocess.run([FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed], check=True, capture_output=True, + timeout=60, cwd=work) + return signed + + +def _package(work, id_, key): + m = {"manifest": 1, "id": id_, "name": "catalog probe", "version": "1.0.0", "author": "forgetest", "license": "MIT", + "api": "0.1", "runtime": "data", "capabilities": []} + return _archive(work, id_, "ForgeFIRM extension", {"manifest.json": json.dumps(m)}, "1.0.0", key) + + +def _index(work, entries, key): + return _archive(work, "index", "ForgeFIRM extension index", {"index.json": json.dumps({"index": 1, "packages": entries})}, + "2026.9.23", key) + + +def _staged(): + try: + return sorted(n for n in os.listdir(STAGE_DIR) if n.startswith("ext-")) + except OSError: + return [] + + +@test("exthost.catalog", title="The catalog: the signed index verified, kept, and endorsing one key for one id", + subsystem="exthost", kind="auto", hardware="api", est_min=2, + covers=[("forgectrl", "src/extpkg.*"), ("forgectrl", "src/main.c"), ("forgeext", "src/index.*"), + ("forgeext", "src/pkg.*"), ("forgeext", "src/install.c"), ("forgeext", "src/main.c")], + description="GET /ext/catalog answers the index the host keeps (or null) and the one address it is fetched " + "from. On a scratch root, with a throwaway key standing in for the OpenGlow extension key, the " + "machine's own forgeext keeps an index signed with it, and the author key it names for one id " + "makes a package of that id signed with it read as community and endorsed, where before it was " + "unverified; the same key on another id counts for nothing. On the machine's own root, that " + "index (signed by a key that is not the OpenGlow extension key) is refused in words, a package " + "handed over as an index is refused by the product gate, and the index kept is left as it was. " + "POST /ext/catalog/get refuses an id with no such form (400), and one the kept index does not " + "list (404, or 409 with no index kept), before anything is fetched. POST /ext/catalog/refresh " + "asks the fixed https:// address: it keeps OpenGlow's index when one is published there, and " + "when none is, it is 502 in curl's words with the kept index left as it was. Nothing is left in " + "the staging directory. The fetch of a listed package, held to its size and SHA-256, and the " + "tiers an install takes from it, are forgectrl's extpkg_test and forgeext's install_test.") +def catalog(ctx): + fc = ctx.forgectrl + ev = ctx.evidence + ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle") + staged_before = _staged() + kept = _forgeext("index") + ctx.check(kept.get("ok") is True, "forgeext index -> %s", kept.get("error")) + ev["kept_before"] = (kept.get("index") or {}).get("version") + work = tempfile.mkdtemp(prefix="ft-catalog-", dir="/tmp") + try: + st, doc = fc.get("/ext/catalog") + ctx.check(st == 200 and isinstance(doc, dict) and doc.get("url") == INDEX_URL and "index" in doc + and doc.get("index") == kept.get("index"), "GET /ext/catalog -> %s %s", st, doc) + + # The endorsement, on a scratch root with a stand-in for the OpenGlow extension key. + root = os.path.join(work, "root") + os.makedirs(os.path.join(root, "keys")) + og, author = _key(work, "standin"), _key(work, "author") + probe, other = _package(work, PROBE_ID, author), _package(work, OTHER_ID, author) + with open(probe, "rb") as f: + data = f.read() + with open(author + ".pub") as f: + author_pub = f.read().strip() + idx = _index(work, [{"id": PROBE_ID, "name": "catalog probe", "author": "forgetest", "version": "1.0.0", + "url": "https://example.org/catprobe.ffx", "sha256": hashlib.sha256(data).hexdigest(), + "size": len(data), "capabilities": [], "key": author_pub}], og) + + def scratch(*args): + return _forgeext("--root", root, "--official-key", og + ".pub", "--no-reserve", *args) + r = scratch("inspect", probe) + ctx.check(r.get("ok") is True and r.get("tier") == "unverified", "before an index: %s %s", r.get("tier"), r.get("error")) + r = scratch("index-verify", idx) + ev["scratch_verify"] = r + ctx.check(r.get("ok") is True and r.get("packages") == 1 and r.get("version") == "2026.9.23", + "the stand-in's index is kept on the scratch root: %s", r) + listed = ((scratch("index").get("index") or {}).get("packages") or [{}])[0] + ctx.check(listed.get("id") == PROBE_ID and len(listed.get("key_id", "")) == 64, "and read back: %s", listed) + r = scratch("inspect", probe) + ev["endorsed"] = {"tier": r.get("tier"), "endorsed": r.get("endorsed")} + ctx.check(r.get("tier") == "community" and r.get("endorsed") is True, + "signed with the key it endorses for its id: community, endorsed (%s %s)", r.get("tier"), r.get("endorsed")) + r = scratch("inspect", other) + ctx.check(r.get("tier") == "unverified" and r.get("endorsed") is False, + "the same key on another id: %s %s", r.get("tier"), r.get("endorsed")) + + # The machine's own root keeps no index but OpenGlow's. + refused = {} + for name, path, words in (("the stand-in's index", idx, "not signed with the OpenGlow extension key"), + ("a package as an index", probe, "product")): + r = _forgeext("index-verify", path) + refused[name] = r.get("error") + ctx.check(r.get("ok") is False and words in (r.get("error") or ""), "%s -> %s", name, r.get("error")) + ev["refused"] = refused + ctx.check(_forgeext("index").get("index") == kept.get("index"), "a refused index changed the one kept") + + # The relay's refusals, before anything is fetched. + st, why = fc.post("/ext/catalog/get", data={"id": "org.example;reboot"}) + ctx.check(st == 400, "an id with no such form -> %s %s", st, why) + st, why = fc.post("/ext/catalog/get", data={"id": PROBE_ID}) + want = (404, "not in the catalog") if kept.get("index") else (409, "fetch it first") + ev["get_unlisted"] = [st, why] + ctx.check(st == want[0] and want[1] in json.dumps(why), "an id the kept index does not list -> %s %s", st, why) + + # The fetch from the one address. + st, body, _h = request(fc.base, "POST", "/ext/catalog/refresh", data={}, + headers={"X-ForgeFIRM-Token": fc.token, "Host": fc.host_header()}, timeout=90) + text = body.decode("utf-8", "replace") + ev["refresh"] = [st, text[:400]] + ctx.log("POST /ext/catalog/refresh -> %s %s", st, text[:200]) + if st == 200: + doc = json.loads(text) + ctx.check(isinstance(doc.get("index"), dict) and doc["index"].get("version"), + "a kept index has its version: %s", text[:200]) + else: + ctx.check(st == 502 and "could not be fetched" in text, "a fetch that fails is 502 in curl's words: %s %s", + st, text[:200]) + ctx.check(_forgeext("index").get("index") == kept.get("index"), "a failed fetch changed the index kept") + ctx.check(_staged() == staged_before, "the staging directory holds %s, and held %s before", _staged(), staged_before) + finally: + shutil.rmtree(work, ignore_errors=True) diff --git a/forgetest/forgetest/suite/setup.py b/forgetest/forgetest/suite/setup.py index 1c70de9..62fbe11 100644 --- a/forgetest/forgetest/suite/setup.py +++ b/forgetest/forgetest/suite/setup.py @@ -495,7 +495,9 @@ def override_until_reboot(ctx): subsystem="setup", kind="auto", hardware="takeover", est_min=3, covers=[("forgectrl", "src/advisories.*"), ("forgectrl", "src/setup.*"), ("forgectrl", "src/wiz.*"), ("forgectrl", "src/sha256.*"), ("forgectrl", "src/main.c"), - ("forgectrl", "src/ui/md.js"), ("forgectrl", "src/ui/embed_docs.cmake")], + ("forgectrl", "src/ui/md.js"), ("forgectrl", "src/ui/embed_docs.cmake"), + ("forgectrl", "docs/advisories/safety-and-risk.md"), ("forgectrl", "docs/advisories/licenses.md"), + ("forgectrl", "docs/advisories/privacy.md"), ("forgectrl", "docs/advisories/cloud-service.md")], requires=["forgectrl.auth"], description="GET /advisories/safety-and-risk serves the document as markdown with an ETag " "equal to the SHA-256 of the body and to the hash GET /wiz lists; an unknown " diff --git a/forgetest/tests/test_manifest.py b/forgetest/tests/test_manifest.py index 6026a53..068c96f 100644 --- a/forgetest/tests/test_manifest.py +++ b/forgetest/tests/test_manifest.py @@ -120,6 +120,19 @@ class CoverageReportTests(unittest.TestCase): self.assertNotIn("forgetest", m.coverage_report(man, [], allow=[]), "dev-only components are outside the report") + def test_behavioral_paths_are_never_allowed_away(self): + # An advisory document is behavior (the consent is to its hash): the allowlist's docs/** and + # **/*.md do not take it out of the report, as they do not take it out of a fingerprint. + man = helpers.with_file(helpers.make_manifest(), "forgectrl", "docs/advisories/privacy.md", "text") + rest = helpers.make_test("a.one", [("forgectrl", "src/**"), ("grblhal-glowforge", "**"), + ("kernel-module-glowforge", "**"), ("linux-fslc", "**")]) + self.assertEqual(m.coverage_report(man, [rest]), {"forgectrl": ["docs/advisories/privacy.md"]}) + doc = helpers.make_test("a.two", [("forgectrl", "docs/advisories/privacy.md")]) + self.assertEqual(m.coverage_report(man, [rest, doc]), {}) + self.assertNotEqual(m.fingerprint(man, doc.covers), + m.fingerprint(helpers.with_file(man, "forgectrl", "docs/advisories/privacy.md", "new"), + doc.covers)) + def test_non_behavioral_paths_are_outside_every_fingerprint(self): man = helpers.make_manifest() t = helpers.make_test("a.one", [("forgectrl", "**")])