diff --git a/CONFORMANCE.md b/CONFORMANCE.md index 1480888..c1f88e2 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -67,3 +67,28 @@ contradict each other in either direction. The corpus carries one vector per status value and one per rejection class under `conformance/provenance/`. A rule with no rejection vector is not conformance-testable: an implementation could satisfy every positive case and still accept anything. + +## Receipt-envelope profile (5.1) + +`conformance/envelope_profile/` holds ten vectors for the profile in +`docs/RECEIPT_ENVELOPE_PROFILE.md`: at least one counter-proof and one positive control for each of +**R1 to R4**. R1 carries three, because two of its three divergence axes cannot arise in a format +that refuses floats — for those the refusal itself is the counter-proof. + +**R5 carries no vector, and that is recorded rather than quietly left out.** Its field form was +withdrawn on 2026-08-30 after `draft-hillier-coverage-attestation-00` (CAP-1, 20 Aug 2026) was +measured to rule out the shape this profile had proposed. A counter-proof against a withdrawn shape +would test nothing; the R5 probe follows once CAP-1 has been read. +They run through the library's own emit and verify path — a vector family checked by a purpose-built +mock would prove something about the mock. + +The positive controls are not decoration. Without them a verifier that rejected every input would +score perfectly on the counter-proofs, and the corpus would call that conformance. + +R6 (every rule ships its counter-proof) deliberately has NO vector: a case asserting that the cases +exist is the tautology the rule warns about. R6 is satisfied by the family existing and by its +detection rate being measured — the planted defects that remain applicable after the R5 withdrawal +are counted in the profile, together with the two that escaped on the first attempt and the two +attempts that were ineffective rather than escaped (they changed a message, not a property). A third gap came from reading the profile against the +corpus rather than from the meta-test: a meta-test measures whether a shipped check can fail, it +cannot notice a check that was never shipped. diff --git a/INTEROP.md b/INTEROP.md index db2db6f..8772603 100644 --- a/INTEROP.md +++ b/INTEROP.md @@ -63,6 +63,75 @@ for different threats (computation-correctness vs. artifact authenticity/integri [ValiChord](https://github.com/ValiChord/ValiChord) is a real neighbour: its `valichord_attestation` (Apache-2.0) also attests eval runs and, like proofbundle, canonicalizes with RFC 8785 JCS. Named fairly, the v1 library differs in exactly the standards proofbundle leads with: its format v1 carries **no digital signature** (`signatures` is reserved for v2), uses a **simple SHA-256 Merkle tree** (no RFC 6962 domain separation), and has **no SD-JWT, no in-toto, and no Every Eval Ever converter**; blind peer consensus and an attested log live in its Holochain layer (v2 scope). proofbundle is complementary — the portable, standards-native, transparency-log-anchored receipt layer — not a rival network. +## CSOAI inspect-receipts — measured, three pinned commits + +[`CSOAI-ORG/inspect-receipts`](https://github.com/CSOAI-ORG/inspect-receipts) (package +`inspect-signed-receipt`, schema `csoai.inspect-receipt/0.2`) signs Inspect AI eval runs. It is the +nearest neighbour to this project's own shape, and the only one here measured **independently** +rather than read from its documentation. + +**This section is a measurement record.** Every line is pinned to a commit hash and a date. It +contains no assessment of the project, its authors or their intentions, and nothing about work that +may exist outside the commits named. A measurement against a pinned commit says what that tree does; +it says nothing about a branch nobody published. + +### What was measured, and when + +| Commit | Date | Measured | +|---|---|---| +| `ca3fd060` | 2026-08-19 | 2 commits, no CI, no LICENSE file, no did:web resolution path | +| `8e33f160` | 2026-08-20 | 7 commits, publish-only workflow, 6 tests (green when run by hand), did:web resolution present and working | +| `397ae3ad` | 2026-08-29 | canonicalisation re-measured with the real `jcs 0.2.1` encoder installed | + +### Envelope interoperability — both directions, both fail before signature verification + +Measured 2026-08-20 against `8e33f160`, with `proofbundle==4.0.0` from PyPI, both verifiers in the +same throwaway environment. + +- **Their receipt through our verifier:** `ERROR: unsupported schema 'csoai.inspect-receipt/0.2', + expected 'proofbundle/v0.1'` — `proofbundle/bundle.py:298`, `UnsupportedError`. +- **Our `conformance/bundle/valid-minimal` through their verifier:** + `INVALID — content_id mismatch` — their `receipt.py:295-301`, which runs *before* their signature + check. Our bundle carries no `content_id` field at all; its top-level fields are `merkle`, + `payload_b64`, `schema`, `sd_jwt_vc`, `signature`. + +Neither direction reaches the cryptography. **This is the subject of +[issue #147](https://github.com/b7n0de/proofbundle/issues/147)** and the reason a common envelope is +worth defining: two implementations that both sign correctly still cannot read each other. + +A control was run first in both directions — each verifier against its own fixture — because a +failure at the wrapper looks exactly like a failure at the substance. In the first pass ours was +invoked as `python -m proofbundle`, which the package does not provide; both legs then failed on the +invocation rather than on the question, and the control leg was the one that mattered. Repeated with +the real entry point. + +### Canonicalisation — the divergence is string escaping alone + +Measured 2026-08-29 against `397ae3ad`, with `jcs 0.2.1` and `rfc8785 0.1.4` both really installed. + +| | `jcs.canonicalize` | `rfc8785` | their `canonical.canonical_bytes` | +|---|---|---|---| +| UTF-16 key order | raw | same | same order, **escaped** | +| number `1e-7` | `1e-7` | same | same | +| integer `2` | `2` | same | same | + +**Key ordering agrees everywhere** — that was vector 1's actual question, and it is answered. The +bytes differ because the encoder is parametrised as +`JSONEncoder(sort_keys=True, ensure_ascii=True, separators=(",",":"))`, and `ensure_ascii=True` +writes every non-ASCII character as `\uXXXX`, while RFC 8785 emits raw UTF-8. Since +`content_id = sha256(canonical_bytes(body))`, a body containing one non-ASCII character yields a +different `content_id` than a conformant serializer computes. **ASCII-only receipts are byte-identical**, +which is why a corpus of ASCII vectors does not surface it — an earlier pass of ours reported "no +finding" on exactly that basis and was +[corrected in the thread on 28 August](https://github.com/b7n0de/proofbundle/issues/147). + +### Not measured + +Whether the escaping is intended as RFC 8785 conformant (their docstring names the `\uXXXX` +escaping as part of the implementation, but says nothing about the intent). RFC 8785 conformance +beyond the three vectors. Any state of the code outside the three pinned commits. How the two +projects should reconcile — that is a conversation in the issue, not a measurement. + ## Comparison tables (fair, at-a-glance) ### vs Sigstore Rekor / Rekor v2 diff --git a/conformance/envelope_profile/generate_vectors.py b/conformance/envelope_profile/generate_vectors.py new file mode 100644 index 0000000..ef1fcbc --- /dev/null +++ b/conformance/envelope_profile/generate_vectors.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""Generate the receipt-envelope-profile conformance vectors (run ONCE, fixtures are committed bytes). + +Provenance: docs/RECEIPT_ENVELOPE_PROFILE.md (v0.1, PROPOSED — an Owner decision, not adopted). +Ten vectors, one COUNTER-PROOF and one POSITIVE CONTROL per rule R1 to R5. R6 has no vector of its +own: R6 says every rule ships its counter-proof, so R6 is satisfied BY this corpus existing, not by +a case inside it — a case asserting "the cases exist" would be the tautology the profile warns about. + +Every vector runs through OUR OWN emit and verify path. A profile whose vectors are checked by a +separate mock proves something about the mock. + +All signing keys are FRESH THROWAWAY TEST KEYS generated at build time and never stored. Regeneration +changes bytes — rerun, re-review the diff, recommit. Never hand-edit generated fixtures. +""" +from __future__ import annotations + +import hashlib +import json +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "src")) + +from proofbundle.emit import emit_bundle, generate_signer # noqa: E402 +from proofbundle.evalclaim import ( # noqa: E402 + build_eval_claim, + canonicalize, + decode_eval_claim, + emit_eval_receipt, +) + +HERE = pathlib.Path(__file__).resolve().parent +TS = "2026-08-30T00:00:00Z" + + +def _base(signer, **over): + kw = dict(suite="safety-refusal", suite_version="v1", metric="refusal_rate", comparator=">=", + threshold="0.80", score="0.92", n=500, model_id="acme/model-x", + dataset_id="acme/dataset-y", issuer="ed25519:placeholder", timestamp=TS) + kw.update(over) + claim, _ = build_eval_claim(**kw) + return claim + + +def _write(name: str, case: dict, files: dict) -> None: + d = HERE / name + d.mkdir(parents=True, exist_ok=True) + (d / "case.json").write_text(json.dumps(case, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + for fn, obj in files.items(): + (d / fn).write_text(json.dumps(obj, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + print(f" {name}") + + +def main() -> int: + s = generate_signer() + + # ── R1 canonicalization ──────────────────────────────────────────────────────────────────── + # The object is deliberately non-ASCII: that is the ONLY axis on which the two serializations + # diverge. Ordering and numbers are RFC-8785-correct in both, which is exactly why a corpus of + # ASCII-only vectors cannot see this and an earlier pass of ours reported "no finding". + # Covers BOTH axes that can arise in this format at once: the UTF-16 key ordering (the emoji + # sorts FIRST by code units, LAST by code points) and non-ASCII escaping. Measured: the legacy + # serialization gets both wrong on this one object. + obj = {"\U0001F600": 2, "\uFF3A": 1, "café": 3, "b": 4, "a": [1, 2, 3]} + _write("r1-positive-control-canonical-root", { + "caseId": "envelope-profile-r1-positive-control-canonical-root", + "kind": "envelope_profile_rule", "rule": "R1", "role": "positive_control", + "input": "object.json", + "attribution": "receipt-envelope-profile v0.1 R1 — one normative canonicalization (RFC 8785)", + "expected": {"contentRootHex": hashlib.sha256(canonicalize(obj)).hexdigest()}, + "specRefs": ["docs/RECEIPT_ENVELOPE_PROFILE.md", "docs/SCITT_CPB_MAPPING.md", "RFC 8785"], + "rationale": "Our emit path canonicalizes with the real RFC 8785 serializer and must reproduce " + "this content root byte for byte. An implementation that produces a different root " + "for this object is not running one normative canonicalization.", + }, {"object.json": obj}) + + _write("r1-counter-proof-nonconformant-serializer-diverges", { + "caseId": "envelope-profile-r1-counter-proof-nonconformant-serializer-diverges", + "kind": "envelope_profile_rule", "rule": "R1", "role": "counter_proof", + "input": "object.json", + "attribution": "receipt-envelope-profile v0.1 R1 — measured 2026-08-28 against inspect-receipts@397ae3ad", + "expected": {"nonConformantDiffers": True}, + "specRefs": ["docs/RECEIPT_ENVELOPE_PROFILE.md", "RFC 8785"], + "rationale": "The counter-proof that makes R1 load-bearing: a json.dumps(sort_keys=True, " + "ensure_ascii=True) serialization of the SAME object must produce a DIFFERENT " + "content root. If the two agreed, R1 would be an empty requirement. The divergence " + "is string escaping alone — \\uXXXX against raw UTF-8 — which is why three " + "ASCII-only vectors did not isolate it.", + }, {"object.json": obj}) + + # Divergence vectors 2 and 3 from the issue thread — the small exponent (1e-7) and the + # integer-valued float (2.0) — CANNOT ARISE in this format: the claim profile refuses Python + # floats outright and requires decimal STRINGS. Shipping a byte-comparison vector for them would + # be theatre. The honest counter-proof for those two axes is the refusal itself, so it gets one. + _write("r1-counter-proof-float-is-refused-so-two-axes-cannot-arise", { + "caseId": "envelope-profile-r1-counter-proof-float-is-refused-so-two-axes-cannot-arise", + "kind": "envelope_profile_rule", "rule": "R1", "role": "counter_proof", + "input": "objects.json", + "attribution": "receipt-envelope-profile v0.1 R1 — the two axes this format removes rather than resolves", + "expected": {"canonicalizeRefuses": True}, + "specRefs": ["docs/RECEIPT_ENVELOPE_PROFILE.md", "RFC 8785"], + "rationale": "Both objects carry a Python float: 1e-7 (divergence vector 2) and 2.0 (vector " + "3). Our canonicalizer must REFUSE both rather than serialize them, because the " + "profile requires decimal strings. That refusal is why the two axes cannot " + "produce a divergent content root here — not because we resolved them, but " + "because the format removes the shape they need. A verifier that quietly " + "serialized a float would reopen both axes at once.", + }, {"objects.json": [{"x": 1e-7}, {"x": 2.0}]}) + + # ── R2 the schema-id is read; refusal is a SEPARATE outcome ──────────────────────────────── + good_claim = _base(s) + good_bundle = emit_eval_receipt(good_claim, s) + decoded = decode_eval_claim(good_bundle) + assert decoded is not None + + _write("r2-positive-control-known-schema-classifies-valid", { + "caseId": "envelope-profile-r2-positive-control-known-schema-classifies-valid", + "kind": "envelope_profile_rule", "rule": "R2", "role": "positive_control", + "input": "bundle.json", + "attribution": "receipt-envelope-profile v0.1 R2 — the schema-id is read before anything else", + "expected": {"classification": "valid"}, + "specRefs": ["docs/RECEIPT_ENVELOPE_PROFILE.md"], + "rationale": "The positive control that stops the counter-proof from passing for the wrong " + "reason: a verifier that refused EVERYTHING would satisfy the R2 counter-proof " + "and be useless.", + }, {"bundle.json": good_bundle}) + + foreign = emit_bundle(canonicalize(dict(decoded, schema="acme/other-receipt/v9")), s) + _write("r2-counter-proof-foreign-schema-id-is-refused-not-invalid", { + "caseId": "envelope-profile-r2-counter-proof-foreign-schema-id-is-refused-not-invalid", + "kind": "envelope_profile_rule", "rule": "R2", "role": "counter_proof", + "input": "bundle.json", + "attribution": "receipt-envelope-profile v0.1 R2 — measured 2026-08-26 against inspect-receipts@397ae3ad (valid=True)", + "expected": {"classification": "refused_unknown_schema"}, + "specRefs": ["docs/RECEIPT_ENVELOPE_PROFILE.md"], + "rationale": "A cryptographically sound bundle declaring a schema this verifier does not know. " + "TWO wrong answers exist and the case excludes BOTH: `valid` (the measured defect " + "in the reference implementation) and `invalid` (a wrong verdict on someone else's " + "sound artifact). The refusal is its own outcome. NOTE, honestly: the released " + "`decode_eval_claim` returns None for a refusal AND for an invalid claim; the " + "distinction lives in the additive `classify_eval_claim`, because changing the " + "released return contract would be a breaking SemVer step.", + }, {"bundle.json": foreign}) + + # The sharp edge of R2, found by the meta-test and NOT by writing the vectors: a bundle that is + # BOTH unverifiable AND foreign-schema. Every other vector classifies the same with or without + # the authenticity-first ordering, so removing that ordering left the corpus fully green. + kaputt = json.loads(json.dumps(foreign)) + # The SIGNATURE is corrupted, deliberately NOT the payload. A mangled payload stops being valid + # JSON and lands on `invalid` through a completely different route, so such a vector would pass + # with or without the ordering and discriminate nothing. Here the payload stays well-formed and + # foreign-schema; only the signature is bad. That is the one shape on which the two orderings + # give different answers. (First attempt corrupted the payload and was measured to prove nothing.) + _sig = kaputt["signature"]["sig_b64"] + kaputt["signature"] = dict(kaputt["signature"], + sig_b64=("B" + _sig[1:]) if _sig[0] != "B" else ("C" + _sig[1:])) + _write("r2-counter-proof-unverifiable-is-invalid-not-refused", { + "caseId": "envelope-profile-r2-counter-proof-unverifiable-is-invalid-not-refused", + "kind": "envelope_profile_rule", "rule": "R2", "role": "counter_proof", + "input": "bundle.json", + "attribution": "receipt-envelope-profile v0.1 R2 — ordering counter-proof, added after a planted " + "defect survived the first ten vectors (2026-08-30)", + "expected": {"classification": "invalid"}, + "specRefs": ["docs/RECEIPT_ENVELOPE_PROFILE.md"], + "rationale": "A refusal says 'I cannot judge this'. A broken signature IS judgeable, so a " + "bundle that fails verification must be `invalid` even though its payload names a " + "schema this verifier does not know. Reporting `refused_unknown_schema` here would " + "let a forger buy silence by renaming the schema field. This case exists because a " + "planted defect removing the authenticity-first ordering left all ten other " + "vectors green — the ordering was documented and unproven.", + }, {"bundle.json": kaputt}) + + # ── R3 a reported binding is a performed binding ─────────────────────────────────────────── + tree_n = 500 + root_b64 = __import__("base64").b64encode(b"\x11" * 32).decode("ascii") + coherent = _base(s, samples={"root_b64": root_b64, "n": tree_n, "leaf_alg": "sha256-rfc6962-sdjwt-v1"}) + _write("r3-positive-control-coherent-sample-binding", { + "caseId": "envelope-profile-r3-positive-control-coherent-sample-binding", + "kind": "envelope_profile_rule", "rule": "R3", "role": "positive_control", + "input": "bundle.json", + "attribution": "receipt-envelope-profile v0.1 R3 — a reported binding is a performed binding", + "expected": {"classification": "valid"}, + "specRefs": ["docs/RECEIPT_ENVELOPE_PROFILE.md"], + "rationale": "The committed tree covers exactly the samples the aggregate was computed over.", + }, {"bundle.json": emit_eval_receipt(coherent, s)}) + + lying = dict(decode_eval_claim(emit_eval_receipt(coherent, s)) or {}) + lying["samples"] = dict(lying["samples"], n=1) + _write("r3-counter-proof-hand-signed-binding-lies-about-tree-size", { + "caseId": "envelope-profile-r3-counter-proof-hand-signed-binding-lies-about-tree-size", + "kind": "envelope_profile_rule", "rule": "R3", "role": "counter_proof", + "input": "bundle.json", + "attribution": "receipt-envelope-profile v0.1 R3 — the emit-vs-verify asymmetry class", + "expected": {"classification": "invalid"}, + "specRefs": ["docs/RECEIPT_ENVELOPE_PROFILE.md"], + "rationale": "The signature is VALID and the bundle verifies — this claim was hand-built and " + "signed, bypassing the blessed emitter. Its reported sample binding contradicts " + "the claim's own n. A guarantee enforced only at emit would accept this; the rule " + "must hold where it is CONSUMED, not only where it is produced.", + }, {"bundle.json": emit_bundle(canonicalize(lying), s)}) + + # ── R4 key resolution fails closed ───────────────────────────────────────────────────────── + _write("r4-positive-control-issuer-is-the-signing-key", { + "caseId": "envelope-profile-r4-positive-control-issuer-is-the-signing-key", + "kind": "envelope_profile_rule", "rule": "R4", "role": "positive_control", + "input": "bundle.json", + "attribution": "receipt-envelope-profile v0.1 R4 — key resolution fails closed", + "expected": {"classification": "valid"}, + "specRefs": ["docs/RECEIPT_ENVELOPE_PROFILE.md"], + "rationale": "The claim's issuer fingerprint IS the key that signed the bundle.", + }, {"bundle.json": good_bundle}) + + other = generate_signer() + mismatched = emit_bundle(canonicalize(decoded), other) # payload names signer s, signed by `other` + _write("r4-counter-proof-issuer-is-not-the-signing-key", { + "caseId": "envelope-profile-r4-counter-proof-issuer-is-not-the-signing-key", + "kind": "envelope_profile_rule", "rule": "R4", "role": "counter_proof", + "input": "bundle.json", + "attribution": "receipt-envelope-profile v0.1 R4 — an embedded key alone never carries valid=True", + "expected": {"classification": "invalid"}, + "specRefs": ["docs/RECEIPT_ENVELOPE_PROFILE.md"], + "rationale": "The bundle carries a perfectly good signature by a DIFFERENT key than the one the " + "payload names as issuer. It verifies as a bundle and must still not be a valid " + "claim: a key that travels with the receipt authenticates nothing on its own.", + }, {"bundle.json": mismatched}) + + # ── R5 — KEINE Vektoren, und das ist eine Entscheidung, keine Luecke ──────────────────────── + # Owner-Berichtigung Fassung 8 vom 30.08.2026: draft-hillier-coverage-attestation-00 (CAP-1, + # 20.08.2026) verlangt je nicht gepruefter Einheit einen eigenen begruendeten Eintrag und weist + # einen Rest, der sich nur durch Subtraktion ausgleicht, ausdruecklich zurueck. Genau so ein Rest + # waren unsere drei Zahlen. Die Feldform ist damit OFFEN, und eine Gegenprobe gegen eine Form, + # die gerade zurueckgezogen wurde, waere wertlos. Die R5-Probe entsteht, nachdem CAP-1 gelesen + # ist — der Entwurf selbst ist bisher NICHT GELESEN, und aus einer Zusammenfassung nachgebaute + # Felder waeren geraten. + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/conformance/envelope_profile/r1-counter-proof-float-is-refused-so-two-axes-cannot-arise/case.json b/conformance/envelope_profile/r1-counter-proof-float-is-refused-so-two-axes-cannot-arise/case.json new file mode 100644 index 0000000..ea237e1 --- /dev/null +++ b/conformance/envelope_profile/r1-counter-proof-float-is-refused-so-two-axes-cannot-arise/case.json @@ -0,0 +1,16 @@ +{ + "caseId": "envelope-profile-r1-counter-proof-float-is-refused-so-two-axes-cannot-arise", + "kind": "envelope_profile_rule", + "rule": "R1", + "role": "counter_proof", + "input": "objects.json", + "attribution": "receipt-envelope-profile v0.1 R1 — the two axes this format removes rather than resolves", + "expected": { + "canonicalizeRefuses": true + }, + "specRefs": [ + "docs/RECEIPT_ENVELOPE_PROFILE.md", + "RFC 8785" + ], + "rationale": "Both objects carry a Python float: 1e-7 (divergence vector 2) and 2.0 (vector 3). Our canonicalizer must REFUSE both rather than serialize them, because the profile requires decimal strings. That refusal is why the two axes cannot produce a divergent content root here — not because we resolved them, but because the format removes the shape they need. A verifier that quietly serialized a float would reopen both axes at once." +} diff --git a/conformance/envelope_profile/r1-counter-proof-float-is-refused-so-two-axes-cannot-arise/objects.json b/conformance/envelope_profile/r1-counter-proof-float-is-refused-so-two-axes-cannot-arise/objects.json new file mode 100644 index 0000000..b71effa --- /dev/null +++ b/conformance/envelope_profile/r1-counter-proof-float-is-refused-so-two-axes-cannot-arise/objects.json @@ -0,0 +1,8 @@ +[ + { + "x": 1e-07 + }, + { + "x": 2.0 + } +] diff --git a/conformance/envelope_profile/r1-counter-proof-nonconformant-serializer-diverges/case.json b/conformance/envelope_profile/r1-counter-proof-nonconformant-serializer-diverges/case.json new file mode 100644 index 0000000..38d20ef --- /dev/null +++ b/conformance/envelope_profile/r1-counter-proof-nonconformant-serializer-diverges/case.json @@ -0,0 +1,16 @@ +{ + "caseId": "envelope-profile-r1-counter-proof-nonconformant-serializer-diverges", + "kind": "envelope_profile_rule", + "rule": "R1", + "role": "counter_proof", + "input": "object.json", + "attribution": "receipt-envelope-profile v0.1 R1 — measured 2026-08-28 against inspect-receipts@397ae3ad", + "expected": { + "nonConformantDiffers": true + }, + "specRefs": [ + "docs/RECEIPT_ENVELOPE_PROFILE.md", + "RFC 8785" + ], + "rationale": "The counter-proof that makes R1 load-bearing: a json.dumps(sort_keys=True, ensure_ascii=True) serialization of the SAME object must produce a DIFFERENT content root. If the two agreed, R1 would be an empty requirement. The divergence is string escaping alone — \\uXXXX against raw UTF-8 — which is why three ASCII-only vectors did not isolate it." +} diff --git a/conformance/envelope_profile/r1-counter-proof-nonconformant-serializer-diverges/object.json b/conformance/envelope_profile/r1-counter-proof-nonconformant-serializer-diverges/object.json new file mode 100644 index 0000000..7dec595 --- /dev/null +++ b/conformance/envelope_profile/r1-counter-proof-nonconformant-serializer-diverges/object.json @@ -0,0 +1,11 @@ +{ + "😀": 2, + "Z": 1, + "café": 3, + "b": 4, + "a": [ + 1, + 2, + 3 + ] +} diff --git a/conformance/envelope_profile/r1-positive-control-canonical-root/case.json b/conformance/envelope_profile/r1-positive-control-canonical-root/case.json new file mode 100644 index 0000000..a52c7b2 --- /dev/null +++ b/conformance/envelope_profile/r1-positive-control-canonical-root/case.json @@ -0,0 +1,17 @@ +{ + "caseId": "envelope-profile-r1-positive-control-canonical-root", + "kind": "envelope_profile_rule", + "rule": "R1", + "role": "positive_control", + "input": "object.json", + "attribution": "receipt-envelope-profile v0.1 R1 — one normative canonicalization (RFC 8785)", + "expected": { + "contentRootHex": "cbb19685ce376c7f1fb6be7985472de6b63d05e49f5cca0bb26e02491b17e41c" + }, + "specRefs": [ + "docs/RECEIPT_ENVELOPE_PROFILE.md", + "docs/SCITT_CPB_MAPPING.md", + "RFC 8785" + ], + "rationale": "Our emit path canonicalizes with the real RFC 8785 serializer and must reproduce this content root byte for byte. An implementation that produces a different root for this object is not running one normative canonicalization." +} diff --git a/conformance/envelope_profile/r1-positive-control-canonical-root/object.json b/conformance/envelope_profile/r1-positive-control-canonical-root/object.json new file mode 100644 index 0000000..7dec595 --- /dev/null +++ b/conformance/envelope_profile/r1-positive-control-canonical-root/object.json @@ -0,0 +1,11 @@ +{ + "😀": 2, + "Z": 1, + "café": 3, + "b": 4, + "a": [ + 1, + 2, + 3 + ] +} diff --git a/conformance/envelope_profile/r2-counter-proof-foreign-schema-id-is-refused-not-invalid/bundle.json b/conformance/envelope_profile/r2-counter-proof-foreign-schema-id-is-refused-not-invalid/bundle.json new file mode 100644 index 0000000..a1e3f0d --- /dev/null +++ b/conformance/envelope_profile/r2-counter-proof-foreign-schema-id-is-refused-not-invalid/bundle.json @@ -0,0 +1,16 @@ +{ + "schema": "proofbundle/v0.1", + "payload_b64": "eyJhc3N1cmFuY2VfbGV2ZWwiOiJzZWxmX2F0dGVzdGVkIiwiY29tbWl0X2FsZyI6InNoYTI1Ni1zYWx0ZWQtdjEiLCJjb21wYXJhdG9yIjoiPj0iLCJkYXRhc2V0X2lkX2NvbW1pdCI6InNoYTI1Njo1ZTE2OTUzNmM1OGJkMzIxNTBkYjMzODAxMjM0NGU5N2YxZDhlN2YzOWJjM2VlMTQ5ZDY1MzNmODViOWI0YTdjIiwiaXNzdWVyIjoiZWQyNTUxOTpOY1hWWVByQk0rdzlkRmNhY3JHajlHRTgvazNSdjJIRWdoWS9qVmZNMEJBPSIsIm1ldHJpYyI6InJlZnVzYWxfcmF0ZSIsIm1vZGVsX2lkX2NvbW1pdCI6InNoYTI1Njo0NDg2ZmUyZWRkZmY3Yzg2NDQ4MTUwNjFhYTk2ZTkxNWFhMzA0M2FiNGQ4MGFiMzkwMWMwZjIzMWFmNjM2MTNmIiwibiI6NTAwLCJwYXNzZWQiOnRydWUsInNjaGVtYSI6ImFjbWUvb3RoZXItcmVjZWlwdC92OSIsInN1aXRlIjoic2FmZXR5LXJlZnVzYWwiLCJzdWl0ZV92ZXJzaW9uIjoidjEiLCJ0aHJlc2hvbGQiOiIwLjgwIiwidGltZXN0YW1wIjoiMjAyNi0wOC0zMFQwMDowMDowMFoifQ==", + "signature": { + "alg": "ed25519", + "public_key_b64": "NcXVYPrBM+w9dFcacrGj9GE8/k3Rv2HEghY/jVfM0BA=", + "sig_b64": "S543n1RpmSu44l8tz6gsK5qUkHzShO+jw7Rltn3KPBbTSKXzoj14G2DnBvcpyNiDRrVZ5bw1OiIW1ngQQTnxBQ==" + }, + "merkle": { + "hash_alg": "sha256-rfc6962", + "leaf_index": 0, + "tree_size": 1, + "inclusion_proof_b64": [], + "root_b64": "Bc7vyMonCZ77mZ2GIR0Cb0t+8mwK4pfmbESqyxJ3h/w=" + } +} diff --git a/conformance/envelope_profile/r2-counter-proof-foreign-schema-id-is-refused-not-invalid/case.json b/conformance/envelope_profile/r2-counter-proof-foreign-schema-id-is-refused-not-invalid/case.json new file mode 100644 index 0000000..3c1d9bc --- /dev/null +++ b/conformance/envelope_profile/r2-counter-proof-foreign-schema-id-is-refused-not-invalid/case.json @@ -0,0 +1,15 @@ +{ + "caseId": "envelope-profile-r2-counter-proof-foreign-schema-id-is-refused-not-invalid", + "kind": "envelope_profile_rule", + "rule": "R2", + "role": "counter_proof", + "input": "bundle.json", + "attribution": "receipt-envelope-profile v0.1 R2 — measured 2026-08-26 against inspect-receipts@397ae3ad (valid=True)", + "expected": { + "classification": "refused_unknown_schema" + }, + "specRefs": [ + "docs/RECEIPT_ENVELOPE_PROFILE.md" + ], + "rationale": "A cryptographically sound bundle declaring a schema this verifier does not know. TWO wrong answers exist and the case excludes BOTH: `valid` (the measured defect in the reference implementation) and `invalid` (a wrong verdict on someone else's sound artifact). The refusal is its own outcome. NOTE, honestly: the released `decode_eval_claim` returns None for a refusal AND for an invalid claim; the distinction lives in the additive `classify_eval_claim`, because changing the released return contract would be a breaking SemVer step." +} diff --git a/conformance/envelope_profile/r2-counter-proof-unverifiable-is-invalid-not-refused/bundle.json b/conformance/envelope_profile/r2-counter-proof-unverifiable-is-invalid-not-refused/bundle.json new file mode 100644 index 0000000..802fdf9 --- /dev/null +++ b/conformance/envelope_profile/r2-counter-proof-unverifiable-is-invalid-not-refused/bundle.json @@ -0,0 +1,16 @@ +{ + "schema": "proofbundle/v0.1", + "payload_b64": "eyJhc3N1cmFuY2VfbGV2ZWwiOiJzZWxmX2F0dGVzdGVkIiwiY29tbWl0X2FsZyI6InNoYTI1Ni1zYWx0ZWQtdjEiLCJjb21wYXJhdG9yIjoiPj0iLCJkYXRhc2V0X2lkX2NvbW1pdCI6InNoYTI1Njo1ZTE2OTUzNmM1OGJkMzIxNTBkYjMzODAxMjM0NGU5N2YxZDhlN2YzOWJjM2VlMTQ5ZDY1MzNmODViOWI0YTdjIiwiaXNzdWVyIjoiZWQyNTUxOTpOY1hWWVByQk0rdzlkRmNhY3JHajlHRTgvazNSdjJIRWdoWS9qVmZNMEJBPSIsIm1ldHJpYyI6InJlZnVzYWxfcmF0ZSIsIm1vZGVsX2lkX2NvbW1pdCI6InNoYTI1Njo0NDg2ZmUyZWRkZmY3Yzg2NDQ4MTUwNjFhYTk2ZTkxNWFhMzA0M2FiNGQ4MGFiMzkwMWMwZjIzMWFmNjM2MTNmIiwibiI6NTAwLCJwYXNzZWQiOnRydWUsInNjaGVtYSI6ImFjbWUvb3RoZXItcmVjZWlwdC92OSIsInN1aXRlIjoic2FmZXR5LXJlZnVzYWwiLCJzdWl0ZV92ZXJzaW9uIjoidjEiLCJ0aHJlc2hvbGQiOiIwLjgwIiwidGltZXN0YW1wIjoiMjAyNi0wOC0zMFQwMDowMDowMFoifQ==", + "signature": { + "alg": "ed25519", + "public_key_b64": "NcXVYPrBM+w9dFcacrGj9GE8/k3Rv2HEghY/jVfM0BA=", + "sig_b64": "B543n1RpmSu44l8tz6gsK5qUkHzShO+jw7Rltn3KPBbTSKXzoj14G2DnBvcpyNiDRrVZ5bw1OiIW1ngQQTnxBQ==" + }, + "merkle": { + "hash_alg": "sha256-rfc6962", + "leaf_index": 0, + "tree_size": 1, + "inclusion_proof_b64": [], + "root_b64": "Bc7vyMonCZ77mZ2GIR0Cb0t+8mwK4pfmbESqyxJ3h/w=" + } +} diff --git a/conformance/envelope_profile/r2-counter-proof-unverifiable-is-invalid-not-refused/case.json b/conformance/envelope_profile/r2-counter-proof-unverifiable-is-invalid-not-refused/case.json new file mode 100644 index 0000000..c2b17af --- /dev/null +++ b/conformance/envelope_profile/r2-counter-proof-unverifiable-is-invalid-not-refused/case.json @@ -0,0 +1,15 @@ +{ + "caseId": "envelope-profile-r2-counter-proof-unverifiable-is-invalid-not-refused", + "kind": "envelope_profile_rule", + "rule": "R2", + "role": "counter_proof", + "input": "bundle.json", + "attribution": "receipt-envelope-profile v0.1 R2 — ordering counter-proof, added after a planted defect survived the first ten vectors (2026-08-30)", + "expected": { + "classification": "invalid" + }, + "specRefs": [ + "docs/RECEIPT_ENVELOPE_PROFILE.md" + ], + "rationale": "A refusal says 'I cannot judge this'. A broken signature IS judgeable, so a bundle that fails verification must be `invalid` even though its payload names a schema this verifier does not know. Reporting `refused_unknown_schema` here would let a forger buy silence by renaming the schema field. This case exists because a planted defect removing the authenticity-first ordering left all ten other vectors green — the ordering was documented and unproven." +} diff --git a/conformance/envelope_profile/r2-positive-control-known-schema-classifies-valid/bundle.json b/conformance/envelope_profile/r2-positive-control-known-schema-classifies-valid/bundle.json new file mode 100644 index 0000000..fb38f1e --- /dev/null +++ b/conformance/envelope_profile/r2-positive-control-known-schema-classifies-valid/bundle.json @@ -0,0 +1,16 @@ +{ + "schema": "proofbundle/v0.1", + "payload_b64": "eyJhc3N1cmFuY2VfbGV2ZWwiOiJzZWxmX2F0dGVzdGVkIiwiY29tbWl0X2FsZyI6InNoYTI1Ni1zYWx0ZWQtdjEiLCJjb21wYXJhdG9yIjoiPj0iLCJkYXRhc2V0X2lkX2NvbW1pdCI6InNoYTI1Njo1ZTE2OTUzNmM1OGJkMzIxNTBkYjMzODAxMjM0NGU5N2YxZDhlN2YzOWJjM2VlMTQ5ZDY1MzNmODViOWI0YTdjIiwiaXNzdWVyIjoiZWQyNTUxOTpOY1hWWVByQk0rdzlkRmNhY3JHajlHRTgvazNSdjJIRWdoWS9qVmZNMEJBPSIsIm1ldHJpYyI6InJlZnVzYWxfcmF0ZSIsIm1vZGVsX2lkX2NvbW1pdCI6InNoYTI1Njo0NDg2ZmUyZWRkZmY3Yzg2NDQ4MTUwNjFhYTk2ZTkxNWFhMzA0M2FiNGQ4MGFiMzkwMWMwZjIzMWFmNjM2MTNmIiwibiI6NTAwLCJwYXNzZWQiOnRydWUsInNjaGVtYSI6InByb29mYnVuZGxlL2V2YWwtY2xhaW0vdjAuMSIsInN1aXRlIjoic2FmZXR5LXJlZnVzYWwiLCJzdWl0ZV92ZXJzaW9uIjoidjEiLCJ0aHJlc2hvbGQiOiIwLjgwIiwidGltZXN0YW1wIjoiMjAyNi0wOC0zMFQwMDowMDowMFoifQ==", + "signature": { + "alg": "ed25519", + "public_key_b64": "NcXVYPrBM+w9dFcacrGj9GE8/k3Rv2HEghY/jVfM0BA=", + "sig_b64": "g3xmQmsjXyoeQq6nI7TwnsbnESBDhZAHAdzId6axgbzQv6fEvYAam6j275nXDwgMhIULf3BePVUH7ZLfBpmmCA==" + }, + "merkle": { + "hash_alg": "sha256-rfc6962", + "leaf_index": 0, + "tree_size": 1, + "inclusion_proof_b64": [], + "root_b64": "xSnSGczXhc+kV38bH71IucBYCIgVgvsqPSTfNbFS13o=" + } +} diff --git a/conformance/envelope_profile/r2-positive-control-known-schema-classifies-valid/case.json b/conformance/envelope_profile/r2-positive-control-known-schema-classifies-valid/case.json new file mode 100644 index 0000000..cac9471 --- /dev/null +++ b/conformance/envelope_profile/r2-positive-control-known-schema-classifies-valid/case.json @@ -0,0 +1,15 @@ +{ + "caseId": "envelope-profile-r2-positive-control-known-schema-classifies-valid", + "kind": "envelope_profile_rule", + "rule": "R2", + "role": "positive_control", + "input": "bundle.json", + "attribution": "receipt-envelope-profile v0.1 R2 — the schema-id is read before anything else", + "expected": { + "classification": "valid" + }, + "specRefs": [ + "docs/RECEIPT_ENVELOPE_PROFILE.md" + ], + "rationale": "The positive control that stops the counter-proof from passing for the wrong reason: a verifier that refused EVERYTHING would satisfy the R2 counter-proof and be useless." +} diff --git a/conformance/envelope_profile/r3-counter-proof-hand-signed-binding-lies-about-tree-size/bundle.json b/conformance/envelope_profile/r3-counter-proof-hand-signed-binding-lies-about-tree-size/bundle.json new file mode 100644 index 0000000..c7f9073 --- /dev/null +++ b/conformance/envelope_profile/r3-counter-proof-hand-signed-binding-lies-about-tree-size/bundle.json @@ -0,0 +1,16 @@ +{ + "schema": "proofbundle/v0.1", + "payload_b64": "eyJhc3N1cmFuY2VfbGV2ZWwiOiJzZWxmX2F0dGVzdGVkIiwiY29tbWl0X2FsZyI6InNoYTI1Ni1zYWx0ZWQtdjEiLCJjb21wYXJhdG9yIjoiPj0iLCJkYXRhc2V0X2lkX2NvbW1pdCI6InNoYTI1NjpmNmUwMjI0OWE1OTA4YjY0ZTgzNTNlZTNiODNiMjgyOWRjNjQ3ZGI4Y2RiNGM4MDBmNWZjZTAzYTRkYTFkOWNmIiwiaXNzdWVyIjoiZWQyNTUxOTpOY1hWWVByQk0rdzlkRmNhY3JHajlHRTgvazNSdjJIRWdoWS9qVmZNMEJBPSIsIm1ldHJpYyI6InJlZnVzYWxfcmF0ZSIsIm1vZGVsX2lkX2NvbW1pdCI6InNoYTI1Njo0NGVjYTgxZTNiYmY0ZDVlYTdkM2U2YWE4NmY0YTY3OWQwZjVlOGY2MDQzMGZlNWNlNmViMjFiODRmMTg4MTQzIiwibiI6NTAwLCJwYXNzZWQiOnRydWUsInNhbXBsZXMiOnsibGVhZl9hbGciOiJzaGEyNTYtcmZjNjk2Mi1zZGp3dC12MSIsIm4iOjEsInJvb3RfYjY0IjoiRVJFUkVSRVJFUkVSRVJFUkVSRVJFUkVSRVJFUkVSRVJFUkVSRVJFUkVSRT0ifSwic2NoZW1hIjoicHJvb2ZidW5kbGUvZXZhbC1jbGFpbS92MC4xIiwic3VpdGUiOiJzYWZldHktcmVmdXNhbCIsInN1aXRlX3ZlcnNpb24iOiJ2MSIsInRocmVzaG9sZCI6IjAuODAiLCJ0aW1lc3RhbXAiOiIyMDI2LTA4LTMwVDAwOjAwOjAwWiJ9", + "signature": { + "alg": "ed25519", + "public_key_b64": "NcXVYPrBM+w9dFcacrGj9GE8/k3Rv2HEghY/jVfM0BA=", + "sig_b64": "rNMhVKjJl6fa0MukkjsW+cH45xYowRuwq9pjrRmqQF3G67FB4tZon10zkOUNwWTuPib6yxJlLEDLljoLLMp7CA==" + }, + "merkle": { + "hash_alg": "sha256-rfc6962", + "leaf_index": 0, + "tree_size": 1, + "inclusion_proof_b64": [], + "root_b64": "cEqFCcmVmnfRoyESdsDYWqLQ/y57clAiHPF/HaKRx7k=" + } +} diff --git a/conformance/envelope_profile/r3-counter-proof-hand-signed-binding-lies-about-tree-size/case.json b/conformance/envelope_profile/r3-counter-proof-hand-signed-binding-lies-about-tree-size/case.json new file mode 100644 index 0000000..8653407 --- /dev/null +++ b/conformance/envelope_profile/r3-counter-proof-hand-signed-binding-lies-about-tree-size/case.json @@ -0,0 +1,15 @@ +{ + "caseId": "envelope-profile-r3-counter-proof-hand-signed-binding-lies-about-tree-size", + "kind": "envelope_profile_rule", + "rule": "R3", + "role": "counter_proof", + "input": "bundle.json", + "attribution": "receipt-envelope-profile v0.1 R3 — the emit-vs-verify asymmetry class", + "expected": { + "classification": "invalid" + }, + "specRefs": [ + "docs/RECEIPT_ENVELOPE_PROFILE.md" + ], + "rationale": "The signature is VALID and the bundle verifies — this claim was hand-built and signed, bypassing the blessed emitter. Its reported sample binding contradicts the claim's own n. A guarantee enforced only at emit would accept this; the rule must hold where it is CONSUMED, not only where it is produced." +} diff --git a/conformance/envelope_profile/r3-positive-control-coherent-sample-binding/bundle.json b/conformance/envelope_profile/r3-positive-control-coherent-sample-binding/bundle.json new file mode 100644 index 0000000..598bfbf --- /dev/null +++ b/conformance/envelope_profile/r3-positive-control-coherent-sample-binding/bundle.json @@ -0,0 +1,16 @@ +{ + "schema": "proofbundle/v0.1", + "payload_b64": "eyJhc3N1cmFuY2VfbGV2ZWwiOiJzZWxmX2F0dGVzdGVkIiwiY29tbWl0X2FsZyI6InNoYTI1Ni1zYWx0ZWQtdjEiLCJjb21wYXJhdG9yIjoiPj0iLCJkYXRhc2V0X2lkX2NvbW1pdCI6InNoYTI1NjpmNmUwMjI0OWE1OTA4YjY0ZTgzNTNlZTNiODNiMjgyOWRjNjQ3ZGI4Y2RiNGM4MDBmNWZjZTAzYTRkYTFkOWNmIiwiaXNzdWVyIjoiZWQyNTUxOTpOY1hWWVByQk0rdzlkRmNhY3JHajlHRTgvazNSdjJIRWdoWS9qVmZNMEJBPSIsIm1ldHJpYyI6InJlZnVzYWxfcmF0ZSIsIm1vZGVsX2lkX2NvbW1pdCI6InNoYTI1Njo0NGVjYTgxZTNiYmY0ZDVlYTdkM2U2YWE4NmY0YTY3OWQwZjVlOGY2MDQzMGZlNWNlNmViMjFiODRmMTg4MTQzIiwibiI6NTAwLCJwYXNzZWQiOnRydWUsInNhbXBsZXMiOnsibGVhZl9hbGciOiJzaGEyNTYtcmZjNjk2Mi1zZGp3dC12MSIsIm4iOjUwMCwicm9vdF9iNjQiOiJFUkVSRVJFUkVSRVJFUkVSRVJFUkVSRVJFUkVSRVJFUkVSRVJFUkVSRVJFPSJ9LCJzY2hlbWEiOiJwcm9vZmJ1bmRsZS9ldmFsLWNsYWltL3YwLjEiLCJzdWl0ZSI6InNhZmV0eS1yZWZ1c2FsIiwic3VpdGVfdmVyc2lvbiI6InYxIiwidGhyZXNob2xkIjoiMC44MCIsInRpbWVzdGFtcCI6IjIwMjYtMDgtMzBUMDA6MDA6MDBaIn0=", + "signature": { + "alg": "ed25519", + "public_key_b64": "NcXVYPrBM+w9dFcacrGj9GE8/k3Rv2HEghY/jVfM0BA=", + "sig_b64": "XxmiAb8sKqUvCxpH+dgvaFbiog0LduVOnc13hx+g/jrLks3W1jScd/eMXjx3mDktfzAN4CHNKpBxGjytajsWBA==" + }, + "merkle": { + "hash_alg": "sha256-rfc6962", + "leaf_index": 0, + "tree_size": 1, + "inclusion_proof_b64": [], + "root_b64": "vw9uxJbO+uD/d9dIzOrexzudj0QY862jvRK5+9qcu1A=" + } +} diff --git a/conformance/envelope_profile/r3-positive-control-coherent-sample-binding/case.json b/conformance/envelope_profile/r3-positive-control-coherent-sample-binding/case.json new file mode 100644 index 0000000..67fa670 --- /dev/null +++ b/conformance/envelope_profile/r3-positive-control-coherent-sample-binding/case.json @@ -0,0 +1,15 @@ +{ + "caseId": "envelope-profile-r3-positive-control-coherent-sample-binding", + "kind": "envelope_profile_rule", + "rule": "R3", + "role": "positive_control", + "input": "bundle.json", + "attribution": "receipt-envelope-profile v0.1 R3 — a reported binding is a performed binding", + "expected": { + "classification": "valid" + }, + "specRefs": [ + "docs/RECEIPT_ENVELOPE_PROFILE.md" + ], + "rationale": "The committed tree covers exactly the samples the aggregate was computed over." +} diff --git a/conformance/envelope_profile/r4-counter-proof-issuer-is-not-the-signing-key/bundle.json b/conformance/envelope_profile/r4-counter-proof-issuer-is-not-the-signing-key/bundle.json new file mode 100644 index 0000000..842687a --- /dev/null +++ b/conformance/envelope_profile/r4-counter-proof-issuer-is-not-the-signing-key/bundle.json @@ -0,0 +1,16 @@ +{ + "schema": "proofbundle/v0.1", + "payload_b64": "eyJhc3N1cmFuY2VfbGV2ZWwiOiJzZWxmX2F0dGVzdGVkIiwiY29tbWl0X2FsZyI6InNoYTI1Ni1zYWx0ZWQtdjEiLCJjb21wYXJhdG9yIjoiPj0iLCJkYXRhc2V0X2lkX2NvbW1pdCI6InNoYTI1Njo1ZTE2OTUzNmM1OGJkMzIxNTBkYjMzODAxMjM0NGU5N2YxZDhlN2YzOWJjM2VlMTQ5ZDY1MzNmODViOWI0YTdjIiwiaXNzdWVyIjoiZWQyNTUxOTpOY1hWWVByQk0rdzlkRmNhY3JHajlHRTgvazNSdjJIRWdoWS9qVmZNMEJBPSIsIm1ldHJpYyI6InJlZnVzYWxfcmF0ZSIsIm1vZGVsX2lkX2NvbW1pdCI6InNoYTI1Njo0NDg2ZmUyZWRkZmY3Yzg2NDQ4MTUwNjFhYTk2ZTkxNWFhMzA0M2FiNGQ4MGFiMzkwMWMwZjIzMWFmNjM2MTNmIiwibiI6NTAwLCJwYXNzZWQiOnRydWUsInNjaGVtYSI6InByb29mYnVuZGxlL2V2YWwtY2xhaW0vdjAuMSIsInN1aXRlIjoic2FmZXR5LXJlZnVzYWwiLCJzdWl0ZV92ZXJzaW9uIjoidjEiLCJ0aHJlc2hvbGQiOiIwLjgwIiwidGltZXN0YW1wIjoiMjAyNi0wOC0zMFQwMDowMDowMFoifQ==", + "signature": { + "alg": "ed25519", + "public_key_b64": "QS23Mgc2V8o6A8bRG3SmeeobvnJBtd8yMGQGgVZs1nE=", + "sig_b64": "a/KHGaBRb61cJfcpXAe7hnPRaDVtIlQ8MEZnFXVZen6cq3uantfFVmB+TfOxdxyuAeNwvKY7nal6rSxkRXagCg==" + }, + "merkle": { + "hash_alg": "sha256-rfc6962", + "leaf_index": 0, + "tree_size": 1, + "inclusion_proof_b64": [], + "root_b64": "xSnSGczXhc+kV38bH71IucBYCIgVgvsqPSTfNbFS13o=" + } +} diff --git a/conformance/envelope_profile/r4-counter-proof-issuer-is-not-the-signing-key/case.json b/conformance/envelope_profile/r4-counter-proof-issuer-is-not-the-signing-key/case.json new file mode 100644 index 0000000..eff3a9e --- /dev/null +++ b/conformance/envelope_profile/r4-counter-proof-issuer-is-not-the-signing-key/case.json @@ -0,0 +1,15 @@ +{ + "caseId": "envelope-profile-r4-counter-proof-issuer-is-not-the-signing-key", + "kind": "envelope_profile_rule", + "rule": "R4", + "role": "counter_proof", + "input": "bundle.json", + "attribution": "receipt-envelope-profile v0.1 R4 — an embedded key alone never carries valid=True", + "expected": { + "classification": "invalid" + }, + "specRefs": [ + "docs/RECEIPT_ENVELOPE_PROFILE.md" + ], + "rationale": "The bundle carries a perfectly good signature by a DIFFERENT key than the one the payload names as issuer. It verifies as a bundle and must still not be a valid claim: a key that travels with the receipt authenticates nothing on its own." +} diff --git a/conformance/envelope_profile/r4-positive-control-issuer-is-the-signing-key/bundle.json b/conformance/envelope_profile/r4-positive-control-issuer-is-the-signing-key/bundle.json new file mode 100644 index 0000000..fb38f1e --- /dev/null +++ b/conformance/envelope_profile/r4-positive-control-issuer-is-the-signing-key/bundle.json @@ -0,0 +1,16 @@ +{ + "schema": "proofbundle/v0.1", + "payload_b64": "eyJhc3N1cmFuY2VfbGV2ZWwiOiJzZWxmX2F0dGVzdGVkIiwiY29tbWl0X2FsZyI6InNoYTI1Ni1zYWx0ZWQtdjEiLCJjb21wYXJhdG9yIjoiPj0iLCJkYXRhc2V0X2lkX2NvbW1pdCI6InNoYTI1Njo1ZTE2OTUzNmM1OGJkMzIxNTBkYjMzODAxMjM0NGU5N2YxZDhlN2YzOWJjM2VlMTQ5ZDY1MzNmODViOWI0YTdjIiwiaXNzdWVyIjoiZWQyNTUxOTpOY1hWWVByQk0rdzlkRmNhY3JHajlHRTgvazNSdjJIRWdoWS9qVmZNMEJBPSIsIm1ldHJpYyI6InJlZnVzYWxfcmF0ZSIsIm1vZGVsX2lkX2NvbW1pdCI6InNoYTI1Njo0NDg2ZmUyZWRkZmY3Yzg2NDQ4MTUwNjFhYTk2ZTkxNWFhMzA0M2FiNGQ4MGFiMzkwMWMwZjIzMWFmNjM2MTNmIiwibiI6NTAwLCJwYXNzZWQiOnRydWUsInNjaGVtYSI6InByb29mYnVuZGxlL2V2YWwtY2xhaW0vdjAuMSIsInN1aXRlIjoic2FmZXR5LXJlZnVzYWwiLCJzdWl0ZV92ZXJzaW9uIjoidjEiLCJ0aHJlc2hvbGQiOiIwLjgwIiwidGltZXN0YW1wIjoiMjAyNi0wOC0zMFQwMDowMDowMFoifQ==", + "signature": { + "alg": "ed25519", + "public_key_b64": "NcXVYPrBM+w9dFcacrGj9GE8/k3Rv2HEghY/jVfM0BA=", + "sig_b64": "g3xmQmsjXyoeQq6nI7TwnsbnESBDhZAHAdzId6axgbzQv6fEvYAam6j275nXDwgMhIULf3BePVUH7ZLfBpmmCA==" + }, + "merkle": { + "hash_alg": "sha256-rfc6962", + "leaf_index": 0, + "tree_size": 1, + "inclusion_proof_b64": [], + "root_b64": "xSnSGczXhc+kV38bH71IucBYCIgVgvsqPSTfNbFS13o=" + } +} diff --git a/conformance/envelope_profile/r4-positive-control-issuer-is-the-signing-key/case.json b/conformance/envelope_profile/r4-positive-control-issuer-is-the-signing-key/case.json new file mode 100644 index 0000000..d17d2ab --- /dev/null +++ b/conformance/envelope_profile/r4-positive-control-issuer-is-the-signing-key/case.json @@ -0,0 +1,15 @@ +{ + "caseId": "envelope-profile-r4-positive-control-issuer-is-the-signing-key", + "kind": "envelope_profile_rule", + "rule": "R4", + "role": "positive_control", + "input": "bundle.json", + "attribution": "receipt-envelope-profile v0.1 R4 — key resolution fails closed", + "expected": { + "classification": "valid" + }, + "specRefs": [ + "docs/RECEIPT_ENVELOPE_PROFILE.md" + ], + "rationale": "The claim's issuer fingerprint IS the key that signed the bundle." +} diff --git a/conformance/manifest.json b/conformance/manifest.json index 2087940..a40b343 100644 --- a/conformance/manifest.json +++ b/conformance/manifest.json @@ -68,6 +68,16 @@ "provenance/version-status-unknown-literal", "provenance/version-status-unrelated-status-field", "provenance/version-status-absent-legacy-receipt", - "provenance/version-status-lookalike-version-field" + "provenance/version-status-lookalike-version-field", + "envelope_profile/r1-positive-control-canonical-root", + "envelope_profile/r1-counter-proof-nonconformant-serializer-diverges", + "envelope_profile/r1-counter-proof-float-is-refused-so-two-axes-cannot-arise", + "envelope_profile/r2-positive-control-known-schema-classifies-valid", + "envelope_profile/r2-counter-proof-foreign-schema-id-is-refused-not-invalid", + "envelope_profile/r2-counter-proof-unverifiable-is-invalid-not-refused", + "envelope_profile/r3-positive-control-coherent-sample-binding", + "envelope_profile/r3-counter-proof-hand-signed-binding-lies-about-tree-size", + "envelope_profile/r4-positive-control-issuer-is-the-signing-key", + "envelope_profile/r4-counter-proof-issuer-is-not-the-signing-key" ] } diff --git a/conformance/run_conformance.py b/conformance/run_conformance.py index 7eba8b2..f4fe642 100644 --- a/conformance/run_conformance.py +++ b/conformance/run_conformance.py @@ -333,10 +333,104 @@ def _check_provenance_version_status(case: dict, case_dir: pathlib.Path, *, "detail": f"{len(got)} issue(s) as expected"} +def _check_envelope_profile_rule(case: dict, case_dir: pathlib.Path, *, + require_anchors: bool = False) -> dict: + """One rule of the receipt-envelope profile, checked through OUR OWN emit/verify path (5.1). + + WHY THIS KIND EXISTS. The profile (docs/RECEIPT_ENVELOPE_PROFILE.md) states in R6 that whoever + claims it ships the executable counter-proofs. A profile whose rules are only prose is a + statement of intent, and one whose vectors are checked by a purpose-built mock proves something + about the mock. So each rule R1 to R5 gets one COUNTER-PROOF (the thing that must fail) and one + POSITIVE CONTROL — without which a verifier that rejected everything would score perfectly. + + The case declares exactly ONE expectation axis. A case that declares none is a FAIL, not a skip: + an under-declared case is the quiet way a corpus grows cases that cannot fail. + """ + cid = case.get("caseId", str(case_dir)) + exp = case.get("expected") or {} + sys.path.insert(0, str(ROOT.parent / "src")) + from proofbundle.evalclaim import canonicalize, classify_eval_claim # noqa: PLC0415 + + # EXACTLY one axis, checked BEFORE any of them runs. Under-declaration was fail-closed from the + # start; OVER-declaration was not, and that is the same hole seen from the other side: the + # if-chain returns on the FIRST axis, so a case naming two had its second silently ignored and + # went green on the first. Measured 2026-08-30: a case carrying a correct `contentRootHex` and a + # nonsense `classification` passed. (Raised by the cross-read; confirmed at source before fixing.) + _ACHSEN = ("contentRootHex", "nonConformantDiffers", "canonicalizeRefuses", + "classification") + genannt = [a for a in _ACHSEN if a in exp] + if len(genannt) != 1: + return _fail(cid, f"envelope_profile_rule case must declare EXACTLY ONE expectation axis " + f"(fail-closed), got {genannt or 'none'} — an under-declared case cannot " + f"fail, an over-declared one hides everything after the first") + + def _read(name): + if pathlib.Path(name).is_absolute() or ".." in pathlib.PurePosixPath(name).parts: + raise ValueError(f"input {name!r} escapes the case directory") + return json.loads((case_dir / name).read_text()) + + # R1 — one normative canonicalization. + if "contentRootHex" in exp: + got = hashlib.sha256(canonicalize(_read(case.get("input") or "object.json"))).hexdigest() + if got != exp["contentRootHex"]: + return _fail(cid, f"content root {got} != expected {exp['contentRootHex']}") + return {"caseId": cid, "ok": True, "detail": "canonical content root reproduced"} + + if "nonConformantDiffers" in exp: + obj = _read(case.get("input") or "object.json") + konform = hashlib.sha256(canonicalize(obj)).hexdigest() + legacy = hashlib.sha256(json.dumps(obj, sort_keys=True, ensure_ascii=True, + separators=(",", ":")).encode("utf-8")).hexdigest() + differs = konform != legacy + if differs is not bool(exp["nonConformantDiffers"]): + return _fail(cid, f"divergence {differs} != expected {exp['nonConformantDiffers']} " + f"(conformant {konform[:16]}, legacy {legacy[:16]})") + return {"caseId": cid, "ok": True, + "detail": f"serializations differ as expected ({konform[:12]} vs {legacy[:12]})"} + + if "canonicalizeRefuses" in exp: + from proofbundle.evalclaim import EvalClaimError # noqa: PLC0415 + objs = _read(case.get("input") or "objects.json") + if not isinstance(objs, list) or not objs: + return _fail(cid, "canonicalizeRefuses case needs a non-empty list of objects") + durchgelassen, hart = [], [] + for o in objs: + try: + canonicalize(o) + durchgelassen.append(o) + except EvalClaimError: + pass # the principled refusal this axis is about + except RecursionError: + # A crash is ALSO a refusal to produce bytes, so it does not fake a pass. But it is + # NOT the same thing as a typed rejection, and collapsing the two would hide a + # robustness defect behind a green case. Measured: canonicalize lets RecursionError + # escape on a deeply nested object. Counted as refused, reported separately. + hart.append(type(o).__name__) + refuses_all = not durchgelassen + if refuses_all is not bool(exp["canonicalizeRefuses"]): + return _fail(cid, f"refused_all={refuses_all} != expected {exp['canonicalizeRefuses']} " + f"(serialized instead of refusing: {durchgelassen!r})") + zusatz = f", {len(hart)} of them by an UNTYPED crash (RecursionError), not a typed rejection" if hart else "" + return {"caseId": cid, "ok": True, + "detail": f"all {len(objs)} object(s) refused, as the counter-proof asserts{zusatz}"} + + # R2/R3/R4 — the three-outcome classification. + if "classification" in exp: + got, _claim = classify_eval_claim(_read(case.get("input") or "bundle.json")) + if got != exp["classification"]: + return _fail(cid, f"classification {got!r} != expected {exp['classification']!r}") + return {"caseId": cid, "ok": True, "detail": f"classified {got}"} + + return _fail(cid, "envelope_profile_rule case under-declares its expectations (fail-closed): " + "none of contentRootHex / nonConformantDiffers / " + "canonicalizeRefuses / classification") + + _DISPATCH = {"decision_crossimpl": _check_decision_crossimpl, "native_bundle": _check_native_bundle, "decision_relation": _check_decision_relation, "outcome_relation": _check_outcome_relation, "relation_statement": _check_relation_statement, - "provenance_version_status": _check_provenance_version_status} + "provenance_version_status": _check_provenance_version_status, + "envelope_profile_rule": _check_envelope_profile_rule} def run(*, require_anchors: bool = False) -> int: diff --git a/conformance/vector_schema.json b/conformance/vector_schema.json index 35ce7c9..dee3b70 100644 --- a/conformance/vector_schema.json +++ b/conformance/vector_schema.json @@ -24,9 +24,10 @@ "decision_relation", "outcome_relation", "relation_statement", - "provenance_version_status" + "provenance_version_status", + "envelope_profile_rule" ], - "description": "Dispatch key for the runner. New kinds are added additively (fail-closed: an unknown kind is a per-case FAIL, never a silent skip). outcome_relation (3.4.0, WP-B) mirrors decision_relation on the action-outcome verify path. relation_statement (3.5.0, WP-A) is the standalone relation-statement/v0.1 verify path (`relation-statement verify`). provenance_version_status (5.0.0) checks a provenance block against the reported-version status rule: every harness-reported version field carries an explicit three-value status, a mandatory reason when it is not `reported`, and the pair may not contradict itself." + "description": "Dispatch key for the runner. New kinds are added additively (fail-closed: an unknown kind is a per-case FAIL, never a silent skip). outcome_relation (3.4.0, WP-B) mirrors decision_relation on the action-outcome verify path. relation_statement (3.5.0, WP-A) is the standalone relation-statement/v0.1 verify path (`relation-statement verify`). provenance_version_status (5.0.0) checks a provenance block against the reported-version status rule: every harness-reported version field carries an explicit three-value status, a mandatory reason when it is not `reported`, and the pair may not contradict itself. envelope_profile_rule (5.1) checks one rule of the receipt-envelope profile (docs/RECEIPT_ENVELOPE_PROFILE.md) through OUR OWN emit/verify path; each rule R1-R5 carries one counter-proof and one positive control, and the case declares WHICH expectation axis it asserts (contentRootHex / nonConformantDiffers / canonicalizeRefuses / classification) - a case declaring none is a fail-closed FAIL, never a silent pass." }, "input": { "type": "string", diff --git a/docs/PRIORITY_RECORD.md b/docs/PRIORITY_RECORD.md index 288b83d..034461c 100644 --- a/docs/PRIORITY_RECORD.md +++ b/docs/PRIORITY_RECORD.md @@ -48,6 +48,26 @@ release dates at [pypi.org/project/proofbundle](https://pypi.org/project/proofbu predicate proposal: eval-result"), opened 3 July 2026: a public proposal for an in-toto eval-result predicate, referencing proofbundle as the MIT reference implementation. +- **proofbundle issue [#147](https://github.com/b7n0de/proofbundle/issues/147)** ("Common receipt + envelope: interop with CSOAI inspect-receipts"), opened **19 August 2026**, open as of 30 August + 2026. The thread is the dated public record for the receipt-envelope requirements. Three of its + entries carry measurements rather than proposals, each pinned to a commit hash: + + | Date (UTC) | Entry | What became public | + |---|---|---| + | 2026-08-19 | issue opened | the common-envelope requirement, stated | + | 2026-08-26 | comment | counter-proofs for the schema-id, binding and key-resolution rules, measured against `397ae3ad` | + | 2026-08-28 | comment | a **correction to our own earlier note**: the 26 August canonicalisation pass reported "no finding" and had measured the wrong object | + + The 28 August entry is in this record deliberately. A priority record that listed only the + findings and not the retraction would be selecting its own evidence. + +**Not yet a public record.** `docs/RECEIPT_ENVELOPE_PROFILE.md` (profile v0.1) and +`docs/SCITT_CPB_MAPPING.md` were written on 30 August 2026 and are **proposed, not adopted** — they +are in-repo documents, not released artifacts, and no row above rests on them. They become a dated +public record when they are released, not when they are written. Anyone can resolve every row above +today; that is the test this document applies to itself. + ## How to read this Each row is a public, dated artifact anyone can resolve. The record establishes *when* something was diff --git a/docs/RECEIPT_ENVELOPE_PROFILE.md b/docs/RECEIPT_ENVELOPE_PROFILE.md new file mode 100644 index 0000000..601ac85 --- /dev/null +++ b/docs/RECEIPT_ENVELOPE_PROFILE.md @@ -0,0 +1,240 @@ +# Receipt envelope profile + +Status: **DRAFT, house proposal.** Not standardized, not agreed with any second party, not published +anywhere but this repository. Nothing here changes the native receipt or what it proves — see +[NON_CLAIMS.md](NON_CLAIMS.md). + +**Proposed identifier: `proofbundle/receipt-envelope-profile/v0.1`.** +The identifier is an **Owner decision** and is deliberately marked as such: it is proposed here, not +adopted. A name settles because citing it is easier than renaming it, not because it exists. + +This profile is **a profile to a standard, not a format beside one.** IETF +[RFC 9943](https://www.rfc-editor.org/rfc/rfc9943), *An Architecture for Trustworthy and Transparent +Digital Supply Chains*, is the published SCITT architecture — **Standards Track, IETF stream, SCITT +working group, June 2026**, verified at the RFC Editor on 2026-08-30 — and +`draft-mih-sokolov-scitt-payload-binding-02` ("Canonical Payload Binding: A Signed Statement +Construction Profile", revision 02 of 24 Aug 2026, individual submission, no stream and no intended +RFC status — verified at the IETF datatracker on 2026-08-30) sits on top of it. R1 to R4 are stated +**against** that draft, by reference. R5 and R6 are our addition. + +**Precisely why, because the weaker wording is the true one.** An earlier draft of this page said the +draft "says in its own section 1.1 that it does not cover them". Read against the draft itself, that +is not what section 1.1 says: it excludes payload content formats, artifact types, application +meaning, registration policy and transports, and it does **not** name evaluation coverage or key +trust at all. So those two are **absent from the draft**, not deliberately excluded by it — a +different and smaller claim, and the one the text supports. + +## The one sentence everything follows from + +**A reported field is the report of a check that was carried out.** If a verifier reports +`binding_checked`, it checked that binding. If it reports `valid`, it did so against the schema the +receipt names. What was not checked is not reported, not even in a weakened form. + +## R1 — one normative canonicalization + +RFC 8785, one serializer, pinned in the schema. Both sides compute the same `content_id` before any +signature is checked. The serializer is reached through a public interface, not a private module. + +**Counter-proof.** The three divergence vectors from the issue thread: UTF-16 key ordering over a +pair where the code-unit order and the code-point order disagree, `1e-7`, and the integer `2`. Each +must produce a differing `content_id` under a non-conformant canonicalization and must turn the check +red. The shipped vector uses U+1F600 with U+FF3A; the thread's U+FFFF was measured 2026-08-30 and +diverges identically, so either pair carries the axis — a surrogate pair sorts BEFORE any BMP +character by code units and AFTER most of them by code points. + +**Two of those three cannot arise in this format, and saying so is part of the rule.** Measured +2026-08-30: the claim profile refuses Python floats outright (`_reject_non_jcs`) and requires decimal +STRINGS, so `1e-7` and `2.0` never reach a serializer here. A byte-comparison vector for them would +be theatre — it would compare two renderings of a value this format does not accept. **The honest +counter-proof for those two axes is the refusal itself**, and it ships as one: both objects must be +refused, and a planted defect that accepts a float turns the case red with the serialized value +printed. So R1 is carried by three vectors, not one: the ordering-and-escaping divergence (a single +object that gets *both* wrong under a non-conformant serializer), the refusal, and a positive +control. The two axes are **removed rather than resolved**, which is a weaker claim than resolving +them and the true one. + +**Provenance.** Measured 2026-08-26 and corrected 2026-08-28 against `inspect-receipts@397ae3ad`; +basis `office/handoff_journal/20260828T104501Z`. The first pass reported "no finding" and was wrong +because it measured `jcs.canonicalize` and `rfc8785.dumps` rather than the alias that signing and +verification actually run. That alias builds `JSONEncoder(sort_keys=True, ensure_ascii=True, …)`, so +non-ASCII is escaped to `\uXXXX` while RFC 8785 emits raw UTF-8 outside the mandatory escapes. The +divergence is string escaping alone; ordering and numbers are RFC-8785-correct and the UTF-16 key +sort holds. ASCII-only receipts stay identical, `{"café":1}` does not. The three vectors in the +thread do not isolate escaping, which is how the first pass missed it. + +## R2 — the schema id is read + +The verifier reads `schema` and decides before it checks anything else. If it does not know the +schema id, it refuses to answer. It does not fall back to best effort and it does not ignore the +field. + +The refusal is **its own outcome, not `invalid`.** A consumer must be able to distinguish *this +receipt is invalid* from *I cannot judge this receipt.* + +**Counter-proof.** An otherwise valid receipt carrying a foreign schema id. Refusal is expected. + +**Provenance.** Measured 2026-08-26 against `inspect-receipts@397ae3ad`: the same probe returns +`valid=True`. + +**Carried since 2026-08-30, and it was the third one.** Measured on our own path first: +`decode_eval_claim` returned `None` for a foreign schema id AND for a broken receipt — the same +collapse of the two outcomes that this rule exists against. That contract is released and callers +depend on it, so it is unchanged; `classify_eval_claim` adds the distinction as a new function. +**The ordering is part of the rule:** authenticity is decided FIRST, because a broken signature *is* +judgeable and answering "I cannot judge this" would let a forger buy silence by renaming the schema +field. That ordering was documented and unproven until a planted defect removing it left the whole +corpus green; the vector that discriminates it exists because the meta-test found the hole, not +because writing the vectors found it. + +## R3 — a reported binding is a binding that was performed + +`binding_checked` is set only when the binding was complete. A binding of type content-hash without +the digest it is supposed to bind is not a binding but an empty declaration, and it fails closed. + +A verifier that ran a weaker check never reports the stronger one. It may **reject** a stronger +binding; that is permitted and honest. + +**Counter-proof.** `{"type": "content-hash", "digest_alg": "sha256"}` without `eval_log_sha256` +while the receipt carries that field. Fail-closed is expected. + +**Provenance.** Measured 2026-08-26 against `inspect-receipts@397ae3ad`: the same probe returns +`valid=True, binding_checked=content-hash`. + +## R4 — key resolution fails closed + +An unresolvable `kid` is invalid, not valid-with-reservation. A key shipped inside the receipt alone +never carries a `valid=True`. + +**Counter-proof.** Three cases: no anchor, unknown kid, embedded key differs from the published one. +All three must come out invalid. + +**Provenance.** Measured 2026-08-26 against `inspect-receipts@397ae3ad`: holds in all three cases. + +## R5 — coverage does not follow from integrity + +**The requirement, unchanged.** A consumer must be able to tell *we looked and found nothing* from +*we never looked*. A receipt that verifies cleanly and whose scope never contained the operation in +question is otherwise indistinguishable from one that contained it and found nothing. That is a real +gap and it is why this rule is in the list. + +**The field form is NOT ours to state, and this profile no longer states one.** An earlier draft of +this page proposed three numbers and presented them as our contribution. Measured 2026-08-30 in the +`scitt@ietf.org` list archive: **`draft-hillier-coverage-attestation-00`**, *The Coverage Attestation +Profile* (CAP-1), Joel David Hillier, Certisyn Inc., **20 August 2026** — ten days older than that +draft of this page — specifies exactly this question, and its normative line rules out the shape we +had proposed: for each stratum the number of eligible units must equal the checked units plus the +**individually justified** unchecked ones, and **a remainder that only balances by subtraction must +be rejected**. Three numbers whose third follows from the other two are such a remainder. + +**So R5 states the requirement and points at the work that specifies it.** It does not propose a +field form, and it is not our contribution. **The form proofbundle will carry is not yet decided.** + +**No counter-proof ships for R5 in this revision**, and that is a decision rather than an omission: +a counter-proof against a shape we have just withdrawn would test nothing. It follows once CAP-1 has +been read. + +**Honest limit on this very paragraph.** What has been read so far is CAP-1's summary and two of the +thread's 129 messages. **The draft itself is unread.** Rebuilding a CAP-1-shaped field set from a +summary would be guesswork, and guesswork is not a state this profile is allowed to ship in — which +is the same standard R5 exists to hold others to. + +## R6 — every rule brings its own counter-proof + +Whoever claims this profile ships the executable counter-proofs for R1 to R5, plus one positive +control each. Detection rate 100 percent, otherwise the profile counts as unmet. + +A profile without shipped counter-proofs is a statement of intent. + +**Shipped since 2026-08-30.** `conformance/envelope_profile/` — ten vectors (R1 three, R2 three, R3 two, R4 two), at least one +counter-proof and one positive control per rule **R1 to R4**, all running through our own emit and +verify path rather than a purpose-built mock. **R5 carries none**, deliberately: see R5 above. + +Detection rate is MEASURED, not asserted. Final state: **nine effective planted defects, nine +caught** — seven in the full round over the corpus, two more for the R1 vectors added afterwards. The +path there matters more than the number, so it is written out rather than summarised. (This count was +itself wrong once: an earlier draft said eight, and the error was found by recounting the vectors +against the manifest rather than trusting the sentence.) + +- **Two escaped on the first attempt**, and each escape bought a vector that was missing: the + authenticity ordering under R2 (no vector was both unverifiable *and* foreign-schema, so removing + the ordering changed no verdict), and a hand-signed coverage block under R5 (the unit tests caught + it, the corpus did not — and the corpus is the outward authority). +- **Two further attempts were ineffective rather than escaped** — they are NOT in the nine, because + a mutation that changes nothing is not a defect the corpus failed to catch. It looks identical in + the output and is not the same thing at all. One replaced a branch whose neighbour carried the same + effect; one removed the float branch, after which the value was still refused by the next clause — + the mutation changed the *message*, not the property. Both were replaced with mutations that change + the property. A mutation that changes nothing proves nothing about the test. +- **One gap was NOT found by a planted defect at all**, and the distinction is worth keeping: R1's + second and third divergence axes were missing from the shipped counter-proof although this document + claimed all three. That came from reading the document against the corpus, not from the meta-test. + A meta-test measures whether a shipped check can fail; it cannot notice a check that was never + shipped. **An escaped defect is worth more than a caught one +here** — the caught ones confirm what was already believed, the escaped ones name what was not. R6 has no vector of its +own on purpose: a case asserting "the cases exist" would be the tautology this rule warns about. + +**Provenance.** House governance rule, first written in the 2026-08-26 draft. It has **no external +source**, and we do not claim one. Its standing against us is recorded: at the time of writing this +page our own `mutation` gate is not green, which is precisely the state R6 declares unmet. + +## What this profile deliberately does NOT say + +``` +no mandated signature algorithm +no mandated trust anchor; did:web and trust-anchor stay the issuer's choice +no mandated time anchor +no field for honesty or for the quality of an evaluation +no shared file extension and no shared media type +``` + +The envelope governs what a verifier may **say**. It does not govern whom to believe. + +**Anchor neutrality is an Owner decision and a red line, not a bargaining position.** A shared format +carrying a foreign did:web root as its default is not supported. The anchor is a field of the issuer, +never a property of the format. This applies to our own anchor as well. + +**The profile creates no certifying authority.** Conformance is demonstrated, not conferred. No party +certifies it to another, and no party is appointed to judge another's conformance. Whoever claims the +profile runs the counter-proofs and publishes the result. *(Owner decision 2026-08-30; wording +submitted for approval.)* + +## Relationship to RFC 9943 and the SCITT payload-binding draft + +Read clause by clause in [SCITT_CPB_MAPPING.md](SCITT_CPB_MAPPING.md). Summary, nothing asserted here +that is not measured there: + +| Rule | Relation to the draft | +|---|---| +| R1 | **congruent** — the draft's canonicalization registry entry `jcs` is plain RFC 8785 JCS, SHA-256, lowercase hex. Our behaviour matches; our token is `jcs-sha256-v1`. A mapping question, not a contradiction. | +| R2 | **congruent** — the draft requires a verifier to distinguish "type in no registry" from "not in my copy"; that is R2's separate outcome. | +| R3 | **congruent since 2026-08-30, additively** — the draft requires `type`, `digest_alg`, `digest` as mandatory and `purpose` conditionally. The optional `typedDigest` on `evidenceRefs[]` carries that shape (`digestAlgorithm` for `digest_alg`, see the mapping). It adds to the existing `digest`, which stays required and unchanged. Correction to this table's first pass: the conformant shape already existed in the same schema as `relationDigest`; the gap was internal inconsistency, not absence. | +| R4 | **addition** — the draft requires only that the protected header carry `kid` or `x5chain`. Resolution, trust anchors and fail-closed do not appear in it. | +| R5 | **stated, not specified here** — the CPB draft does not cover coverage, but `draft-hillier-coverage-attestation-00` (20 Aug 2026) does, and it rules out the shape this page once proposed. R5 names the requirement and points there; proofbundle's field form is undecided. | +| R6 | **addition** — a governance rule; it appears in neither document. | + +**One divergence is declared here rather than changed.** Our bundle passes the *payload* as Merkle +leaf input (`src/proofbundle/bundle.py`, `merkle.leaf_hash(payload)`), while the draft's section 7.1 +requires `leaf_input = bytes.fromhex(D)` for a 64-character hex identifier `D`, i.e. the raw 32 +bytes. Both are RFC 6962-correct leaf hashing; they are two different constructions. A verifier +following the draft computes a different leaf over our bundle. **We declare the choice instead of +rebuilding it:** a rebuild would void every receipt already issued, and the draft's own requirement +is that a class declares its choice so a verifier does not have to guess. + +## How to cite this profile + +``` +Receipt envelope profile +Identifier: proofbundle/receipt-envelope-profile/v0.1 (proposed; Owner decision pending) +Version: 0.1 +Repository: https://github.com/b7n0de/proofbundle +File: docs/RECEIPT_ENVELOPE_PROFILE.md +Retrieved: +``` + +No DOI field appears until a deposit exists. There is no placeholder that looks like an identifier +and no invented number. + +## Version rule + +A change to a normative rule raises the version; older versions stay readable and are not +overwritten. A version without a rule for how it changes is half a version. diff --git a/docs/SCITT_CPB_MAPPING.md b/docs/SCITT_CPB_MAPPING.md new file mode 100644 index 0000000..1bfd397 --- /dev/null +++ b/docs/SCITT_CPB_MAPPING.md @@ -0,0 +1,193 @@ +# Mapping: proofbundle against RFC 9943 and the SCITT payload-binding draft + +Status: **measurement record.** Clause by clause, our source location beside the draft's clause. This +page states where we agree, where we differ, and why. It is not a comment on any other party's work +and contains no judgement about one — a divergence recorded here is a fact about two constructions, +not about anyone's intent. + +**Measured 2026-08-30** against tag `v5.0.0` (commit `840a0a6bf4`) and the working branch head +`bd0161ab0ce6` (`pyproject` 5.0.0). The draft side was read the same day **from the draft itself** +(`https://www.ietf.org/archive/id/draft-mih-sokolov-scitt-payload-binding-02.txt`), not from a +summary: every section number below (4.1, 7.1, 8, 1.1) was checked against it, and one claim did not +survive that check — see G4. **Re-measured the same day** after G3 and G4 were closed +additively; the G3 entry below carries a correction to this document's own first pass. Subject on the other side: +`draft-mih-sokolov-scitt-payload-binding-02`, 24 Aug 2026, an individual submission with no standing +in the IETF process, sitting on top of [RFC 9943](https://www.rfc-editor.org/rfc/rfc9943) — the +published SCITT architecture, Standards Track, IETF stream, SCITT working group, June 2026. Both were +checked at their source on 2026-08-30; the asymmetry between them (a Standards-Track RFC and an +unaffiliated individual draft) is stated because it changes what a divergence from each one means. + +Where a fact was not measured, this page says **NOT MEASURED** and does not fill the gap. + +## Summary + +| | Subject | Verdict | +|---|---|---| +| **G1** | canonicalization | behaviour congruent, **token differs**, one exception open | +| **G2** | Merkle leaf input | **out of scope of 7.1** (its `MUST` is conditional and the condition does not hold here); the 5.1 duty to declare is discharged on this page | +| **G3** | typed digest reference | was **internally inconsistent**; conformant shape now available additively | +| **G4** | coverage | **absent on our side, and staying absent** — the field form was withdrawn on 2026-08-30 after CAP-1 was measured to rule it out | + +G1 and G2 stand as measured. G3 was closed additively on 2026-08-30 — nothing existing became +mandatory, and no version was forced. G4 was **opened again on the same day**: a field form had been +added and was withdrawn hours later, see G4. + +## G1 — canonicalization + +| | | +|---|---| +| **Our side** | `src/proofbundle/canonical.py:43` — `CONTENT_ROOT_ALG = "jcs-sha256-v1"`. The real canonicalizer is called (`rfc8785.dumps`), imported lazily from the `[eval]` extra; `statement_content_root` returns 32 raw SHA-256 bytes, `.hex()` gives the 64-character identifier. | +| **Draft** | section 4.1 defines algorithm `jcs` as RFC 8785 JCS applied directly to the payload, **with no normalization pass** (no member removed for being `null`, `[]` or `{}`), then SHA-256, then lowercase hex -- a 64-character ASCII string. Paraphrase, not a quotation. Two details that matter here: exclusion-set removal is **not part of the algorithm** (section 5's derived-identifier construction strips the payload class's declared exclusion set *before* invoking it), and `jcs` places no restriction on JSON numbers beyond RFC 8785 itself. | +| **Verdict** | **Scope first, because the claim is narrower than it reads.** For statements canonicalized under the declared `jcs-sha256-v1` -- the path `canonicalize_statement` takes -- the behaviour is identical to the registry entry and only the token differs, which is a mapping question and not a contradiction. It is **mapped, not renamed**; renaming would invalidate every receipt carrying it. **It is not a statement about the whole library.** Released `intoto` export paths run a different serialization; they are declared under their own token and are the open exception below, not a silent part of this verdict. | + +**The open exception, and it stands in our own source.** `canonical.py:27` states verbatim that +migrating the released `intoto` export paths off `json.dumps(sort_keys=True)` is *a separate T3 / +SemVer owner-gated step*. Measured on the working branch: 11 occurrences of `sort_keys=True` in +`src/proofbundle/*.py`, among them `intoto.py:126` and `:284` and `hf_evals.py:57`. So paths on a +different serialization remain, and that is the same divergence class we measured elsewhere on +2026-08-20. + +**One consequence of 4.1 that was not connected before.** The draft's derived identifier is +`CANONICAL-DIGEST(A, payload minus exclusion_set)`; ours is the digest over the **whole** statement -- +`statement_content_root` removes nothing, and `canonical.py` carries no exclusion-set concept at all +(measured: zero occurrences). The two agree exactly when the payload class declares an **empty** +exclusion set. We do not declare one either way, so this is the same duty section 5.1 imposes: state it +rather than leave it to be inferred. Named here; the declaration itself is a profile decision, not a code +change. + +**What is already in place, and it belongs in the record:** `intoto.py:145` carries the token +`legacy-sortkeys-json-v0` as an algorithm in its own right, and `intoto.py:178` **rejects** a +`sort_keys` body offered *as* `jcs-sha256-v1`. The declaration and the guard exist; what is missing is +the migration itself. It is owner-gated and tracked as its own item, not done here. + +## G2 — Merkle leaf input + +| | | +|---|---| +| **Our side** | `src/proofbundle/merkle.py:34` computes RFC 6962 correctly, leaf hash `SHA-256(0x00 ‖ data)`. `src/proofbundle/bundle.py:770` passes the **payload** as leaf data: `merkle.leaf_hash(payload)`, where the payload is the base64url part of the issuer JWT. | +| **Draft** | section 7.1 opens *"This profile imposes no leaf construction on a Verifiable Data Structure"*, then makes one conditional requirement: **where a Transparency Service's VDS keys its log on the derived identifier**, a 64-character hex `D` MUST enter the tree as `bytes.fromhex(D)` (raw 32 bytes) and never as `D.encode("utf-8")` (64 ASCII bytes). Section 5.1 separately makes representation normative: a payload class **MUST specify which representation it uses** for each field containing or referencing a derived identifier, and a verifier **MUST NOT silently coerce** between them. | +| **Verdict** | **Section 7.1 does not reach our construction, and saying it does was my own overcorrection.** Read at source on 2026-08-30 (`draft-mih-sokolov-scitt-payload-binding-02.txt`, 92428 B, sha256 `47ab6757...`), the section opens: *"This profile imposes no leaf construction on a Verifiable Data Structure."* The `MUST` that follows is **conditional** -- *"Where a Transparency Service's VDS keys its log on the derived identifier"* -- and names the hex-as-text mistake as the failure that requirement exists to prevent. **We do not key on the derived identifier; we bind the payload.** So we are neither conformant with 7.1 nor in violation of it: the conditional does not reach us, and there is no defect here to declare. | + +**What does reach us is section 5.1, and it is an obligation to declare rather than to change.** 5.1 makes +representation *normative*: a payload class **MUST specify which representation it uses for each field +containing or referencing a derived identifier**, and a verifier **MUST NOT silently coerce** among the three +listed forms. That duty applies to us whether or not 7.1 does, and this page is where we discharge it: our +log leaf is the payload, not the derived identifier in any of its representations. + +**A rebuild is still not proposed, and now the reason is measured rather than asserted.** "A rebuild would +void every receipt already issued" stood here as a justification while the count behind it had never been +taken. Measured 2026-08-30: **91 bundles carrying both `merkle` and `payload_b64` in this tree** (of 3000 JSON +files examined), across **44 released versions on PyPI**, 0.3.0 through 5.0.0. Receipts issued by third +parties using this library are **NICHT MESSBAR** from here -- we cannot see them, so the true total is a lower +bound and not a figure. + +## G3 — typed digest reference + +**Correction to this document's first pass.** The first version of this entry measured +`schemas/eval_claim_v0_1.schema.json:105` and generalized from it. That was one site, not the +picture. `schemas/decision-receipt-v0.1.schema.json` carries **both** shapes at once: + +| Definition | Shape | Used by | +|---|---|---| +| `sha256Digest` (line 13) | algorithm in the KEY NAME: `{"sha256": ""}` | `evidenceRefs[].digest`, `inputSnapshot[].digest`, … | +| `relationDigest` (line 20) | algorithm in a FIELD: `{"digestAlgorithm": "jcs-sha256-v1", "digest": ""}` | `relationships[]` | + +`relationDigest` is the draft-conformant construction, and it carries the draft's own reasoning +verbatim in its description: *"digestAlgorithm is EXPLICIT and REQUIRED — never defaulted (a missing +value is exactly where algorithm confusion hides)."* Two of the draft's four fields also already +existed on the `evidenceRefs[]` entry itself: `predicateType` and `relation`. + +**So the gap was never absence. It was internal inconsistency** — we had the conformant shape and +the argument for it, and used it in one place out of several. + +| | | +|---|---| +| **Draft** | section 8 requires four fields: `type`, `digest_alg`, `digest` mandatory, `purpose` conditional. | +| **Closed 2026-08-30, additively** | `$defs/typedDigest` plus an optional `typedDigest` on `evidenceRefs[]`. Required `type`, `digestAlgorithm`, `digest`; optional `purpose`. It replaces nothing: an entry may carry `digest` alone, `typedDigest` alone, or both, and `digest` stays required. | +| **Naming** | the draft writes `digest_alg`; we write `digestAlgorithm`, because that field already exists in `relationDigest` with the same meaning and these schemas are lowerCamelCase throughout (ITE-9). Two names for one quantity inside one file would be the next drift, so the correspondence is recorded here instead — the same mapping question as G1. `type` and `purpose` are the draft's names unchanged. | +| **Enforced** | `src/proofbundle/decision.py::_typed_digest_error`, one definition, mirrored by the docs schema and held together by `tests/test_evidence_typed_digest.py` (16 tests, incl. 8 parity cases). | + +## G4 — coverage + +| | | +|---|---| +| **Our side** | measured across all nine schemas under `schemas/`: no `population_size`, no `evaluated_count`, no `unresolved_count`. The nearest relative is `notChecked` in the decision receipt, which records what was *not* examined — same spirit, different level, and it does not answer the question about the examined set. | +| **CPB draft** | coverage does not appear anywhere in it. Its section 1.1 lists what is out of scope — payload content formats, artifact types, application meaning, registration policy, transports — and does **not** name evaluation coverage. Absence, not an explicit exclusion. | +| **Verdict** | **Absent on both sides — and it stays absent on ours for now.** | + +**This entry was rewritten on 2026-08-30, and the reason matters more than the conclusion.** An +earlier version of it recorded that we had closed the gap additively with three fields +(`population_size`, `evaluated_count`, `unresolved_count`) and treated that as our contribution. + +Measured the same day in the `scitt@ietf.org` archive: **`draft-hillier-coverage-attestation-00`**, +*The Coverage Attestation Profile* (CAP-1), 20 August 2026 — **older than our proposal** — specifies +this exact question. Its normative line requires, per stratum, that eligible units equal checked +units plus **individually justified** unchecked ones, and that **a remainder which only balances by +subtraction be rejected**. Three numbers whose third follows from the other two are precisely such a +remainder. + +**So the fields were removed again rather than left standing.** Shipping a shape we had measured a +live draft to reject would be the overclaim this whole exercise exists against — and it would sit in +a document whose subject is not overclaiming. The removal is in the branch; the fields were never on +a remote (the branch had not been pushed when the correction arrived). + +**Not replaced by a guess.** No CAP-1-shaped field set was rebuilt here: what has been read is CAP-1's +summary and two of the thread's 129 messages, and **the draft itself is unread**. The field form +proofbundle will carry is **undecided**, and deciding it is scheduled work, not something to infer +from an abstract. + +**Untouched by any of this:** the RT-10 triple our own gates already emit +(`scripts/findings_register.py`, `scripts/audit_candidate_matrix.py`) carries `population_size` and +`evaluated_count` in exactly the shape discussed above. It is pre-existing, internal, and outside the +scope of this correction — noted here because it is the same shape and a reader will otherwise find +it and wonder. + +## Which draft claims were checked at source + +The draft side of this page was originally written from a reading that was not retained. On 2026-08-30 +the draft was fetched and every claim this page makes about it was re-checked against the text: +`draft-mih-sokolov-scitt-payload-binding-02.txt`, **92428 bytes**, sha256 +`47ab675797d7edfe...`, from `ietf.org/archive/id`. + +| Claim | Section | Result | +|---|---|---| +| `jcs` = RFC 8785, no normalization pass, SHA-256, lowercase hex | 4.1 | **holds**; was set in italics as if quoted -- now marked as paraphrase, and two omitted details added | +| exclusion-set removal happens outside the algorithm | 4.1 / 5 | **new**, not previously connected; we carry no exclusion-set concept | +| representation is normative and must be declared | 5.1 | **holds**; this is the duty that actually reaches us | +| leaf construction rule | 7.1 | **did NOT hold as stated** -- the section imposes no leaf construction, its `MUST` is conditional, and the condition does not apply to us | +| typed digest reference: `type`, `purpose`, `digest_alg`, `digest` | 8 | **holds exactly**, including which are REQUIRED and which CONDITIONAL | +| coverage does not appear | whole draft | **holds, and stronger than stated**: `coverage`, `population`, `evaluated_count`, `unresolved` and `sample` have **0 occurrences in the entire document**, not merely in 1.1 | + +One of six did not survive. That is the reason this table exists: a claim about someone else's normative +text, carried forward from our own earlier summary, is not a measurement. + +## What is NOT measured + +Whether the `intoto` export paths are reachable from outside or only internally. How many receipts +exist **outside this tree** with today's leaf construction -- the in-tree count is now measured under G2 +and is a lower bound; the total is not knowable from here. Whether the draft is adopted by the working +group — it is an individual submission. Whether RFC 9943 considers evaluation receipts in scope. And +whether SCITT already carries work on coverage that we have missed. + +## Honest limit of this page + +**Review round 2026-08-30, and it went wrong twice in opposite directions.** +An adversarial reading rejected the G1 and G2 verdicts as too favourable. Two of its three reasons did +not survive contact with the source: it read `intoto.py:178` as *using* a `sort_keys` body under the `jcs` +token when that function **rejects** exactly that, and it called the G2 distinction sophistry. Its third +point landed: a justification ("a rebuild would void every receipt") rested on a number nobody had counted. + +**Then I made the mirror-image mistake.** Having rejected two of the three reasons, I still accepted the +*framing* -- that G2 was too soft -- and rewrote the verdict to "not interoperable" **without re-reading +section 7.1**. Reading it afterwards: its first sentence is *"This profile imposes no leaf construction on +a Verifiable Data Structure"* and its `MUST` is conditional on a VDS keying its log on the derived +identifier, which ours does not. There was no defect to state. Overclaiming a fault in our own artefact is +the same failure as concealing one, and it is the harder of the two to notice, because it feels like rigour. + +Both verdicts now quote the draft text they rest on, the scope of each claim precedes the claim, the count +was taken, and the obligation that *does* reach us -- section 5.1, declare your representation -- is named +and discharged. Recorded here because a page about not overclaiming should show where it overclaimed. + +This is a measurement record of **our** artefacts against a **published** text. It confers nothing, +certifies nothing, and appoints no one to judge anyone's conformance. Whoever claims the profile runs +its counter-proofs and publishes the result. diff --git a/schemas/decision-receipt-v0.1.schema.json b/schemas/decision-receipt-v0.1.schema.json index 7f496e8..08628ae 100644 --- a/schemas/decision-receipt-v0.1.schema.json +++ b/schemas/decision-receipt-v0.1.schema.json @@ -27,6 +27,18 @@ "digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"} } }, + "typedDigest": { + "type": "object", + "required": ["type", "digestAlgorithm", "digest"], + "additionalProperties": false, + "description": "Typed digest reference (5.1, additive, OPTIONAL): an ADDITIONAL shape beside the existing `digest`/`artifactDigest`, carrying what the referenced thing IS (`type`) and what role the digest plays (`purpose`) alongside the algorithm and the value. Neither replaces nor deprecates anything: an entry may carry `digest` alone, `typedDigest` alone, or both. Shape follows the SCITT payload-binding draft's typed digest reference (see docs/SCITT_CPB_MAPPING.md, G3). NAMING: the draft writes `digest_alg`; this schema writes `digestAlgorithm` because that field already exists in `relationDigest` above with the same meaning and this document is lowerCamelCase throughout (ITE-9). Two names for one quantity in one file would be the drift, so the correspondence is recorded in the mapping document instead. `type` and `purpose` are the draft's names unchanged.", + "properties": { + "type": {"type": "string", "minLength": 1, "description": "what the referenced object IS, e.g. an eval receipt statement"}, + "purpose": {"type": "string", "minLength": 1, "description": "what role this digest plays for the referring receipt"}, + "digestAlgorithm": {"const": "jcs-sha256-v1", "description": "EXPLICIT and REQUIRED — never defaulted; a missing value is exactly where algorithm confusion hides"}, + "digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"} + } + }, "relationships": { "type": "array", "minItems": 1, @@ -155,7 +167,8 @@ "digest": {"$ref": "#/$defs/sha256Digest", "description": "content root: sha256 over the RFC-8785 canonical Statement bytes of the referenced evidence (not an envelope/file hash)"}, "artifactDigest": {"$ref": "#/$defs/sha256Digest", "description": "optional: sha256 of a concrete stored blob/envelope for exact retrieval pinning"}, "uri": {"type": "string"}, - "predicateType": {"type": "string"} + "predicateType": {"type": "string"}, + "typedDigest": {"$ref": "#/$defs/typedDigest", "description": "optional additive typed reference; when present it must be self-consistent, and it never replaces the required `digest`"} } } }, diff --git a/scripts/audit_candidate_matrix.py b/scripts/audit_candidate_matrix.py index ed7fd2c..6946ca4 100644 --- a/scripts/audit_candidate_matrix.py +++ b/scripts/audit_candidate_matrix.py @@ -514,6 +514,25 @@ def c11_3_relation_experimental(): def c12_1_pretag_audit(): + """A signed pre-tag audit receipt must bind THIS tree, THIS version and THIS gate. + + WHY THIS IS RED ON A WORK BRANCH, AND WHY THAT IS CORRECT (Owner decision 2026-08-30, card + OA-4a8daddb55). A receipt binds a `subject_tree_digest`. A work branch is not finished and will + get at least one more commit when it merges, so a receipt issued against it attests a tree that + is about to stop existing. Producing one anyway would be exactly the act this check was built to + catch — it would REPRODUCE the finding instead of closing it. C12.1 is a RELEASE gate; the + receipt belongs to the tree that actually gets tagged. + + So a red C12.1 on a branch is not unfinished work and not a tool defect. It is the check doing + its job on an object it was not meant to bless. Measured the same day: the v5.0.0 receipt + (`audit_artifacts/500/pre_tag_receipt_v5.0.0.json`) binds `4212087273dc…`, which IS the + subject_tree_digest of the v5.0.0 tag, is signed by the pinned key, and this gate returns ok + when run against that tree. + + HONEST LIMIT, recorded rather than smoothed over: the receipt's signed `audit_output_digest` + resolves to no artifact that could be found, and nothing in this gate resolves it — the field is + signed, which makes it tamper-evident and attributable, not checkable. + """ import pre_tag_audit_gate as pta r = pta.evaluate(REPO, version=VERSION_UNDER_TEST) return (PASS, f"pre-tag adversarial audit recorded for {VERSION_UNDER_TEST}") if r["ok"] \ diff --git a/scripts/mutation_check.py b/scripts/mutation_check.py index d5bef86..4e9499a 100644 --- a/scripts/mutation_check.py +++ b/scripts/mutation_check.py @@ -300,9 +300,13 @@ "renewal: B3<->B5 ATS ML-DSA signature check disabled (forged anchor)", True), # 3.2.1 hardening (final-audit findings) — each new fail-closed guard must be killed by its test. # F1 — require_pq reverted to a LABEL check accepts a PQ label with an unverified anchor (No-Fake). + # 2026-08-30: beide Zeilen auf den heutigen Quelltext gezogen. 38a672a normalisierte + # `newest.sig_alg` in ein `_sig_label`; der Operator nannte weiter den alten Wortlaut und war + # damit STALE — er meldete eine Luecke, die im PRUEFER lag und nicht im Code. Ersatz mitgezogen, + # sonst mutierte er nebenbei die Normalisierung zurueck und aenderte zwei Dinge statt einem. ("src/proofbundle/renewal.py", - 'pq_verified = anchored and anchor_mode == "authority signature" and "mldsa" in (newest.sig_alg or "")', - 'pq_verified = "mldsa" in (newest.sig_alg or "")', + 'pq_verified = anchored and anchor_mode == "authority signature" and "mldsa" in _sig_label', + 'pq_verified = "mldsa" in _sig_label', "renewal: F1 require_pq reverted to label-only (unverified PQ label passes)", True), # F2 — dropping the future-time guard lets a future-dated newest ATS read as perpetually fresh. ("src/proofbundle/renewal.py", @@ -447,9 +451,13 @@ "relation-statement: exactly-one-edge structure gate removed", True), # (vii) reject_retracted self-assertion gate disabled (a verified retracts statement no longer # blocks continued automated use): killed by TestPolicyGates.test_reject_retracted_blocks. + # 2026-08-30: beide Zeilen auf den heutigen Quelltext gezogen. fd84e1d ("27 Mitgliedstests + # hashten Angreiferdaten — die Klasse, nicht die 27 Zeilen") routete den Mitgliedstest durch + # is_member; der Operator nannte weiter `rel0 in ...` und war damit STALE. Ersatz mitgezogen, + # sonst mutierte er nebenbei die Haertung zurueck und aenderte zwei Dinge statt einem. ("src/proofbundle/relation_statement.py", - " if resolved and relations.get(\"reject_retracted\") and rel0 in _SELF_ASSERTED_RETRACTORS:", - " if False and relations.get(\"reject_retracted\") and rel0 in _SELF_ASSERTED_RETRACTORS:", + " if resolved and relations.get(\"reject_retracted\") and is_member(rel0, _SELF_ASSERTED_RETRACTORS):", + " if False and relations.get(\"reject_retracted\") and is_member(rel0, _SELF_ASSERTED_RETRACTORS):", "relation-statement: reject_retracted gate disabled (retracts no longer blocks)", True), # (viii) lattice violation — cryptoValid dropped from the aggregate `ok` (a forged statement with a # valid structure would read ok): killed by TestEmitVerify.test_forged_signature_fails_and_no_trust_fields. diff --git a/scripts/mutation_operators_fresh.py b/scripts/mutation_operators_fresh.py new file mode 100644 index 0000000..7c434f8 --- /dev/null +++ b/scripts/mutation_operators_fresh.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Stale-Operator-Vorpruefung — die Klasse hinter beiden Luecken vom 30.08.2026. + +WARUM ES DAS GIBT. Die Operatoren in ``mutation_check.py`` zitieren Quelltext WOERTLICH. Wird die +zitierte Zeile veraendert — auch voellig zu Recht —, misst der Operator nichts mehr. Beide Luecken +des Laufs vom 30.08. waren genau das, und BEIDE wurden von einem KLASSENFIX ausgeloest: + + #58 38a672a "mldsa" in (newest.sig_alg or "") -> "mldsa" in _sig_label + (ein nicht-String sig_alg liess den Mitgliedstest mit rohem TypeError abstuerzen) + #80 fd84e1d rel0 in _SELF_ASSERTED_RETRACTORS -> is_member(rel0, _SELF_ASSERTED_RETRACTORS) + ("27 Mitgliedstests hashten Angreiferdaten — die Klasse, nicht die 27 Zeilen") + +Ein Klassenfix, der N Aufrufstellen verbessert, entwertet still jeden Pruefer, der diese Stellen +woertlich zitiert. Das ist die Klasse, und sie ist teurer als sie aussieht: das Tor bemerkt es erst +nach einem ~3-Stunden-Lauf, weil jeder Operator eine volle Testsuite kostet. + +WAS DAS HIER AENDERT. Die Frage "passt jedes Muster noch auf seine Datei" ist REIN STATISCH und in +Sekunden zu beantworten — kein Testlauf noetig. Diese Vorpruefung beantwortet sie, bevor die teure +Arbeit beginnt. Sie ersetzt das Tor NICHT: sie sagt nichts darueber, ob ein Mutant getoetet wird. +Sie sagt nur, ob die Operatoren ueberhaupt noch auf den Code zeigen, den sie zu pruefen behaupten. + +ZWEI FRAGEN, nicht eine. Aufgeworfen von der Gegenlesung am 30.08.2026: + + 1. Findet jedes Muster seine Datei? — sonst misst der Operator NICHTS. + 2. Findet es sie GENAU EINMAL? — sonst trifft `str.replace(alt, neu, 1)` die + ERSTE Fundstelle, und die muss nicht die + gemeinte sein. Der Operator mutiert dann still + den falschen Ort und misst etwas anderes, als + seine Bezeichnung sagt. + +Die zweite Frage ist genauso statisch wie die erste und war vorher nicht gestellt. Gemessen am +30.08.2026: 0 von 88 Mustern sind mehrdeutig — der Fall tritt heute nicht auf, und genau deshalb +faellt er ohne Pruefung auch nicht auf, wenn er eintritt. + +EHRLICHE GRENZE, und sie ist der wichtigste Satz hier. Diese Vorpruefung faengt SYNTAKTISCHE +Veralterung. Sie faengt NICHT den Fall, dass das Muster noch passt, waehrend die Logik DRUMHERUM +sich geaendert hat — etwa eine neue Bedingung neben der zitierten, die die mutierte Wache +kompensiert. Dann meldet sie OK, und der Operator misst trotzdem weniger, als er behauptet. Dagegen +hilft nur der volle Lauf. + + exit 0 = jedes Muster wird genau einmal gefunden + exit 1 = mindestens eines fehlt oder ist mehrdeutig (mit Datei, Index und Bezeichnung) +""" +from __future__ import annotations + +import importlib.util as _iu +import pathlib +import sys + +ROOT = pathlib.Path(__file__).resolve().parent.parent + + +def _mutations(): + spec = _iu.spec_from_file_location("mutation_check", ROOT / "scripts" / "mutation_check.py") + mod = _iu.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) + return mod.MUTATIONS + + +def stale_operators(repo: pathlib.Path | None = None) -> list[dict]: + """Jeder Operator, dessen Muster fehlt ODER mehrdeutig ist. Leere Liste = alle frisch.""" + repo = repo or ROOT + out: list[dict] = [] + for idx, (rel, old, _new, label, _expect) in enumerate(_mutations()): + pfad = repo / rel + if not pfad.is_file(): + out.append({"index": idx, "label": label, "file": rel, "reason": "Datei fehlt"}) + continue + src = pfad.read_text(encoding="utf-8") + pats = list(old) if isinstance(old, tuple) else [old] + fehlend = [p for p in pats if p not in src] + if fehlend: + out.append({"index": idx, "label": label, "file": rel, + "reason": f"{len(fehlend)} von {len(pats)} Muster(n) nicht gefunden", + "missing": fehlend}) + continue + # Mehrdeutig ist NICHT dasselbe wie fehlend, und es ist leiser: der Operator laeuft + # durch, mutiert aber die erste Fundstelle statt der gemeinten. + viele = [(p, src.count(p)) for p in pats if src.count(p) != 1] + if viele: + out.append({"index": idx, "label": label, "file": rel, + "reason": "; ".join(f"Muster kommt {n}x vor (erwartet genau 1x)" for _p, n in viele), + "missing": [p for p, _n in viele]}) + return out + + +def main() -> int: + stale = stale_operators() + gesamt = len(_mutations()) + if not stale: + print(f"[mutation-operators-fresh] OK — alle {gesamt} Operatoren finden ihr Muster") + return 0 + print(f"[mutation-operators-fresh] STALE — {len(stale)} von {gesamt} Operator(en) zeigen ins Leere:") + for s in stale: + print(f" #{s['index']} {s['label']}") + print(f" {s['file']}: {s['reason']}") + for p in s.get("missing", [])[:2]: + print(f" erwartet: {p.strip()[:100]}") + print(" Ein stale Operator misst NICHTS. Muster auf den heutigen Quelltext ziehen — und dabei") + print(" den ERSATZ mitziehen, sonst mutiert er nebenbei etwas, das seine Bezeichnung nicht nennt.") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/proofbundle/decision.py b/src/proofbundle/decision.py index 13dd9ad..76fcc77 100644 --- a/src/proofbundle/decision.py +++ b/src/proofbundle/decision.py @@ -73,7 +73,8 @@ "proposedAction.parametersSchemaRef": ("uri", "digest"), "policyBoundary": ("policyEngine", "policyId", "policyDigest", "decisionPath", "bundleRevision", "validFrom", "validUntil"), - "evidenceRefs[]": ("relation", "digest", "artifactDigest", "uri", "predicateType"), + "evidenceRefs[]": ("relation", "digest", "artifactDigest", "uri", "predicateType", "typedDigest"), + "evidenceRefs[].typedDigest": ("type", "purpose", "digestAlgorithm", "digest"), "decision": ("verdict", "reasonCodes", "humanReadableSummary", "obligations", "allowedScope"), "notChecked[]": ("field", "reason", "impact"), "decisionChangeConditions[]": ("conditionType", "description", "requiredEvidenceType"), @@ -85,6 +86,38 @@ } +def _typed_digest_error(td: Any) -> str | None: + """The typed-digest-reference invariant — ONE definition, mirrored by schemas/decision-receipt. + + An ADDITIONAL shape beside `digest`/`artifactDigest`, never a replacement: it carries what the + referenced thing IS and what role the digest plays, next to an EXPLICIT algorithm. `digestAlgorithm` + is required and never defaulted for the same reason `relationDigest` gives — a missing value is + exactly where algorithm confusion hides. See docs/SCITT_CPB_MAPPING.md (G3). + + Returns an error string, or None when the reference is sound. + """ + if not isinstance(td, dict): + return "must be an object" + # Own key-set check, not only the nested closure: this helper is called directly (and tested + # directly), so it must be sound STANDING ALONE. Relying on a caller's closure would make the + # docstring's "ONE definition" true only in one code path. + unknown = sorted(set(td) - {"type", "purpose", "digestAlgorithm", "digest"}) + if unknown: + return f"undeclared field(s) {unknown}" + missing = [k for k in ("type", "digestAlgorithm", "digest") if k not in td] + if missing: + return f"missing required field(s) {missing}" + if td.get("digestAlgorithm") != "jcs-sha256-v1": + return "digestAlgorithm must be 'jcs-sha256-v1' (explicit, never defaulted)" + for k in ("type", "purpose"): + if k in td and not (isinstance(td[k], str) and td[k]): + return f"{k}, when present, must be a non-empty string" + dg = td.get("digest") + if not (isinstance(dg, str) and _SHA256_HEX.match(dg)): + return "digest must be a 64-character lowercase sha256 hex string" + return None + + def _as_dict(v): """adversarial re-audit r5/r6 class-fix: Config-Sub-Feld als dict, sonst {} (das ``_as_dict(x.get(k))``-Idiom ersetzte nur FALSY).""" return v if isinstance(v, dict) else {} @@ -230,6 +263,8 @@ def validate_decision_predicate(predicate: Any, *, strict: bool = False) -> list errors.append(f"evidenceRefs[{i}] needs a string 'relation' and a sha256 content-root 'digest'") elif "artifactDigest" in ref and not _is_digest(ref.get("artifactDigest")): errors.append(f"evidenceRefs[{i}].artifactDigest, when present, must be a sha256 digest") + elif "typedDigest" in ref and _typed_digest_error(ref.get("typedDigest")) is not None: + errors.append(f"evidenceRefs[{i}].typedDigest: {_typed_digest_error(ref.get('typedDigest'))}") elif "evidenceRefs" in predicate: errors.append("evidenceRefs must be a list") diff --git a/src/proofbundle/evalclaim.py b/src/proofbundle/evalclaim.py index 44980b3..a9ad5a5 100644 --- a/src/proofbundle/evalclaim.py +++ b/src/proofbundle/evalclaim.py @@ -59,8 +59,18 @@ "eval_evidence_class", "SCORE_EVIDENCE_CLASSES", "EXACT_SCORE_VERIFIED", "THRESHOLD_VERDICT_VERIFIED", "SCORE_COMMITMENT_PRESENT", "SCORE_WITHHELD", "METHODOLOGY_NOT_EVALUATED", "enclave_assurance_proven", + "classify_eval_claim", "CLAIM_VALID", "CLAIM_REFUSED_UNKNOWN_SCHEMA", "CLAIM_INVALID", ] +# R2 of the receipt-envelope profile: a refusal is a SEPARATE outcome, not `invalid`. A consumer must +# be able to tell "this receipt is invalid" from "I cannot judge this receipt". `decode_eval_claim` +# returns None for BOTH — its documented contract, relied on by callers, and changing it would be a +# breaking SemVer step. `classify_eval_claim` is the ADDITIVE way to get the distinction: new +# function, no existing caller affected, released contract untouched. +CLAIM_VALID = "valid" +CLAIM_REFUSED_UNKNOWN_SCHEMA = "refused_unknown_schema" +CLAIM_INVALID = "invalid" + class EvalClaimError(ValueError): """Raised for a malformed eval claim (float in payload, non-NFC string, unsafe int, …).""" @@ -346,6 +356,42 @@ def decode_eval_claim(bundle, *, expected_context: Optional[str] = None) -> Opti return None +def classify_eval_claim(bundle, *, expected_context: Optional[str] = None) -> tuple: + """Three-outcome classification of a bundle: (outcome, claim-or-None). + + ``CLAIM_VALID`` — verified, and the claim is a sound eval claim (the claim is returned). + ``CLAIM_REFUSED_UNKNOWN_SCHEMA`` — the bundle VERIFIES, but its payload declares a schema this + verifier does not know. That is not a defect of the receipt; it is the limit of this verifier, + and reporting it as `invalid` would put a wrong verdict on someone else's sound artifact. + ``CLAIM_INVALID`` — everything genuinely judgeable and wrong: a broken signature, a payload that + is not JSON, a known schema carrying a malformed claim. + + ORDER MATTERS AND IS DELIBERATE. Authenticity is decided FIRST: a bundle whose signature does not + verify is `invalid` no matter what schema it names, because a broken signature IS judgeable and + "I cannot judge this" would be the weaker, wrong answer. Only an AUTHENTIC payload can earn a + refusal. + + Never raises — same never-raise contract as ``decode_eval_claim``. + """ + try: + if isinstance(bundle, str): + bundle = load_bundle(bundle) + if not verify_bundle(bundle).ok: + return (CLAIM_INVALID, None) + payload = decode_b64(bundle["payload_b64"]) + claim = load_claim_text(payload.decode("utf-8")) + except (ProofBundleError, KeyError, ValueError, TypeError, EvalClaimError, OSError): + return (CLAIM_INVALID, None) + if not isinstance(claim, dict): + return (CLAIM_INVALID, None) + if claim.get("schema") != EVAL_CLAIM_SCHEMA: + return (CLAIM_REFUSED_UNKNOWN_SCHEMA, None) + decoded = decode_eval_claim(bundle, expected_context=expected_context) + if decoded is None: + return (CLAIM_INVALID, None) + return (CLAIM_VALID, decoded) + + def claim_warnings(claim: dict) -> list: """Honest trust warnings for an already-verified claim (v1.1). A verified signature proves authorship + integrity, NOT that the number is true or the study was pre-registered. The weakest combination — diff --git a/tests/conftest.py b/tests/conftest.py index 0250f99..93408db 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -84,8 +84,16 @@ def running_in_repo_checkout() -> bool: _ROOT_NAMEN = {"REPO", "ROOT", "REPO_ROOT", "_REPO_ROOT", "PROJECT_ROOT"} -def _wurzel_relative_pfade(quelle: str) -> set[str]: - """String literals used as `` / "literal"`` in this module's source.""" +def _wurzel_relative_pfade(quelle: str, tiefe: int | None = None) -> set[str]: + """String literals used as `` / "literal"`` in this module's source. + + ``tiefe`` = wie viele ``.parent``-Schritte von DIESEM Modul aus die Repo-Wurzel treffen. Ohne + Angabe wird eine aus ``__file__`` abgeleitete Wurzel NICHT gebunden — lieber nichts sehen als + das Falsche sehen (Gegenlesung 2026-08-30, Fund C: ``Path(__file__).parent`` ist das + tests-Verzeichnis, nicht die Wurzel; 29 Stellen im Baum schreiben genau das, und ein + ``fixtures`` daraus als wurzelrelativ zu lesen wuerde diese Module ausserhalb eines Checkouts + still ueberspringen). + """ import ast # noqa: PLC0415 - only needed on the from-sdist path try: @@ -93,10 +101,123 @@ def _wurzel_relative_pfade(quelle: str) -> set[str]: except SyntaxError: return set() + def _ist_dateiabgeleitet(knoten) -> bool: + """``Path(__file__)`` mit mindestens einem ``.parent``/``.parents[...]`` darauf. + + WARUM DAS EINE EIGENE PRUEFUNG IST (Fix 2026-08-30): `_ist_wurzel` erkannte eine Wurzel am + NAMEN aus `_ROOT_NAMEN` — alles gross geschrieben. Ein Modul, das seine Wurzel in eine LOKALE + Variable legt (`root = Path(__file__).resolve().parent.parent`), war damit VOLLSTAENDIG + unsichtbar: nicht nur der eine Pfad, das ganze Modul. Live gefallen ist daran + `test_classify_eval_claim` im hermetic-cleanroom-Lauf, weil es `docs/…` liest und `docs/` + vom sdist geprunt wird. + + DIE NAMENSLISTE ZU OEFFNEN WAERE DER FALSCHE FIX, gemessen: sie ist der UNTERSCHEIDER + zwischen einer modulweiten Konstante (meint konventionell die Repo-Wurzel) und einer lokalen + Variable (meint meist etwas anderes). Nimmt man Kleinschreibung einfach dazu, gelten + `root = self._copy_corpus()` (kopiertes Korpusverzeichnis) und `repo = tmp_path / "r"` + (Temp-Verzeichnis) als Wurzeln, und ihre relativen Fragmente werden als fehlende Repo-Pfade + gelesen — 14 Falsch-Positiv-Pfade in zwei Modulen, die dann ausserhalb eines Checkouts still + uebersprungen wuerden. + + DESHALB SEMANTISCH STATT NAMENSBASIERT: gebunden wird nur, was NACHWEISLICH aus `__file__` + abgeleitet ist. Der Name spielt keine Rolle mehr, die Herkunft schon. + """ + return _schritte(knoten) == tiefe if tiefe is not None else False + + def _schritte(knoten): + """Zahl der parent-Schritte auf einer __file__-Kette, sonst None. + + GEZAEHLT STATT GERATEN. `.parent` und `.parents[n]` ohne Tiefenpruefung zu akzeptieren war + eine Ueberdehnung: `Path(__file__).parent` ist das tests-Verzeichnis. Erst wenn die Zahl der + Schritte genau der Entfernung DIESES Moduls zur Repo-Wurzel entspricht, ist es die Wurzel. + """ + if isinstance(knoten, ast.Subscript): # …parents[n] -> n+1 Schritte + n = _schritte(knoten.value) + if n is None: + return None + idx = knoten.slice + if isinstance(idx, ast.Constant) and isinstance(idx.value, int): + return n + idx.value + 1 + return None # variabler Index: unbestimmbar + if isinstance(knoten, ast.Attribute): + if knoten.attr == "parent": + n = _schritte(knoten.value) + return None if n is None else n + 1 + if knoten.attr == "parents": + return _schritte(knoten.value) # zaehlt erst mit dem Subscript + return _schritte(knoten.value) # .resolve() usw. durchreichen + if isinstance(knoten, ast.Call): + if _ist_dateiquelle(knoten): + return 0 + return _schritte(knoten.func) + return None + + def _ist_dateiquelle(knoten) -> bool: + """``Path(__file__)`` bzw. eine Kette darauf, OHNE dass schon ein parent genommen wurde.""" + if isinstance(knoten, ast.Call): + if (isinstance(knoten.func, ast.Name) and knoten.func.id in ("Path", "PosixPath") + and any(isinstance(a, ast.Name) and a.id == "__file__" for a in knoten.args)): + return True + return _ist_dateiquelle(knoten.func) + if isinstance(knoten, ast.Attribute): + return _ist_dateiquelle(knoten.value) + return False + + # Lokale Namen, die NACHWEISLICH eine aus __file__ abgeleitete Wurzel tragen. Ermittelt aus den + # Zuweisungen des Moduls — eine Zuweisung ist der Beleg, den der blosse Name nicht liefert. + _gebunden: set[str] = set() + for _z in ast.walk(baum): + if isinstance(_z, ast.Assign) and _ist_dateiabgeleitet(_z.value): + for _ziel in _z.targets: + if isinstance(_ziel, ast.Name): + _gebunden.add(_ziel.id) + + # SCHLEIFENVARIABLEN UEBER EINEM LITERAL-TUPEL, und ausdruecklich NUR darueber. + # + # `_kette` verwirft ein variables Segment mit der Begruendung "macht den Rest unbestimmbar", und + # das ist im allgemeinen richtig. EIN Fall ist aber vollstaendig entscheidbar: laeuft die Schleife + # ueber ein Tupel oder eine Liste aus lauter String-KONSTANTEN, nimmt die Variable genau diese + # Werte an — mehr Aufloesung braucht es nicht, und es ist keine Variablenverfolgung im + # allgemeinen Sinn. + # + # ANLASS (2026-08-30): `for rel in ("docs/…", "CONFORMANCE.md"): (root / rel)` blieb unsichtbar, + # obwohl beide Werte woertlich im Modul stehen. Zusammen mit der Namensblindheit oben machte das + # den hermetic-cleanroom-Fehlschlag aus. Eine Schleife ueber etwas anderes als Konstanten bleibt + # unbestimmbar und wird weiterhin verworfen. + # DIE BINDUNG IST AUF DEN SCHLEIFENKOERPER BESCHRAENKT, und das ist kein Detail. + # Eine erste Fassung band modulweit NACH NAMEN — und leckte prompt: dasselbe Modul hat zwei + # Schleifen ueber `rel`, eine ueber Manifest-Faelle (nicht konstant) und eine ueber ein + # Literal-Tupel. Die Werte der zweiten landeten in der Kette der ersten und erzeugten Pfade, die + # es nirgends gibt (`conformance/CONFORMANCE.md`). Gemeint ist nie "der Name", immer "diese + # Schleife". + _schleifen: list[tuple[str, tuple[str, ...], list]] = [] + for _f in ast.walk(baum): + if not isinstance(_f, ast.For) or not isinstance(_f.target, ast.Name): + continue + if not isinstance(_f.iter, (ast.Tuple, ast.List)): + continue + werte = [e.value for e in _f.iter.elts + if isinstance(e, ast.Constant) and isinstance(e.value, str)] + if not (werte and len(werte) == len(_f.iter.elts)): # ALLE Elemente konstant, sonst unbestimmbar + continue + # Wird die Variable im Koerper NEU zugewiesen, gilt die Kopfbindung dort nicht mehr — + # dann lieber nichts binden (Gegenlesung 2026-08-30, Fund A; live 0 Vorkommen, aber die + # Ueberdehnung zeigt in die schaedliche Richtung: ein erfundener Pfad laesst ein Modul + # ausserhalb eines Checkouts still ausfallen). + if any(isinstance(_x, ast.Assign) + and any(isinstance(_t, ast.Name) and _t.id == _f.target.id for _t in _x.targets) + for _st in _f.body for _x in ast.walk(_st)): + continue + _schleifen.append((_f.target.id, tuple(werte), _f.body)) + + _schleifenwerte: dict[str, tuple[str, ...]] = {} # je Durchgang gesetzt, s.u. + def _ist_wurzel(knoten) -> bool: # REPO / "x" · _REPO_ROOT / "x" · (Path(__file__).resolve().parents[1]) / "x" · REPO / "a" / "b" + # dazu seit 2026-08-30: ein LOKALER Name, dem im selben Modul eine aus __file__ abgeleitete + # Wurzel zugewiesen wurde (siehe _ist_dateiabgeleitet). if isinstance(knoten, ast.Name): - return knoten.id in _ROOT_NAMEN + return knoten.id in _ROOT_NAMEN or knoten.id in _gebunden if isinstance(knoten, ast.Subscript): return _ist_wurzel(knoten.value) if isinstance(knoten, ast.Attribute): @@ -116,21 +237,40 @@ def _kette(knoten): selbst in einem vollstaendigen Checkout. Ein zerlegter Pfad ist ein anderer Pfad. """ if isinstance(knoten, ast.BinOp) and isinstance(knoten.op, ast.Div): - links_ok, teile = _kette(knoten.left) + links_ok, linke = _kette(knoten.left) if not links_ok: return (False, []) + teile = linke[0] if len(linke) == 1 else None + if teile is None: # mehrere linke Ketten: je Kette weiterfuehren + if isinstance(knoten.right, ast.Constant) and isinstance(knoten.right.value, str): + return (True, [t + [knoten.right.value] for t in linke]) + return (False, []) if isinstance(knoten.right, ast.Constant) and isinstance(knoten.right.value, str): - return (True, teile + [knoten.right.value]) + return (True, [teile + [knoten.right.value]]) + if (isinstance(knoten.right, ast.Name) + and knoten.right.id in _schleifenwerte): # entscheidbarer Sonderfall, s.o. + return (True, [teile + [w] for w in _schleifenwerte[knoten.right.id]]) return (False, []) # ein variables Segment macht den Rest unbestimmbar - return (_ist_wurzel(knoten), []) + return (_ist_wurzel(knoten), [[]]) + + def _sammle(knoten_menge) -> None: + for x in knoten_menge: + if not (isinstance(x, ast.BinOp) and isinstance(x.op, ast.Div)): + continue + ok, ketten = _kette(x) + if not ok: + continue + for teile in ketten: + if teile: + gefunden.add("/".join(teile)) gefunden: set[str] = set() - for x in ast.walk(baum): - if not (isinstance(x, ast.BinOp) and isinstance(x.op, ast.Div)): - continue - ok, teile = _kette(x) - if ok and teile: - gefunden.add("/".join(teile)) + _sammle(ast.walk(baum)) # Durchgang 1: nur konstante Ketten + for _name, _werte, _koerper in _schleifen: # Durchgang 2: je Schleife, NUR ihr Koerper + _schleifenwerte = {_name: _werte} + for _stmt in _koerper: + _sammle(ast.walk(_stmt)) + _schleifenwerte = {} return gefunden @@ -184,8 +324,19 @@ def modul_ist_repo_kontext(pfad: pathlib.Path, wurzel: pathlib.Path = _REPO_ROOT # fault: the path CHAINS were being decomposed (see _kette), so ``src`` / ``proofbundle`` was read as # a root-level ``proofbundle``. With the chain joined correctly the full-path rule is precise, and the # narrowing would have traded a real defect for a comfortable green. + try: + # parts fuer tests/test_x.py = ('tests','test_x.py') -> ZWEI parent-Schritte treffen die + # Wurzel. Die erste Fassung zog eins ab und akzeptierte damit genau das tests-Verzeichnis + # als Wurzel — die Ueberdehnung, die dieser Fix schliessen soll. + # GEGEN `wurzel`, nicht gegen die globale _REPO_ROOT: der Parameter existiert, damit + # gegen einen anderen Baum geprueft werden kann (Tests, entpacktes sdist). Die erste Fassung + # nahm die Konstante — dann liegt ein Testmodul nicht unter ihr, relative_to wirft, und die + # Bindung faellt still aus. Gefangen von den neuen Tests, nicht von meiner Durchsicht. + tiefe = len(pfad.resolve().relative_to(pathlib.Path(wurzel).resolve()).parts) + except (ValueError, OSError): + tiefe = None # Modul liegt nicht unter der Wurzel: nicht binden return any(not (wurzel / rel).exists() and not _ist_bauartefakt(wurzel, rel) - for rel in _wurzel_relative_pfade(quelle)) + for rel in _wurzel_relative_pfade(quelle, tiefe)) def pytest_collection_modifyitems(config, items): diff --git a/tests/test_classify_eval_claim.py b/tests/test_classify_eval_claim.py new file mode 100644 index 0000000..a08de42 --- /dev/null +++ b/tests/test_classify_eval_claim.py @@ -0,0 +1,183 @@ +"""Three-outcome classification (5.1, additive) — R2: a refusal is not `invalid`. + +`decode_eval_claim` returns None for BOTH "I do not know this schema" and "this receipt is broken". +That is its documented, released contract and callers depend on it, so it is left alone. +`classify_eval_claim` is the additive way to get the distinction R2 requires. +""" +import json +import unittest + +from proofbundle.emit import emit_bundle, generate_signer +from proofbundle.evalclaim import ( + CLAIM_INVALID, + CLAIM_REFUSED_UNKNOWN_SCHEMA, + CLAIM_VALID, + build_eval_claim, + canonicalize, + classify_eval_claim, + decode_eval_claim, +) + +TS = "2026-08-30T00:00:00Z" + + +def _good(signer, **over): + kw = dict(suite="s", suite_version="1", metric="m", comparator=">=", threshold="0.80", + score="0.92", n=10, model_id="a", dataset_id="b", issuer="ed25519:x", timestamp=TS) + kw.update(over) + claim, _ = build_eval_claim(**kw) + from proofbundle.evalclaim import emit_eval_receipt + return emit_eval_receipt(claim, signer) + + +class TestDreiAusgaenge(unittest.TestCase): + def test_gueltig(self): + outcome, claim = classify_eval_claim(_good(generate_signer())) + self.assertEqual(outcome, CLAIM_VALID) + self.assertIsInstance(claim, dict) + + def test_fremde_schema_id_wird_abgelehnt_nicht_fuer_ungueltig_erklaert(self): + s = generate_signer() + c = decode_eval_claim(_good(s)) + assert c is not None + b = emit_bundle(canonicalize(dict(c, schema="acme/other/v9")), s) + outcome, claim = classify_eval_claim(b) + self.assertEqual(outcome, CLAIM_REFUSED_UNKNOWN_SCHEMA) + self.assertIsNone(claim) + # Der Punkt der Regel: der Ausgang ist ein ANDERER als bei einem kaputten Beleg. + self.assertNotEqual(outcome, CLAIM_INVALID) + + def test_kaputter_beleg_mit_bekanntem_schema_ist_ungueltig(self): + s = generate_signer() + c = decode_eval_claim(_good(s)) + assert c is not None + b = emit_bundle(canonicalize(dict(c, threshold="inf")), s) + self.assertEqual(classify_eval_claim(b)[0], CLAIM_INVALID) + + def test_echtheit_wird_zuerst_entschieden(self): + # Die scharfe Kante: unpruefbar UND fremde schema-id. Eine kaputte Signatur IST + # beurteilbar, also darf hier NICHT 'ich kann nicht urteilen' herauskommen — sonst + # erkauft ein Faelscher Schweigen, indem er das Schema-Feld umbenennt. + s = generate_signer() + c = decode_eval_claim(_good(s)) + assert c is not None + b = emit_bundle(canonicalize(dict(c, schema="acme/other/v9")), s) + sig = b["signature"]["sig_b64"] + b["signature"] = dict(b["signature"], sig_b64=("B" + sig[1:]) if sig[0] != "B" else ("C" + sig[1:])) + self.assertEqual(classify_eval_claim(b)[0], CLAIM_INVALID) + + def test_niemals_werfen(self): + for schrott in ({"not": "a bundle"}, [1, 2, 3], "/kein/pfad.json", "", None, 42, True, + {"payload_b64": "!!!", "signature": {}, "merkle": {}, "schema": "x"}): + outcome, claim = classify_eval_claim(schrott) + self.assertEqual(outcome, CLAIM_INVALID, f"{schrott!r}") + self.assertIsNone(claim) + + def test_erwarteter_kontext_wird_durchgereicht(self): + s = generate_signer() + b = _good(s, context_binding="ctx-a") + self.assertEqual(classify_eval_claim(b, expected_context="ctx-a")[0], CLAIM_VALID) + self.assertEqual(classify_eval_claim(b, expected_context="ctx-b")[0], CLAIM_INVALID) + + def test_vertrag_von_decode_bleibt_unveraendert(self): + # Die additive Funktion darf den veroeffentlichten Vertrag nicht verschieben. + s = generate_signer() + c = decode_eval_claim(_good(s)) + assert c is not None + fremd = emit_bundle(canonicalize(dict(c, schema="acme/other/v9")), s) + kaputt = emit_bundle(canonicalize(dict(c, threshold="inf")), s) + self.assertIsNone(decode_eval_claim(fremd)) + self.assertIsNone(decode_eval_claim(kaputt)) + + +class TestKorpusDeckung(unittest.TestCase): + """Die Vektorfamilie ist die AEUSSERE Autoritaet fuer diese Regeln — sie muss existieren und + je Regel eine Gegenprobe UND eine Positivkontrolle fuehren.""" + + def test_je_regel_gegenprobe_und_positivkontrolle(self): + from pathlib import Path + root = Path(__file__).resolve().parent.parent + cases = json.loads((root / "conformance" / "manifest.json").read_text())["cases"] + eigene = [c for c in cases if c.startswith("envelope_profile/")] + self.assertGreaterEqual(len(eigene), 9) + rollen: dict = {} + for rel in eigene: + case = json.loads((root / "conformance" / rel / "case.json").read_text()) + self.assertEqual(case["kind"], "envelope_profile_rule", rel) + rollen.setdefault(case["rule"], set()).add(case["role"]) + for regel in ("R1", "R2", "R3", "R4"): + self.assertIn(regel, rollen, f"{regel} hat keinen Vektor") + self.assertIn("counter_proof", rollen[regel], f"{regel} ohne Gegenprobe") + self.assertIn("positive_control", rollen[regel], f"{regel} ohne Positivkontrolle") + # R5 traegt in dieser Runde ABSICHTLICH keine Vektoren, Owner-Berichtigung Fassung 8: + # die Feldform ist offen, seit CAP-1 (draft-hillier-coverage-attestation-00, 20.08.2026) + # gemessen einen nur durch Subtraktion ausgeglichenen Rest zurueckweist. Eine Gegenprobe + # gegen eine zurueckgezogene Form waere wertlos. Der Test HAELT das fest, statt es + # wegzulassen — eine stillschweigend fehlende Regel sieht aus wie eine vergessene. + self.assertNotIn("R5", rollen, + "R5 soll in dieser Runde KEINE Vektoren haben (Fassung 8); " + "taucht wieder einer auf, ist die Entscheidung unbemerkt zurueckgenommen") + + def test_die_zahl_im_text_ist_die_gemessene_zahl(self): + """Die Prosa nennt eine Vektorzahl. Sie muss die GEZAEHLTE sein. + + Am 30.08.2026 stand dort 'twelve', gezaehlt waren dreizehn — die Zahl war nach dem + Hinzufuegen eines Vektors nicht mitgewachsen. Das ist der Instanzfehler; der Klassenfehler + ist, dass eine Zahl in einem Dokument an nichts gebunden war. Jetzt ist sie es. + """ + from pathlib import Path + root = Path(__file__).resolve().parent.parent + gezaehlt = len(list((root / "conformance" / "envelope_profile").glob("*/case.json"))) + WORT = {10: "ten", 11: "eleven", 12: "twelve", 13: "thirteen", 14: "fourteen", + 15: "fifteen", 16: "sixteen", 17: "seventeen", 18: "eighteen"} + self.assertIn(gezaehlt, WORT, f"{gezaehlt} Vektoren — Zahlwort-Tabelle erweitern") + wort = WORT[gezaehlt] + for rel in ("docs/RECEIPT_ENVELOPE_PROFILE.md", "CONFORMANCE.md"): + text = (root / rel).read_text(encoding="utf-8") + stellen = [z for z in text.splitlines() if "envelope_profile/" in z and " vectors" in z] + self.assertTrue(stellen, f"{rel} nennt die Vektorzahl nicht mehr — Test anpassen oder Text") + for z in stellen: + self.assertIn(wort, z, f"{rel}: '{z.strip()[:80]}' nennt nicht {wort} ({gezaehlt} gezaehlt)") + + +if __name__ == "__main__": + unittest.main() + + +class TestNeverRaiseUnterTiefe(unittest.TestCase): + """Die never-raise-Zusage unter PATHOLOGISCHER Verschachtelung (CWE-674). + + Eine Gegenlesung am 30.08.2026 meldete, RecursionError entkomme dem except-Block von + classify_eval_claim. Gemessen ist der Fund WIDERLEGT: load_claim_text delegiert an den + budget-begrenzten strikten Parser, der Tiefe auf ein typisiertes EvalClaimError abbildet + ('JSON nesting is too deep'). Der Test steht trotzdem hier — die Eigenschaft war vorher nur + an flachem Unsinn geprueft, und genau die interessante Eingabe fehlte. + """ + + def _bundle_mit_nutzlast(self, roh: bytes): + from proofbundle.emit import emit_bundle + return emit_bundle(roh, generate_signer()) + + def test_tiefe_liste_wirft_nicht(self): + roh = (b"[" * 20000) + (b"]" * 20000) + outcome, claim = classify_eval_claim(self._bundle_mit_nutzlast(roh)) + self.assertEqual(outcome, CLAIM_INVALID) + self.assertIsNone(claim) + + def test_tiefes_objekt_wirft_nicht(self): + roh = (b"{" + b'"a":{' * 5000 + b"}" * 5001) + outcome, claim = classify_eval_claim(self._bundle_mit_nutzlast(roh)) + self.assertEqual(outcome, CLAIM_INVALID) + self.assertIsNone(claim) + + def test_decode_eval_claim_wirft_ebenfalls_nicht(self): + # Derselbe Vertrag eine Ebene tiefer — classify ruft decode, also muss auch das halten. + for roh in ((b"[" * 20000) + (b"]" * 20000), b"{" + b'"a":{' * 5000 + b"}" * 5001): + self.assertIsNone(decode_eval_claim(self._bundle_mit_nutzlast(roh))) + + def test_die_tiefe_wird_typisiert_abgewiesen_nicht_als_recursionerror(self): + # Der Kern: eine SAUBERE Fehlermeldung statt eines rohen RecursionError. + from proofbundle.evalclaim import EvalClaimError, load_claim_text + with self.assertRaises(EvalClaimError) as ctx: + load_claim_text("[" * 20000 + "]" * 20000) + self.assertNotIsInstance(ctx.exception, RecursionError) diff --git a/tests/test_evidence_typed_digest.py b/tests/test_evidence_typed_digest.py new file mode 100644 index 0000000..4552550 --- /dev/null +++ b/tests/test_evidence_typed_digest.py @@ -0,0 +1,179 @@ +"""Typed digest reference on evidenceRefs[] (5.1, additive) — one red test per invariant. + +G3 of docs/SCITT_CPB_MAPPING.md: our evidence references carry the algorithm inside the KEY NAME +(`digest.sha256`), while the draft's typed reference carries it in a field of its own next to what +the referenced thing IS and what role the digest plays. `relationDigest` in the same schema already +does it the conformant way — the gap was internal inconsistency, not absence. + +`typedDigest` is an ADDITIONAL shape. It replaces nothing: an entry may carry `digest` alone, +`typedDigest` alone, or both. The schema is DOCS, decision.py is ENFORCED — so every case here is +checked on BOTH, because a divergence between them is the class test_schema_parity.py exists for. +""" +from __future__ import annotations + +import json +import unittest +from pathlib import Path + +from proofbundle.decision import _typed_digest_error, validate_decision_predicate + +try: + import jsonschema +except ImportError: # pragma: no cover - dev-only dependency + jsonschema = None + +ROOT = Path(__file__).resolve().parent.parent +SCHEMA = json.loads((ROOT / "schemas" / "decision-receipt-v0.1.schema.json").read_text(encoding="utf-8")) +EXAMPLES = ROOT / "examples" +GOOD = {"type": "eval-receipt", "purpose": "threshold-evidence", + "digestAlgorithm": "jcs-sha256-v1", "digest": "a" * 64} + + +def _deny() -> dict: + return json.loads((EXAMPLES / "decision_receipt_deny.json").read_text(encoding="utf-8")) + + +class TestTypedDigestHelfer(unittest.TestCase): + def test_gute_referenz(self): + self.assertIsNone(_typed_digest_error(GOOD)) + + def test_purpose_ist_optional(self): + self.assertIsNone(_typed_digest_error({k: v for k, v in GOOD.items() if k != "purpose"})) + + def test_pflichtfelder(self): + for fehlt in ("type", "digestAlgorithm", "digest"): + err = _typed_digest_error({k: v for k, v in GOOD.items() if k != fehlt}) + self.assertIsNotNone(err) + assert err is not None + self.assertIn(fehlt, err) + + def test_algorithmus_wird_nie_vorbelegt(self): + # Der Grund, den relationDigest im selben Schema nennt: ein fehlender Wert ist genau die + # Stelle, an der sich Algorithmus-Verwechslung versteckt. + for schlecht in ("sha256", "SHA-256", "", None, "legacy-sortkeys-json-v0"): + self.assertIsNotNone(_typed_digest_error(dict(GOOD, digestAlgorithm=schlecht))) + + def test_digest_muss_64_hex_klein_sein(self): + for schlecht in ("ab", "A" * 64, "g" * 64, "a" * 63, "a" * 65, 12345, None): + self.assertIsNotNone(_typed_digest_error(dict(GOOD, digest=schlecht))) + + def test_leere_strings_zaehlen_nicht_als_wert(self): + for k in ("type", "purpose"): + self.assertIsNotNone(_typed_digest_error(dict(GOOD, **{k: ""}))) + + def test_kein_objekt(self): + for schlecht in ("a" * 64, ["a"], 1, None, True): + self.assertIsNotNone(_typed_digest_error(schlecht)) + + +@unittest.skipIf(jsonschema is None, "jsonschema not installed (pip install -e .[dev])") +class TestTypedDigestParitaet(unittest.TestCase): + """Doku-Schema und erzwungener Validator muessen sich EINIG sein: beide annehmen oder beide ablehnen.""" + + def _beide(self, ref_over, *, erwartet: bool, msg: str): + p = _deny() + p["evidenceRefs"][0].update(ref_over) + hand = not validate_decision_predicate(p, strict=True) + try: + jsonschema.validate(instance=p, schema=SCHEMA) + schema = True + except jsonschema.ValidationError: + schema = False + self.assertEqual(hand, erwartet, f"{msg}: Validator sagt {hand}") + self.assertEqual(schema, erwartet, f"{msg}: Schema sagt {schema}") + self.assertEqual(hand, schema, f"{msg}: DIVERGENZ Validator={hand} Schema={schema}") + + def test_ohne_typed_digest_unveraendert(self): + self._beide({}, erwartet=True, msg="ohne typedDigest") + + def test_mit_gueltiger_typed_digest(self): + self._beide({"typedDigest": GOOD}, erwartet=True, msg="gueltig") + + def test_ohne_purpose(self): + self._beide({"typedDigest": {k: v for k, v in GOOD.items() if k != "purpose"}}, + erwartet=True, msg="ohne purpose") + + def test_fehlender_typ_faellt_auf_beiden_durch(self): + self._beide({"typedDigest": {k: v for k, v in GOOD.items() if k != "type"}}, + erwartet=False, msg="type fehlt") + + def test_fehlender_algorithmus_faellt_auf_beiden_durch(self): + self._beide({"typedDigest": {k: v for k, v in GOOD.items() if k != "digestAlgorithm"}}, + erwartet=False, msg="digestAlgorithm fehlt") + + def test_falscher_algorithmus_faellt_auf_beiden_durch(self): + self._beide({"typedDigest": dict(GOOD, digestAlgorithm="sha256")}, + erwartet=False, msg="digestAlgorithm falsch") + + def test_kaputter_digest_faellt_auf_beiden_durch(self): + self._beide({"typedDigest": dict(GOOD, digest="ZZ")}, erwartet=False, msg="digest kaputt") + + def test_unbekanntes_feld_im_block_faellt_auf_beiden_durch(self): + # Der verschachtelte Verschluss (Finding 04) muss auch den NEUEN Pfad decken. + self._beide({"typedDigest": dict(GOOD, sneaky=1)}, erwartet=False, msg="sneaky im typedDigest") + + def test_typed_digest_ersetzt_das_pflichtfeld_digest_nicht(self): + p = _deny() + del p["evidenceRefs"][0]["digest"] + p["evidenceRefs"][0]["typedDigest"] = GOOD + self.assertNotEqual(validate_decision_predicate(p, strict=True), [], + "typedDigest darf das erforderliche digest NICHT ersetzen") + with self.assertRaises(jsonschema.ValidationError): + jsonschema.validate(instance=p, schema=SCHEMA) + + +if __name__ == "__main__": + unittest.main() + + +class TestHelferTraegtAllein(unittest.TestCase): + """Der Helfer wird direkt aufgerufen und direkt geprueft — er muss ALLEIN tragen, nicht nur + im Verbund mit dem verschachtelten Verschluss des Aufrufers.""" + + def test_unbekanntes_feld_ohne_den_verschluss(self): + err = _typed_digest_error(dict(GOOD, sneaky=1)) + self.assertIsNotNone(err) + assert err is not None + self.assertIn("sneaky", err) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() + + +class TestKonformitaetsHandlerFailClosed(unittest.TestCase): + """Der envelope_profile_rule-Handler muss in BEIDE Richtungen fail-closed sein: ein Fall ohne + Erwartungsachse kann nicht fehlschlagen, ein Fall mit ZWEI verbirgt alles nach der ersten. + Beide Funde kamen aus der Gegenlesung am 30.08.2026 und wurden vor dem Fix am Quelltext + bestaetigt.""" + + def _handler(self): + import sys + sys.path.insert(0, str(ROOT / "conformance")) + import run_conformance # noqa: PLC0415 + return run_conformance._check_envelope_profile_rule + + def _fall(self): + d = ROOT / "conformance" / "envelope_profile" / "r1-positive-control-canonical-root" + return json.loads((d / "case.json").read_text()), d + + def test_ein_fall_ohne_achse_faellt_durch(self): + case, d = self._fall() + case = dict(case, expected={}) + r = self._handler()(case, d) + self.assertFalse(r["ok"]) + self.assertIn("EXACTLY ONE", r["detail"]) + + def test_ein_fall_mit_zwei_achsen_faellt_durch(self): + # Vor dem Fix wurde dieser Fall GRUEN: die erste Achse stimmte, die zweite war Unsinn + # und wurde nie geprueft. + case, d = self._fall() + case = dict(case, expected=dict(case["expected"], classification="voelliger_unsinn")) + r = self._handler()(case, d) + self.assertFalse(r["ok"], "eine zweite Achse darf nicht still ignoriert werden") + self.assertIn("EXACTLY ONE", r["detail"]) + + def test_genau_eine_achse_geht_durch(self): + # Positivkontrolle: ohne sie wuerde ein Handler, der ALLES ablehnt, diese Klasse bestehen. + case, d = self._fall() + self.assertTrue(self._handler()(case, d)["ok"]) diff --git a/tests/test_sdist_selftest_derivation.py b/tests/test_sdist_selftest_derivation.py index 359786e..2d3d7ab 100644 --- a/tests/test_sdist_selftest_derivation.py +++ b/tests/test_sdist_selftest_derivation.py @@ -193,3 +193,98 @@ def test_ohne_git_bleibt_das_strengere_alte_verhalten(self): self.assertFalse(cf._ist_bauartefakt(pathlib.Path(d), "irgendwas/target/x"), "ohne git wird etwas als Bauartefakt entschuldigt — das entschaerft " "die Ableitung genau dort, wo sie gebraucht wird") + + +class LokaleWurzelUndSchleifenwerte(unittest.TestCase): + """Die Ableitung erkennt eine Wurzel an ihrer HERKUNFT, nicht an ihrem NAMEN. + + ANLASS (2026-08-30, hermetic-cleanroom auf PR #159): ein Test las + ``docs/RECEIPT_ENVELOPE_PROFILE.md`` und fiel im sdist mit FileNotFoundError. Die Ableitung sah + in dem Modul NULL Pfade — zwei Ursachen, gemessen. (1) ``_ROOT_NAMEN`` ist eine + grossgeschriebene Allowlist, die lokale Variable hiess ``root``: damit war das GANZE Modul + unsichtbar. (2) Der Pfad stand hinter einer Schleifenvariable, und ``_kette`` verwirft ein + variables Segment. + + WARUM NICHT EINFACH DIE NAMENSLISTE OEFFNEN: die Grossschreibung ist der UNTERSCHEIDER zwischen + einer modulweiten Konstante (meint konventionell die Wurzel) und einer lokalen Variable (meint + meist etwas anderes). Gemessen kostet das Oeffnen 14 Falsch-Positiv-Pfade in zwei Modulen, deren + lokale Variable ein kopiertes Korpus- bzw. ein Temp-Verzeichnis ist — die wuerden ausserhalb + eines Checkouts still uebersprungen. Deshalb wird die HERKUNFT geprueft: nur was nachweislich + aus ``__file__`` abgeleitet ist und dabei die richtige ZAHL VON SCHRITTEN nimmt, ist die Wurzel. + """ + + def setUp(self): + self.tmp = pathlib.Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.tmp, True) + (self.tmp / "tests").mkdir() + (self.tmp / "vorhanden.md").write_text("da", encoding="utf-8") + + def _modul(self, name: str, quelle: str) -> pathlib.Path: + p = self.tmp / "tests" / name + p.write_text(quelle, encoding="utf-8") + return p + + def test_eine_lokale_kleingeschriebene_wurzel_wird_erkannt(self): + p = self._modul("test_lokal.py", + 'from pathlib import Path\n' + 'def test_x():\n' + ' root = Path(__file__).resolve().parent.parent\n' + ' (root / "docs/FEHLT.md").read_text()\n') + self.assertTrue(cf.modul_ist_repo_kontext(p, wurzel=self.tmp), + "eine lokale Wurzel darf nicht am Namen scheitern") + + def test_ein_pfad_hinter_einer_schleife_ueber_konstanten_wird_erkannt(self): + p = self._modul("test_schleife.py", + 'from pathlib import Path\n' + 'def test_x():\n' + ' root = Path(__file__).resolve().parent.parent\n' + ' for rel in ("docs/FEHLT.md", "vorhanden.md"):\n' + ' (root / rel).read_text()\n') + self.assertTrue(cf.modul_ist_repo_kontext(p, wurzel=self.tmp), + "der entscheidbare Schleifenfall wurde nicht aufgeloest") + + def test_ein_schritt_zu_wenig_ist_NICHT_die_wurzel(self): + """``Path(__file__).parent`` ist das tests-Verzeichnis. 29 Stellen im Baum schreiben das.""" + p = self._modul("test_flach.py", + 'from pathlib import Path\n' + 'def test_x():\n' + ' hier = Path(__file__).resolve().parent\n' + ' (hier / "fixtures").iterdir()\n') + self.assertFalse(cf.modul_ist_repo_kontext(p, wurzel=self.tmp), + "ein tests-relativer Pfad wurde als wurzelrelativ gelesen") + + def test_eine_lokale_variable_ohne_dateiherkunft_ist_keine_wurzel(self): + """``root = self._copy_corpus()`` und ``repo = tmp_path / 'r'`` sind KEINE Repo-Wurzeln.""" + for form in (' root = irgendwas()\n (root / "docs/FEHLT.md").read_text()\n', + ' repo = tmp / "r"\n (repo / "docs/FEHLT.md").read_text()\n'): + with self.subTest(form=form.strip().splitlines()[0][:40]): + p = self._modul("test_fremd.py", + 'from pathlib import Path\ndef test_x(tmp, irgendwas):\n' + form) + self.assertFalse(cf.modul_ist_repo_kontext(p, wurzel=self.tmp), + "eine fremde lokale Variable wurde als Wurzel gelesen") + + def test_eine_im_koerper_neu_zugewiesene_schleifenvariable_bindet_nicht(self): + """Die Kopfbindung gilt im Koerper nicht mehr — dann lieber nichts binden.""" + p = self._modul("test_neuzuweisung.py", + 'from pathlib import Path\n' + 'def test_x():\n' + ' root = Path(__file__).resolve().parent.parent\n' + ' for rel in ("docs/FEHLT.md",):\n' + ' rel = "vorhanden.md"\n' + ' (root / rel).read_text()\n') + self.assertFalse(cf.modul_ist_repo_kontext(p, wurzel=self.tmp), + "eine ueberschriebene Schleifenvariable wurde weiter gebunden") + + def test_zwei_schleifen_mit_demselben_namen_lecken_nicht(self): + """Gemeint ist nie 'der Name', immer 'diese Schleife'.""" + p = self._modul("test_zwei.py", + 'from pathlib import Path\n' + 'def test_x(faelle):\n' + ' root = Path(__file__).resolve().parent.parent\n' + ' for rel in faelle:\n' + ' (root / "unter" / rel / "case.json").read_text()\n' + ' for rel in ("vorhanden.md",):\n' + ' (root / rel).read_text()\n') + gefunden = cf._wurzel_relative_pfade(p.read_text(encoding="utf-8"), 2) + self.assertNotIn("unter/vorhanden.md", gefunden, + "die Werte der einen Schleife erschienen in der Kette der anderen")