110 lines
5.6 KiB
Markdown
110 lines
5.6 KiB
Markdown
# artifact_seal — deterministic SHA-256 seal manifests over a file tree
|
|
|
|
Lane: Codeforge. Stdlib only. No third-party dependencies.
|
|
|
|
artifact_seal.py the engine + CLI (~14 KB)
|
|
selftest_artifact_seal.py 44 checks, 3 layers
|
|
verify_artifact_seal_oracle.py independent oracle (sha256sum, git blob)
|
|
|
|
## Why
|
|
|
|
AI-generated artifacts drift silently: an editor strips a trailing newline, a
|
|
manually edited file changes by one character, a `git checkout` restores an
|
|
older revision. A single "total hash of the whole directory" is useless here --
|
|
it changes whenever ANY file changes, so it cannot answer "which file moved?".
|
|
|
|
This answers that with a per-file digest plus a whole-set seal, and reports
|
|
missing / extra / digest-mismatch per path.
|
|
|
|
## Design decisions that are load-bearing
|
|
|
|
* **Deterministic.** Same bytes, same seal, on any platform. No timestamps, no
|
|
inode data, no absolute paths in the digest input. Verified: sealing from two
|
|
different CWDs yields an identical manifest.
|
|
* **Tamper-evident.** Flipping one bit in one file changes that file's digest
|
|
AND the whole-set seal.
|
|
* **Order-invariant.** The whole-set seal is computed over entries sorted by
|
|
(path, name). Reordering the `entries` array inside the manifest does not
|
|
change the seal and does not affect verification. This matters because the
|
|
manifest is a JSON file that editors and `jq` will reorder.
|
|
* **Length-prefixed canonical encoding.** The whole-set digest is NOT a
|
|
concatenation of the per-file digests. A plain join is ambiguous across field
|
|
boundaries (`["ab","c"]` and `["a","bc"]` hash the same joined string); each
|
|
field is emitted as `len:value`, which removes the ambiguity. A test asserts
|
|
two structurally different trees do not share a seal.
|
|
* **A trailing newline is CONTENT.** `hash_text` does not normalise line
|
|
endings. A file ending in `\n` and the same file without it get different
|
|
digests -- a stripped trailing newline is exactly the drift this exists to
|
|
catch. `str.splitlines()` is never used.
|
|
* **Size is evidence, not a verdict.** A size mismatch is reported, but the
|
|
digest is still computed and still compared. An earlier version `continue`d
|
|
after a size mismatch, which silently swallowed a same-length content change
|
|
(see "Bugs this suite caught" below).
|
|
* **Renames are visible.** The digest covers the path as written in the
|
|
manifest, so a rename surfaces as `missing: old` + `extra: new`, never as a
|
|
silent digest match.
|
|
* **Symlinks and non-regular files are skipped.** Hashing a FIFO is either
|
|
infinite or meaningless. Hidden files (dot-prefixed) ARE included: `.gitignore`
|
|
is content too.
|
|
* **`parse_manifest` raises; `verify_manifest` returns problems.** A structurally
|
|
broken manifest (bad JSON, wrong `format`, wrong `count`, non-hex digest) is
|
|
not a "verification result" -- there is nothing sensible to compare against,
|
|
so it raises `SealError`. Content problems come back as a problem list.
|
|
|
|
## CLI
|
|
|
|
python3 artifact_seal.py seal DIR [--out FILE] [--prefix P]
|
|
python3 artifact_seal.py seal FILE ... --out FILE
|
|
python3 artifact_seal.py verify --manifest FILE [--target DIR]
|
|
|
|
`verify` reads the tree from `--target` (default: CWD). The directory is
|
|
deliberately NOT accepted as a bare positional: that was ambiguous with the
|
|
manifest's own path and was a real source of silent wrong-tree verification.
|
|
|
|
Exit codes: `0` ok / `1` verification failed or unreadable manifest / `2` usage.
|
|
|
|
## Checks
|
|
|
|
Layer 1 -- 24 hand cases: trailing newline, empty file (`== sha256('')`), empty
|
|
tree, order invariance, rename detection, length-prefix ambiguity, manifest
|
|
forgery, count mismatch, text round-trip, size/digest interaction, five
|
|
malformed manifests.
|
|
|
|
Layer 2 -- 60 randomised trees. Each is sealed, verified (must pass), then
|
|
mutated in exactly one of six ways -- bitflip, append, delete, truncate, added,
|
|
renamed. Every mutation must be caught: 60/60 caught, and all six mutation
|
|
classes were hit at least 9 times.
|
|
|
|
Layer 3 -- 3 mutants of the verifier. Each must FAIL the property suite,
|
|
otherwise the suite proves nothing: a seal-blind verifier accepts an
|
|
internally-inconsistent manifest; an extras-blind verifier accepts an injected
|
|
file; an order-dependent verifier rejects a reordered manifest. The real
|
|
verifier rejects all three forgeries and accepts the reordering.
|
|
|
|
Plus 6 CLI subprocess checks.
|
|
|
|
## Bugs this suite caught (all real, all fixed)
|
|
|
|
1. **CLI positional filter was broken.** `rest.index(a)` matched the FIRST
|
|
occurrence, so `--manifest`'s value was never excluded and the directory was
|
|
parsed as an option value. `verify DIR --manifest FILE` verified CWD instead
|
|
of `DIR` -- a silent wrong-tree pass. Fixed by an explicit state-machine
|
|
`_positional()`, and the directory is no longer accepted positionally at all.
|
|
2. **Size check swallowed the digest check.** `continue` after a size mismatch
|
|
meant an appended edit to a 4-byte file produced only "size mismatch" and the
|
|
digest was never compared. Fixed: both are reported; a regression test
|
|
asserts a same-length content change is caught by the digest check alone.
|
|
|
|
Both were found by running the suite, not by reading the code.
|
|
|
|
## Oracle
|
|
|
|
`verify_artifact_seal_oracle.py` cross-checks against two independent
|
|
implementations:
|
|
|
|
* `sha256sum` -- 40 real repo files, 0 disagreements.
|
|
* `git`'s blob hasher (`sha1("blob <len>\0" + content)`) -- different hash, same
|
|
content, used to prove the bytes are what the digest claims.
|
|
* A one-byte tamper to `wizard-work/null/diffkit/diffkit.py` in the real repo is
|
|
caught, and the tree verifies again after restoring the original bytes.
|