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
186 lines
6.8 KiB
Python
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())
|