Files
troupe-standup/wizard-work/spark/json_sha256_validator.py
T
volt 844c55a856 volt: Q80 -- json_sha256_validator hashes the bytes it parsed, refuses NaN, splits rc 1/2
Q80 (spark lane by authorship, unclaimed and taken). Pre-repair blob at e8a916f:
sha256 49f0f3e2, md5 67c34c9c, 60 lines -- two opens per input (digest of the
second, validated document A, printed hash of B), NaN and Infinity rc 0 VALID,
duplicate members silently collapsed, and a missing path / directory / syntax
error all rc 1.

Repaired: 185 lines, sha256 26cb270c. One read serves both halves
(Path.read_bytes once, same bytes hashed and parsed), one decode (utf-8-sig;
BOM tolerated and documented, other encodings rc 1), parse_constant refuses
NaN/Infinity (-Infinity) with the RFC 8259 s6 citation, object_pairs_hook
refuses duplicate member names unless --allow-duplicates, rc 0 valid / 1 not
JSON / 2 could not grade with no verdict and no digest printed for any input,
and --results writes the invocation plus each input's blob sha.

Cells: wizard-work/spark/test_json_sha256_validator.py (new), 15 cells,
sha256 0d2038ea -- "Ran 15 tests ... OK", rc 0. Five mutants, control green in
the same run: M1 parse_constant removed 2 failures; M2 pre-repair blob 11
failures + 2 errors; M2b a second read put back 1 failure ("subject read 2
times: {'read_bytes': 2, 'open': 0}"); M3 ungradable as rc 1 4 failures; M4
duplicates accepted with the flag off 1 failure.

Wiring: wizard-work/spark/CHECK leg 13. bash CHECK in this tree -> VERIFIED,
rc 0; the same gate in a same-depth scratch tree with the pre-repair module
restored -> rc 13.

Gate: python3 tools/verify.py --repo . before landing reads 9 legs, 8 pass,
1 fault -- path-claims names this commit's own new file as untracked, which
the commit clears; re-read after landing is in the tick report.

CHECK: python3 wizard-work/spark/test_json_sha256_validator.py
2026-10-30 12:54:41 +00:00

186 lines
6.8 KiB
Python

#!/usr/bin/env python3
"""Validate JSON documents and print the SHA-256 of the exact bytes parsed.
One read serves both halves of the verdict: the bytes that are hashed are the
bytes that were parsed, so a file rewritten between the parse and the digest
cannot be described as "valid" while a different document's hash is printed
(Q80 measured the two-open form: two reads, and the printed digest belonged to
the second one).
Contract -- the repo's tools/ exit codes:
0 every input is a strict RFC 8259 JSON text; one ``VALID <path> SHA256=``
line per input.
1 every input was readable and at least one is not a JSON text: a syntax
error, a bare ``NaN`` / ``Infinity`` / ``-Infinity`` (RFC 8259 s6: "Numeric
values that cannot be represented in the grammar below (such as Infinity
and NaN) are not permitted" -- rejected here through ``parse_constant``),
a duplicate object member name (s4 says names SHOULD be unique; refused
unless ``--allow-duplicates``), or bytes that are not decodable JSON text.
2 could not grade: a usage error, or an input that could not be read
(missing, a directory, permission denied, a non-directory in the path).
Nothing is printed for *any* input when any input is ungradable -- a
substrate failure never wears a verdict, and no digest is emitted.
A leading UTF-8 byte order mark is tolerated: the parsed text is the bytes
decoded as ``utf-8-sig`` (RFC 8259 s8.1 lets a parser ignore a BOM, and the
lane's ``jsonl_validate.py`` already reads ``utf-8-sig``). Any other encoding --
UTF-16, UTF-32, Latin-1, binary -- is not a JSON *text* in the interchange
encoding RFC 8259 s8.1 requires, and grades rc 1 as undecodable. Both are stated
so neither is filed as a bug. ``--results PATH`` writes the run as JSON: the
exact invocation, and each input's path, size, blob sha256 and verdict.
"""
import argparse
import hashlib
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
RC_OK = 0
RC_INVALID = 1
RC_UNGRADABLE = 2
VALID = "valid"
INVALID = "invalid"
UNGRADABLE = "ungradable"
class _NonStandardConstant(ValueError):
"""NaN / Infinity / -Infinity: not JSON (RFC 8259 s6)."""
class _DuplicateKeys(ValueError):
"""An object repeats a member name (RFC 8259 s4: names SHOULD be unique)."""
def _reject_constant(name):
raise _NonStandardConstant(
f"non-standard constant {name!r}: RFC 8259 s6 forbids NaN and Infinity"
)
def _pairs_hook(pairs):
seen = set()
for name, _value in pairs:
if name in seen:
raise _DuplicateKeys(
f"duplicate object member name {name!r}: RFC 8259 s4 says names "
"SHOULD be unique (pass --allow-duplicates to accept them)"
)
seen.add(name)
return dict(pairs)
def read_bytes(path):
"""Return (bytes, None) or (None, message). One read, no second open."""
try:
return Path(path).read_bytes(), None
except OSError as exc:
detail = exc.strerror or str(exc) or type(exc).__name__
return None, f"{type(exc).__name__}: {detail}"
def parse_bytes(data, allow_duplicates=False):
"""Return (parsed, None) or (None, message). Parses the bytes it is given.
One decode (``utf-8-sig``: a BOM is tolerated, no other encoding is), then
one parse of that text, so the digest taken beside this call covers exactly
the bytes this function read.
"""
try:
text = data.decode("utf-8-sig")
except UnicodeDecodeError as exc:
return None, f"not decodable JSON text: {exc.reason} at byte {exc.start}"
try:
if allow_duplicates:
return json.loads(text, parse_constant=_reject_constant), None
return json.loads(text, parse_constant=_reject_constant,
object_pairs_hook=_pairs_hook), None
except ValueError as exc:
return None, str(exc)
def grade(path, allow_duplicates=False):
"""Grade one input. Returns a record; the digest covers the parsed bytes."""
record = {"path": str(path), "bytes": None, "sha256": None,
"verdict": None, "error": None}
data, read_error = read_bytes(path)
if data is None:
record["verdict"] = UNGRADABLE
record["error"] = read_error
return record
record["bytes"] = len(data)
record["sha256"] = hashlib.sha256(data).hexdigest()
_, parse_error = parse_bytes(data, allow_duplicates=allow_duplicates)
if parse_error is None:
record["verdict"] = VALID
else:
record["verdict"] = INVALID
record["error"] = parse_error
return record
def _write_results(path, argv, records):
payload = {
"tool": Path(__file__).name,
"invocation": [sys.executable, str(Path(__file__).resolve())] + list(argv),
"argv": list(argv),
"cwd": os.getcwd(),
"generated_at_utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"inputs": records,
}
try:
Path(path).write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8")
except OSError as exc:
return f"{type(exc).__name__}: {exc.strerror or exc}"
return None
def main(argv=None):
ap = argparse.ArgumentParser(
prog="json_sha256_validator.py",
description="Validate JSON and print the SHA-256 of the parsed bytes.",
)
ap.add_argument("inputs", nargs="+", metavar="FILE",
help="JSON document(s) to validate")
ap.add_argument("--allow-duplicates", action="store_true",
help="accept repeated object member names (last wins)")
ap.add_argument("--results", metavar="PATH",
help="write the run -- invocation, per-input blob sha, verdict -- as JSON")
try:
args = ap.parse_args(argv)
except SystemExit as exc:
return RC_UNGRADABLE if exc.code not in (0, None) else RC_OK
records = [grade(p, allow_duplicates=args.allow_duplicates) for p in args.inputs]
ungradable = [r for r in records if r["verdict"] == UNGRADABLE]
if ungradable:
for r in ungradable:
print(f"UNGRADABLE {r['path']} ERROR={r['error']}")
print(f"cannot grade: {len(ungradable)} of {len(records)} input(s) unreadable; "
"no verdict and no digest printed")
return RC_UNGRADABLE
if args.results:
error = _write_results(args.results, argv if argv is not None else sys.argv[1:],
records)
if error is not None:
print(f"UNGRADABLE --results ERROR={error}")
return RC_UNGRADABLE
for r in records:
if r["verdict"] == VALID:
print(f"VALID {r['path']} SHA256={r['sha256']}")
else:
print(f"INVALID {r['path']} ERROR={r['error']}")
return RC_OK if all(r["verdict"] == VALID for r in records) else RC_INVALID
if __name__ == "__main__":
sys.exit(main())