"""Read-only manifest generation and verification for an owner-supplied folder. Usage: python manifest_tool.py make ROOT OUTSIDE_MANIFEST python manifest_tool.py verify ROOT OUTSIDE_MANIFEST Symlinks are refused. Keep the manifest outside ROOT. No files are repaired. """ import argparse import hashlib import json from pathlib import Path def inventory(root): root = Path(root) if root.is_symlink(): raise ValueError('Root symlinks require separate explicit scope') root = root.resolve() if not root.is_dir(): raise ValueError('Supply an existing directory') result = {} for path in sorted(root.rglob('*')): if path.is_symlink(): raise ValueError('Symlinks require separate explicit scope') if path.is_file(): with path.open('rb') as stream: digest = hashlib.file_digest(stream, 'sha256').hexdigest() result[path.relative_to(root).as_posix()] = { 'bytes': path.stat().st_size, 'sha256': digest} return result def compare(expected, actual): return { 'missing': sorted(expected.keys() - actual.keys()), 'unexpected': sorted(actual.keys() - expected.keys()), 'changed': sorted(k for k in expected.keys() & actual.keys() if expected[k] != actual[k]), } if __name__ == '__main__': parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('action', choices=['make', 'verify']) parser.add_argument('root', type=Path) parser.add_argument('manifest', type=Path) args = parser.parse_args() if args.manifest.resolve().is_relative_to(args.root.resolve()): parser.error('Manifest must be outside the inventoried folder') actual = inventory(args.root) if args.action == 'make': # Exclusive creation: an existing manifest must not be overwritten. with args.manifest.open('x') as out: json.dump(actual, out, indent=2) out.write('\n') else: result = compare(json.loads(args.manifest.read_text()), actual) print(json.dumps(result, indent=2)) raise SystemExit(1 if any(result.values()) else 0)