beam: deterministic directory manifest tool (make/check)
This commit is contained in:
1 parent
75669c33fa
commit
edac5cfd64
1 file changed
+107
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic directory manifest: hash every regular file, verify integrity.
|
||||
|
||||
Usage:
|
||||
python3 manifest.py make <root> <out.json>
|
||||
python3 manifest.py check <root> <manifest.json>
|
||||
"""
|
||||
import hashlib, json, os, sys
|
||||
|
||||
|
||||
def _walk(root):
|
||||
entries = []
|
||||
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
|
||||
dirnames.sort()
|
||||
for name in sorted(filenames):
|
||||
full = os.path.join(dirpath, name)
|
||||
if os.path.islink(full):
|
||||
# Record symlink target as a special entry; never follow.
|
||||
entries.append((os.path.relpath(full, root), "symlink", os.readlink(full)))
|
||||
continue
|
||||
if not os.path.isfile(full):
|
||||
continue
|
||||
rel = os.path.relpath(full, root)
|
||||
h = hashlib.sha256()
|
||||
with open(full, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(65536), b""):
|
||||
h.update(chunk)
|
||||
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 make(root, out):
|
||||
root = os.path.abspath(root)
|
||||
entries = _walk(root)
|
||||
manifest = {"root": os.path.basename(root), "entries": []}
|
||||
for e in entries:
|
||||
if e[1] == "symlink":
|
||||
manifest["entries"].append({"path": e[0], "type": "symlink", "target": e[2]})
|
||||
else:
|
||||
manifest["entries"].append({"path": e[0], "type": "file", "sha256": e[2], "size": e[3]})
|
||||
with open(out, "w") as f:
|
||||
json.dump(manifest, f, indent=2, sort_keys=True)
|
||||
f.write("\n")
|
||||
return len(entries)
|
||||
|
||||
|
||||
def check(root, manifest_path):
|
||||
root = os.path.abspath(root)
|
||||
with open(manifest_path) as f:
|
||||
manifest = json.load(f)
|
||||
expected = {e["path"]: e for e in manifest["entries"]}
|
||||
actual_entries = _walk(root)
|
||||
actual = {}
|
||||
for e in actual_entries:
|
||||
if e[1] == "symlink":
|
||||
actual[e[0]] = {"path": e[0], "type": "symlink", "target": e[2]}
|
||||
else:
|
||||
actual[e[0]] = {"path": e[0], "type": "file", "sha256": e[2], "size": e[3]}
|
||||
|
||||
errors = []
|
||||
for p, e in expected.items():
|
||||
if p not in actual:
|
||||
errors.append(f"MISSING: {p}")
|
||||
continue
|
||||
a = actual[p]
|
||||
if a["type"] != e["type"]:
|
||||
errors.append(f"TYPE_CHANGED: {p}")
|
||||
continue
|
||||
if e["type"] == "file":
|
||||
if a["sha256"] != e["sha256"]:
|
||||
errors.append(f"HASH_MISMATCH: {p}")
|
||||
if a["size"] != e["size"]:
|
||||
errors.append(f"SIZE_MISMATCH: {p}")
|
||||
else:
|
||||
if a["target"] != e["target"]:
|
||||
errors.append(f"SYMLINK_TARGET_CHANGED: {p}")
|
||||
for p in actual:
|
||||
if p not in expected:
|
||||
errors.append(f"EXTRA: {p}")
|
||||
return errors
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) < 4:
|
||||
print("usage: manifest.py make|check <root> <file>", file=sys.stderr)
|
||||
return 2
|
||||
cmd, root, mf = argv[1], argv[2], argv[3]
|
||||
if cmd == "make":
|
||||
n = make(root, mf)
|
||||
print(f"manifest written: {n} entries -> {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
|
||||
print("unknown command", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
Reference in new issue
Block a user