#!/usr/bin/env python3 """Deterministic directory manifest: hash every regular file, verify integrity. Usage: python3 manifest.py make python3 manifest.py check Exit codes (the lane contract): 0 clean -- make wrote the manifest; check found the tree matches it 1 fault -- check ran and graded a mismatch (missing, extra, hash, size, type change, symlink-target change) 2 ungradable -- bad usage, a root that is missing or is not a directory, an unreadable or corrupt manifest, or a manifest that could not be written; no verdict exists, so no 0 and no 1 Canonical: this file owns the directory-manifest surface for the lane, including the file hash and size it shares with `sha256_manifest.py`. `sha256_manifest.py` is retained only for the sha256sum-compatible `digest path` line format; use it when a consumer needs that text shape, and this file for anything else. """ import hashlib import json import os import sys class Ungradable(Exception): """Input that cannot be graded: no verdict exists, so the exit code is 2.""" def _require_root(root): root = os.path.abspath(root) if not os.path.isdir(root): raise Ungradable("root does not exist or is not a directory: %s" % root) return root def _output_relpath(root, out): """Relpath of a manifest that lives inside root, else None.""" if not out: return None rel = os.path.relpath(os.path.abspath(out), root) if rel == os.pardir or rel.startswith(os.pardir + os.sep): return None return rel def _walk(root, skip=()): """Sorted entries for every file and symlink under root, never following one. A symlinked directory is recorded as a `symlink` entry and is not descended into, so adding or removing one after `make` is a graded difference. Paths in `skip` (the manifest's own output, when it is stored inside root) are ignored. """ entries = [] skip = set(skip) def _raise(exc): raise Ungradable("cannot read %s: %s" % (exc.filename, exc.strerror or exc)) for dirpath, dirnames, filenames in os.walk(root, onerror=_raise, followlinks=False): dirnames.sort() keep = [] for name in dirnames: full = os.path.join(dirpath, name) if os.path.islink(full): entries.append((os.path.relpath(full, root), "symlink", os.readlink(full))) else: keep.append(name) dirnames[:] = keep for name in sorted(filenames): full = os.path.join(dirpath, name) rel = os.path.relpath(full, root) if rel in skip: continue if os.path.islink(full): # Record the symlink target as a special entry; never follow. entries.append((rel, "symlink", os.readlink(full))) continue if not os.path.isfile(full): continue h = hashlib.sha256() try: with open(full, "rb") as f: for chunk in iter(lambda: f.read(65536), b""): h.update(chunk) except OSError as exc: raise Ungradable("cannot read %s: %s" % (rel, exc.strerror or exc)) st = os.stat(full, follow_symlinks=False) entries.append((rel, "file", h.hexdigest(), st.st_size)) entries.sort(key=lambda e: e[0]) return entries def _entries_to_dicts(entries): out = [] for e in entries: if e[1] == "symlink": out.append({"path": e[0], "type": "symlink", "target": e[2]}) else: out.append({"path": e[0], "type": "file", "sha256": e[2], "size": e[3]}) return out def _load_manifest(path): try: with open(path, "rb") as f: manifest = json.load(f) except OSError as exc: raise Ungradable("cannot read manifest %s: %s" % (path, exc.strerror or exc)) except ValueError as exc: # json.JSONDecodeError and non-UTF-8 both land here raise Ungradable("manifest is not valid JSON: %s: %s" % (path, exc)) if not isinstance(manifest, dict): raise Ungradable("manifest is not a JSON object: %s" % path) rows = manifest.get("entries") if not isinstance(rows, list): raise Ungradable("manifest has no entries list: %s" % path) for i, row in enumerate(rows): if not isinstance(row, dict) or "path" not in row or "type" not in row: raise Ungradable("manifest entry %d is malformed: %s" % (i, path)) if row["type"] not in ("file", "symlink"): raise Ungradable("manifest entry %d has unknown type %r: %s" % (i, row["type"], path)) if row["type"] == "file" and ("sha256" not in row or "size" not in row): raise Ungradable("manifest entry %d lacks sha256/size: %s" % (i, path)) if row["type"] == "symlink" and "target" not in row: raise Ungradable("manifest entry %d lacks target: %s" % (i, path)) return manifest def make(root, out): root = _require_root(root) skip = _output_relpath(root, out) entries = _walk(root, skip=[skip] if skip else ()) manifest = {"root": os.path.basename(root), "entries": _entries_to_dicts(entries)} try: with open(out, "w") as f: json.dump(manifest, f, indent=2, sort_keys=True) f.write("\n") except OSError as exc: raise Ungradable("cannot write manifest %s: %s" % (out, exc.strerror or exc)) return len(entries) def check(root, manifest_path): root = _require_root(root) manifest = _load_manifest(manifest_path) skip = _output_relpath(root, manifest_path) expected = {row["path"]: row for row in manifest["entries"]} actual = {row["path"]: row for row in _entries_to_dicts(_walk(root, skip=[skip] if skip else ()))} errors = [] for p, e in expected.items(): if p not in actual: errors.append("MISSING: %s" % p) continue a = actual[p] if a["type"] != e["type"]: errors.append("TYPE_CHANGED: %s" % p) continue if e["type"] == "file": if a["sha256"] != e["sha256"]: errors.append("HASH_MISMATCH: %s" % p) if a["size"] != e["size"]: errors.append("SIZE_MISMATCH: %s" % p) else: if a["target"] != e["target"]: errors.append("SYMLINK_TARGET_CHANGED: %s" % p) for p in actual: if p not in expected: errors.append("EXTRA: %s" % p) return errors def main(argv): if len(argv) < 4: print("usage: manifest.py make|check ", file=sys.stderr) return 2 cmd, root, mf = argv[1], argv[2], argv[3] try: if cmd == "make": n = make(root, mf) print("manifest written: %d entries -> %s" % (n, mf)) return 0 if cmd == "check": errs = check(root, mf) if errs: for e in errs: print(e) return 1 print("OK: manifest matches") return 0 except Ungradable as exc: print("ungradable: %s" % exc, file=sys.stderr) return 2 print("unknown command", file=sys.stderr) return 2 if __name__ == "__main__": sys.exit(main(sys.argv))