forgetest: exthost.catalog, and the coverage lint no longer allows an advisory away

exthost.catalog (suite/extcat.py, its own module): GET /ext/catalog
answers the index the host keeps and the one address it is fetched from.
On a scratch root under /tmp, 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 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 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. The relay
refuses an id with no such form (400) and one the kept index does not
list (404, or 409 with none kept) before anything is fetched, and a
refresh from the fixed address keeps OpenGlow's index when one is
published there and is 502 in curl's words when none is, the kept index
left as it was. Nothing is left in the staging directory.

The coverage lint had a gap: coverage_report() let the allowlist's
docs/** and **/*.md take out a path the BEHAVIORAL list keeps in every
fingerprint, so the four first-run advisory documents were covered by no
test and the lint passed. A change to the privacy advisory would have
invalidated nothing. A behavioral path is now never allowed away, and
setup.advisories-rehash, which accepts every first-run document at its
current hash, covers the four.

Proof: on the bench reference, with forgectrl 848ccc1 and forgeext
a64b933 bind-mounted and the privacy document accepted again at its new
hash with the fixture's press, exthost.catalog PASS (the refresh was 502:
GitHub answered 404, nothing is published at the address yet), and
setup.advisories-rehash PASS with the rest of the campaign. The lint's
new unit test reports the uncovered advisory, and the old reading (the
override ignored) reports nothing for it, which is the gap. forgetest's
unit tests pass (452), and the coverage lint passes with --enforce.
This commit is contained in:
ScottW514
2026-09-23 01:56:57 -04:00
parent 675fc11cb1
commit 4e47b7fbf1
5 changed files with 195 additions and 5 deletions
+8 -4
View File
@@ -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:
+1
View File
@@ -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
+170
View File
@@ -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)
+3 -1
View File
@@ -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 "
+13
View File
@@ -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", "**")])