tricu

A lazy functional language based on Tree Calculus
Log | Files | Refs | README | LICENSE

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())