__init__.py (3285B)
1 """File integrity work in tricu, with Python-owned manifests and presentation.""" 2 from dataclasses import dataclass 3 import hashlib 4 from importlib.resources import files 5 import os 6 import re 7 8 from tricu import Library 9 from ._bundle_pin import BUNDLE_SHA256 10 11 DEFAULT_MAX_BYTES = 64 * 1024 * 1024 12 13 14 @dataclass(frozen=True) 15 class FileDigest: 16 bytes: int 17 sha256: str 18 19 20 @dataclass(frozen=True) 21 class Problem: 22 category: str 23 operation: str 24 detail: str 25 progress: str 26 27 28 class FileProblem(Exception): 29 def __init__(self, problem): 30 self.problem = problem 31 super().__init__(f'{problem.category}: {problem.operation}: {problem.detail}') 32 33 34 class ProtocolError(RuntimeError): 35 pass 36 37 38 class FileChecker: 39 """One loaded bundle/context, reused for sequential calls until close().""" 40 def __init__(self): 41 bundle = files(__package__).joinpath('worker.arboricx').read_bytes() 42 if hashlib.sha256(bundle).hexdigest() != BUNDLE_SHA256: 43 raise ProtocolError('packaged worker bundle checksum mismatch') 44 self.context = Library().context() 45 try: 46 self.worker = self.context.load_bundle(bundle, 'hashFile') 47 except BaseException: 48 self.context.close() 49 raise 50 51 def __enter__(self): 52 return self 53 54 def __exit__(self, *_): 55 self.close() 56 57 def close(self): 58 self.context.close() 59 60 def _list(self, scope, value, count): 61 result = [] 62 for _ in range(count): 63 if self.context.kind(value) != 2: 64 raise ProtocolError('worker returned an improper list') 65 result.append(scope.child(value, 0)) 66 value = scope.child(value, 1) 67 if self.context.kind(value) != 0: 68 raise ProtocolError('worker returned excess list fields') 69 return result 70 71 def hash_file(self, path, max_bytes=DEFAULT_MAX_BYTES): 72 if type(max_bytes) is not int or not 0 <= max_bytes <= 2**63-1: 73 raise ValueError('max_bytes must be an integer in 0..2**63-1') 74 path = os.fspath(path) 75 if not isinstance(path, str): 76 raise TypeError('path must be Unicode text') 77 with self.context.scope() as roots: 78 program = roots.apply_many(self.worker, roots.string(path), roots.number(max_bytes)) 79 outcome, result, cleanup = roots.run_io(program) 80 if outcome != 0 or self.context.kind(cleanup) != 0: 81 # Terminal reasons remain opaque. Ordinary file-operation errors 82 # are Results decoded below, independently of task outcomes. 83 raise ProtocolError(f'worker task outcome {outcome} or cleanup diagnostics') 84 tag, payload = self._list(roots, result, 2) 85 if not self.context.to_bool(tag): 86 fields = self._list(roots, payload, 4) 87 raise FileProblem(Problem(*(self.context.to_string(field) for field in fields))) 88 length, digest = self._list(roots, payload, 2) 89 value = FileDigest(self.context.to_number(length), self.context.to_string(digest)) 90 if value.bytes > max_bytes or not re.fullmatch('[0-9a-f]{64}', value.sha256): 91 raise ProtocolError('invalid worker digest result') 92 return value