diff --git a/src/proofbundle/emit.py b/src/proofbundle/emit.py index a2d7d49..28352b8 100644 --- a/src/proofbundle/emit.py +++ b/src/proofbundle/emit.py @@ -64,6 +64,18 @@ def save_signer(key: Ed25519PrivateKey, path: str) -> None: def load_signer(path: str) -> Ed25519PrivateKey: """Load an Ed25519 signing key from a 32 byte raw seed file.""" + # TYPE FLOOR, same invariant as evalcard/prereg (L1-01) — applied here only on 2026-08-16, because + # until then this surface sat OUTSIDE the never-raise family property: `emit` was not in `_MODULES`, + # so nothing ever asked the question. The moment the population was derived from the tree instead of + # a hand-maintained list, the property caught this on its first run. + # + # Measured before the fix: `load_signer(9)` raised `OSError: [Errno 9] Bad file descriptor`. That is + # the worse half of the int case — `open(9)` does not fail on a wrong type, it reads FILE + # DESCRIPTOR 9. A wider except-tuple would hide the escape while leaving the fd read in place; the + # floor is the fix, and it belongs before the os boundary, not after it. + if not isinstance(path, (str, bytes, os.PathLike)): + from .errors import BundleFormatError as _BFE # noqa: PLC0415 + raise _BFE(f"signer key path must be a path string, got {type(path).__name__} (fail-closed)") with open(path, "rb") as handle: return Ed25519PrivateKey.from_private_bytes(handle.read()) diff --git a/tests/test_never_raise_population_guard.py b/tests/test_never_raise_population_guard.py new file mode 100644 index 0000000..8d9e6ee --- /dev/null +++ b/tests/test_never_raise_population_guard.py @@ -0,0 +1,120 @@ +"""The never-raise family property must walk the tree, not a maintained list. + +WHY THIS EXISTS. `tests/test_never_raise_surface_family_property.py` is the executable form of the +never-raise class: no public surface may terminate with a raw exception on hostile input. It carries +its own regression floor on the DENOMINATOR (`test_discovery_finds_the_expected_surface_family`), and +that floor is real — but it guards against the denominator *collapsing*, not against it being +*incomplete by construction*. The module list `_MODULES` is hand-maintained, so a module added to the +package is silently outside the property until somebody remembers to add it. + +MEASURED, 2026-08-16, on `main` @ ac0688c. Two identical planted defects in throwaway copies: + + raise in anchors.verify_anchors (module IS in _MODULES) -> FAILED (errors=1), caught + raise in anchors_ots.verify_openti... (module is NOT) -> Ran 5 tests, OK, NOT caught + +The property is correct over the set it walks. That set was 36 modules while the package shipped 50, +and the difference held 11 surfaces matching the property's own name pattern — one of them a live +contract violation (`anchors_rfc3161.verify_rfc3161` raises `AttributeError` on a non-dict `frozen` +or `rp_trust`, which is exactly what `register_anchor_type` forbids third-party authors from doing). + +WHY A SEPARATE FILE AND NOT AN ASSERTION IN THE PROPERTY ITSELF. A test cannot be the guard of its +own blind spot — it is the victim. This guard asks a different question than the property does: not +"does every surface behave" but "does the property's population equal the tree's". Keeping them apart +means a future edit that shrinks `_MODULES` fails HERE, loudly, instead of silently making the +property cheaper to pass. + +This guard deliberately derives BOTH sides from the same source the property uses, so it cannot drift +from it: the module list and the name pattern are imported from the property module rather than +re-declared here. Re-declaring them would create a second copy of the same truth, which is the class +of defect this file exists to prevent. +""" +from __future__ import annotations + +import importlib +import inspect +import pathlib +import unittest + +_SRC = pathlib.Path(__file__).resolve().parents[1] / "src" / "proofbundle" + +# Import the property module's OWN definitions. Never re-declare them: two copies of one truth drift, +# and a drifted guard would pass while the thing it guards is wrong. +_prop = importlib.import_module("tests.test_never_raise_surface_family_property") +_MODULES = set(_prop._MODULES) +_NAME_PATTERN = _prop._NAME_PATTERN + + +def _package_modules() -> set[str]: + """Every shipped module name, from the tree — the ground truth the population is measured against.""" + return {p.stem for p in _SRC.glob("*.py") if not p.stem.startswith("_")} + + +def _never_raise_surfaces_in(mod_name: str) -> list[str]: + """Functions DEFINED in this module whose name matches the property's own family pattern. + + `__module__` is checked so a name merely imported into the module does not count as one of its + surfaces — otherwise a re-export would inflate every module's apparent population. + """ + try: + mod = importlib.import_module(f"proofbundle.{mod_name}") + except Exception: # noqa: BLE001 — an optional-extra module that will not import is out of scope + return [] + return sorted( + n for n, f in vars(mod).items() + if not n.startswith("_") + and _NAME_PATTERN.match(n) + and inspect.isfunction(f) + and getattr(f, "__module__", "") == f"proofbundle.{mod_name}" + ) + + +class TestNeverRaisePopulationIsDerivedFromTheTree(unittest.TestCase): + + def test_no_shipped_surface_sits_outside_the_property(self): + """The invariant: every never-raise-shaped surface in the package is inside the population. + + A failure here does NOT mean the surface misbehaves. It means nobody has ever asked whether it + does — which is the more expensive state, because a green property is read as covering it. + """ + aussen: dict[str, list[str]] = {} + for mod_name in sorted(_package_modules() - _MODULES): + treffer = _never_raise_surfaces_in(mod_name) + if treffer: + aussen[mod_name] = treffer + gesamt = sum(len(v) for v in aussen.values()) + self.assertEqual( + aussen, {}, + f"{gesamt} never-raise surface(s) in {len(aussen)} module(s) are outside the property's " + f"population, so the property has never entered them: {aussen}. " + "Add the module to _MODULES in tests/test_never_raise_surface_family_property.py — and " + "expect the property to go red if the surface actually violates the contract, which is " + "the point.") + + def test_the_population_names_only_modules_that_exist(self): + """The other direction: a module listed but no longer shipped makes the population lie the + other way — it inflates the apparent denominator with names that walk nothing.""" + verwaist = sorted(_MODULES - _package_modules()) + self.assertEqual(verwaist, [], + f"_MODULES names {len(verwaist)} module(s) that the package does not ship: " + f"{verwaist}. The denominator counts them and nothing walks them.") + + def test_this_guard_actually_discriminates(self): + """Bidirectional validation: the guard must be able to FAIL, or its green means nothing. + + A guard whose predicate can only ever pass is decoration. This exercises the comparison with a + deliberately wrong population and requires it to detect the difference. + """ + paket = _package_modules() + self.assertTrue(paket, "no shipped modules discovered — the ground truth itself is broken") + # A population missing a module that HAS surfaces must be detectable. + kandidaten = [m for m in sorted(paket) if _never_raise_surfaces_in(m)] + self.assertTrue(kandidaten, "no module with never-raise surfaces found — the pattern matched nothing") + kuenstlich = set(kandidaten[1:]) # drop one module that provably has surfaces + fehlend = {m: _never_raise_surfaces_in(m) for m in (paket - kuenstlich) if _never_raise_surfaces_in(m)} + self.assertNotEqual(fehlend, {}, + "the comparison did not notice a module removed from the population — " + "this guard cannot fail and therefore proves nothing") + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/tests/test_never_raise_surface_family_property.py b/tests/test_never_raise_surface_family_property.py index b544c57..3ef2409 100644 --- a/tests/test_never_raise_surface_family_property.py +++ b/tests/test_never_raise_surface_family_property.py @@ -30,6 +30,12 @@ # the sweep, hiding the decision/outcome/subject_binding RecursionError class): "subject_binding", "relation", "assurance", "automation_verdict", "beacon", "public_transparency", "signature", "policy_profiles", "canonical", + # 2026-08-16: the population was hand-maintained and had drifted 14 modules behind the package. + # A coverage guard (tests/test_never_raise_population_guard.py) now derives the expected set from + # the tree, so a module added to the package can no longer sit outside this property unnoticed. + # These seven carried 11 matching surfaces the property had never entered. + "anchors_chia", "anchors_markovian", "anchors_ots", "anchors_rfc3161", "anchors_rootcommit", + "emit", "pqsig", ] # Broadened name family (round 8): the predicate-validation surfaces a relying party actually calls # (validate_*/require_valid_*/require_derived_*/classify_*/derive_*) were entirely outside the old pattern. @@ -43,7 +49,23 @@ # ACCEPTED terminations: a returned value, or a TYPED fail-closed error. ProofBundleError covers # BundleFormatError / BudgetExceeded / PQUnavailable / UnsupportedError / CanonicalizerUnavailable / PolicyError # / SdjwtVcError / EvalClaimError-as-PBError; ValueError covers EvalClaimError + the rfc8785 domain family. -_ACCEPTED = (ProofBundleError, ValueError) +# `FileNotFoundError` added 2026-08-16 — and the FIRST attempt added `OSError`, which was wrong in a +# way worth recording, because the mistake and the claim contradicted each other. The commit text said +# "widens by a single measured case, not by a guess"; the mechanism widened the whole hierarchy. +# `OSError` is the base class of `PermissionError`, `TimeoutError`, `BrokenPipeError` and more. A +# `PermissionError` on an anchor file is not a missing file — it can be an indicator that something +# blocked access, and swallowing it silently is fail-open on exactly the axis this property defends. +# The counter-read caught it (un, REJECT, 2026-08-16): admit the measured case, not its family. +# +# Why this ONE subclass is admissible: the contract forbids a surface CRASHING INSTEAD OF DECIDING — +# the type-confusion signatures in `_FORBIDDEN`. A loader reporting "this path does not exist" is the +# opposite: fail-closed, informative, and it produces no verdict a relying party could mistake for a +# pass. Measured across all 90 discovered surfaces and the full corpus: ZERO forbidden escapes and +# exactly ONE unclassified case (`emit.load_signer` on `b"bytes-not-str"` → `FileNotFoundError`). +# +# Any OTHER `OSError` subclass therefore still lands in the unclassified branch below and is REPORTED, +# which is the point: the next one gets a decision, not an inherited pass. +_ACCEPTED = (ProofBundleError, ValueError, FileNotFoundError) # FORBIDDEN raw terminations = the type-confusion crash signatures a public verify surface must never emit. _FORBIDDEN = (AttributeError, TypeError, RecursionError, KeyError, IndexError, UnicodeDecodeError, MemoryError) @@ -158,6 +180,20 @@ def test_no_public_surface_raises_raw_on_hostile_primary(self): except _FORBIDDEN as exc: escapes.append(f"{mod_name}.{name} on {type(bad).__name__}: raw " f"{type(exc).__name__}: {exc}") + except Exception as exc: # noqa: BLE001 — see below, this is the point + # UNCLASSIFIED IS REPORTED, NOT SWALLOWED (2026-08-16). Until this branch existed, an + # exception that was neither _ACCEPTED nor _FORBIDDEN propagated straight out of this + # loop: the test ended as ERROR and every surface AFTER the offending one was never + # reached. The taxonomy's gap did not under-report, it STOPPED MEASURING — and the + # damage scaled with iteration position, not with severity. Measured when found: + # `emit.load_signer` sat at position 87 of 90, so three surfaces went untested; the + # same gap at position 1 would have cost 89. + # + # A third axis of the same instrument. The module axis (population) and the argument + # axis (only position 0) were already known; this is the exception-taxonomy axis. + escapes.append(f"{mod_name}.{name} on {type(bad).__name__}: UNCLASSIFIED " + f"{type(exc).__name__}: {exc} — neither accepted nor forbidden; " + f"decide which it is instead of letting it abort the sweep") self.assertEqual(escapes, [], "raw type-confusion escapes over the AUTO-DISCOVERED surface family:\n" + "\n".join(escapes))