111 lines
3.4 KiB
Python
111 lines
3.4 KiB
Python
#!/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, skip=None):
|
|
out = []
|
|
skip = set(skip or ())
|
|
for dirpath, _, files in os.walk(root):
|
|
for name in files:
|
|
full = os.path.join(dirpath, name)
|
|
rel = os.path.relpath(full, root)
|
|
if rel in skip:
|
|
continue
|
|
# Never follow the manifest file itself when it lives inside root.
|
|
out.append((rel, sha256_file(full)))
|
|
out.sort(key=lambda x: x[0])
|
|
return out
|
|
|
|
|
|
def cmd_gen(root, out_path):
|
|
skip = set()
|
|
if out_path:
|
|
try:
|
|
skip.add(os.path.relpath(os.path.abspath(out_path), os.path.abspath(root)))
|
|
except ValueError:
|
|
pass
|
|
rows = collect(root, skip=skip)
|
|
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))
|
|
# The manifest itself, when stored inside root, is not an "extra" file.
|
|
try:
|
|
manifest_rel = os.path.relpath(os.path.abspath(manifest_path), os.path.abspath(root))
|
|
except ValueError:
|
|
manifest_rel = None
|
|
skip = {manifest_rel} if manifest_rel and not manifest_rel.startswith("..") else set()
|
|
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, skip=skip):
|
|
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())
|