cli.py (5286B)
1 """Record/check JSON manifests; file bytes are read and hashed only by tricu.""" 2 import argparse 3 from dataclasses import asdict 4 import json 5 import os 6 from pathlib import Path 7 import re 8 import sys 9 import tempfile 10 11 from . import FileChecker, FileProblem, DEFAULT_MAX_BYTES 12 13 SCHEMA = 'tricu.filecheck.v1' 14 MAX_MANIFEST_BYTES = 1024 * 1024 15 MAX_FILES = 4096 16 17 18 def path_text(text): 19 if not isinstance(text, str) or not text or '\0' in text or Path(text).is_absolute(): 20 raise ValueError('file paths must be nonempty relative Unicode paths without NUL') 21 text.encode('utf8') 22 return text 23 24 25 def unique_object(pairs): 26 value = {} 27 for key, item in pairs: 28 if key in value: 29 raise ValueError('duplicate JSON key: '+key) 30 value[key] = item 31 return value 32 33 34 def validate_manifest(value): 35 if not isinstance(value, dict) or set(value) != {'schema', 'algorithm', 'files'} or value['schema'] != SCHEMA or value['algorithm'] != 'sha256': 36 raise ValueError('unsupported manifest schema') 37 if not isinstance(value['files'], list) or not 1 <= len(value['files']) <= MAX_FILES: 38 raise ValueError(f'manifest must contain 1..{MAX_FILES} files') 39 seen = set() 40 for entry in value['files']: 41 if not isinstance(entry, dict) or set(entry) != {'path', 'bytes', 'sha256'}: 42 raise ValueError('invalid manifest entry') 43 name = path_text(entry['path']) 44 if name in seen: 45 raise ValueError('duplicate manifest path: '+name) 46 seen.add(name) 47 if type(entry['bytes']) is not int or not 0 <= entry['bytes'] <= 2**63-1: 48 raise ValueError('invalid manifest byte count') 49 if not isinstance(entry['sha256'], str) or not re.fullmatch('[0-9a-f]{64}', entry['sha256']): 50 raise ValueError('invalid manifest SHA-256') 51 return value 52 53 54 def load_manifest(path): 55 with path.open('rb') as stream: 56 payload = stream.read(MAX_MANIFEST_BYTES+1) 57 if len(payload) > MAX_MANIFEST_BYTES: 58 raise ValueError('manifest exceeds 1 MiB') 59 return validate_manifest(json.loads(payload.decode('utf8'), object_pairs_hook=unique_object)) 60 61 62 def publish_manifest(path, manifest): 63 payload = (json.dumps(manifest, ensure_ascii=False, indent=2)+'\n').encode('utf8') 64 if len(payload) > MAX_MANIFEST_BYTES: 65 raise ValueError('manifest exceeds 1 MiB') 66 # Complete file publication with no replacement. Errors never truncate an 67 # existing manifest. Hard-link support on this filesystem is required. 68 with tempfile.NamedTemporaryFile(dir=path.parent, prefix='.filecheck-') as stage: 69 stage.write(payload) 70 stage.flush() 71 os.fsync(stage.fileno()) 72 os.link(stage.name, path) 73 74 75 def inspect_files(checker, parent, entries, limit, verify): 76 rows = [] 77 for entry in entries: 78 name = entry['path'] 79 try: 80 actual = asdict(checker.hash_file(parent/name, limit)) 81 if verify: 82 expected = {key: entry[key] for key in ('bytes', 'sha256')} 83 rows.append(dict(path=name, status='ok' if actual == expected else 'changed', expected=expected, actual=actual)) 84 else: 85 rows.append(dict(path=name, status='ok', **actual)) 86 except FileProblem as error: 87 rows.append(dict(path=name, status='error', error=asdict(error.problem))) 88 return rows 89 90 91 def main(): 92 parser = argparse.ArgumentParser(description=__doc__) 93 subparsers = parser.add_subparsers(dest='command', required=True) 94 for command in ('record', 'verify'): 95 sub = subparsers.add_parser(command) 96 sub.add_argument('manifest', type=Path) 97 sub.add_argument('--max-bytes', type=int, default=DEFAULT_MAX_BYTES) 98 if command == 'record': 99 sub.add_argument('paths', nargs='+', help='paths relative to the manifest directory') 100 args = parser.parse_args() 101 try: 102 if not 0 <= args.max_bytes <= 2**63-1: 103 raise ValueError('max-bytes must be in 0..2**63-1') 104 manifest = args.manifest.absolute() 105 if args.command == 'record': 106 names = [path_text(name) for name in args.paths] 107 if len(names) > MAX_FILES or len(names) != len(set(names)): 108 raise ValueError(f'provide 1..{MAX_FILES} distinct paths') 109 entries = [dict(path=name) for name in names] 110 else: 111 entries = load_manifest(manifest)['files'] 112 with FileChecker() as checker: 113 rows = inspect_files(checker, manifest.parent, entries, args.max_bytes, args.command == 'verify') 114 code = 2 if any(row['status'] == 'error' for row in rows) else 1 if any(row['status'] == 'changed' for row in rows) else 0 115 if args.command == 'record' and code == 0: 116 output = dict(schema=SCHEMA, algorithm='sha256', files=[{key: row[key] for key in ('path', 'bytes', 'sha256')} for row in rows]) 117 publish_manifest(manifest, validate_manifest(output)) 118 print(json.dumps(dict(ok=code == 0, files=rows), ensure_ascii=False)) 119 return code 120 except (OSError, ValueError, RuntimeError) as error: 121 print(json.dumps(dict(ok=False, error=str(error)), ensure_ascii=True)) 122 return 2 123 124 125 if __name__ == '__main__': 126 sys.exit(main())