diff --git a/wizard-work/beam/sha256_manifest.py b/wizard-work/beam/sha256_manifest.py new file mode 100644 index 0000000..144b8d5 --- /dev/null +++ b/wizard-work/beam/sha256_manifest.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Generate and verify a SHA-256 manifest for a directory of files.""" +import argparse +import hashlib +import os +import sys + + +def sha256_file(path): + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + h.update(chunk) + return h.hexdigest() + + +def collect(root): + out = [] + for dirpath, _, files in os.walk(root): + for name in files: + full = os.path.join(dirpath, name) + rel = os.path.relpath(full, root) + out.append((rel, sha256_file(full))) + out.sort(key=lambda x: x[0]) + return out + + +def cmd_gen(root, out_path): + rows = collect(root) + lines = [f"{digest} {rel}" for rel, digest in rows] + text = "\n".join(lines) + ("\n" if lines else "") + if out_path: + with open(out_path, "w", encoding="utf-8") as f: + f.write(text) + else: + sys.stdout.write(text) + return 0 + + +def parse_manifest(path): + entries = [] + with open(path, "r", encoding="utf-8") as f: + for lineno, raw in enumerate(f, 1): + line = raw.rstrip("\n") + if not line.strip(): + continue + parts = line.split(" ", 1) + if len(parts) != 2: + raise ValueError(f"bad manifest line {lineno}: {line!r}") + digest, rel = parts + if len(digest) != 64: + raise ValueError(f"bad digest on line {lineno}") + entries.append((rel, digest)) + return entries + + +def cmd_verify(root, manifest_path): + expected = dict(parse_manifest(manifest_path)) + failures = [] + for rel, digest in expected.items(): + full = os.path.join(root, rel) + if not os.path.isfile(full): + failures.append(f"MISSING {rel}") + continue + got = sha256_file(full) + if got != digest: + failures.append(f"MISMATCH {rel}\n expected {digest}\n got {got}") + for rel, _ in collect(root): + if rel not in expected: + failures.append(f"EXTRA {rel}") + if failures: + sys.stderr.write("verification FAILED:\n" + "\n".join(failures) + "\n") + return 1 + print(f"OK: {len(expected)} files verified") + return 0 + + +def main(argv=None): + p = argparse.ArgumentParser() + sub = p.add_subparsers(dest="cmd", required=True) + g = sub.add_parser("gen") + g.add_argument("root") + g.add_argument("--out", default=None) + v = sub.add_parser("verify") + v.add_argument("root") + v.add_argument("manifest") + args = p.parse_args(argv) + if args.cmd == "gen": + return cmd_gen(args.root, args.out) + return cmd_verify(args.root, args.manifest) + + +if __name__ == "__main__": + sys.exit(main())