tricu

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

verify_redistribution.py (6271B)


      1 #!/usr/bin/env python3
      2 """Verify complete source/notice manifests and their pairing with a runtime tree."""
      3 import argparse
      4 import hashlib
      5 import json
      6 import tarfile
      7 from pathlib import Path, PurePosixPath
      8 
      9 
     10 def digest(path):
     11     with path.open('rb') as stream:
     12         return hashlib.file_digest(stream, 'sha256').hexdigest()
     13 
     14 
     15 def relative(root, name):
     16     path = PurePosixPath(name)
     17     if path.is_absolute() or '..' in path.parts:
     18         raise ValueError('unsafe manifest path')
     19     result = root.joinpath(*path.parts)
     20     if not result.resolve().is_relative_to(root.resolve()):
     21         raise ValueError('manifest path escapes its root')
     22     return result
     23 
     24 
     25 def verify(materials, runtime=None):
     26     hashes = json.loads((materials/'SHA256.json').read_text())
     27     links = json.loads((materials/'SYMLINKS.json').read_text())
     28     paths = list(materials.rglob('*'))
     29     actual = {str(p.relative_to(materials)) for p in paths if p.is_file() and not p.is_symlink()}
     30     if actual != set(hashes) | {'SHA256.json', 'SYMLINKS.json'}:
     31         raise ValueError('source file inventory differs')
     32     actual_links = {str(p.relative_to(materials)): str(p.readlink()) for p in paths if p.is_symlink()}
     33     if actual_links != links:
     34         raise ValueError('source symlink inventory differs')
     35     for name, expected in hashes.items():
     36         path = relative(materials, name)
     37         if path.is_symlink() or digest(path) != expected:
     38             raise ValueError(f'changed source/notice file: {name}')
     39     components = json.loads((materials/'components.json').read_text())
     40     for component in components['components']:
     41         directory = relative(materials/'sources', component['name'])
     42         if not relative(directory, component['upstream_file']).exists():
     43             raise ValueError('missing upstream source')
     44         if not component['notices']:
     45             raise ValueError('missing component notices')
     46         for notice in component['notices']:
     47             if digest(relative(materials, notice['file'])) != notice['sha256']:
     48                 raise ValueError('notice hash differs')
     49     if runtime:
     50         metadata = json.loads((runtime/'share/tricu/portable.json').read_text())
     51         if metadata['source_manifest_sha256'] != digest(materials/'SHA256.json'):
     52             raise ValueError('runtime/source pairing differs')
     53         if metadata['source_symlinks_sha256'] != digest(materials/'SYMLINKS.json'):
     54             raise ValueError('runtime/source symlink pairing differs')
     55         for name, row in metadata['libraries'].items():
     56             if digest(relative(runtime/'lib', name)) != row['sha256']:
     57                 raise ValueError('runtime library hash differs')
     58         expected_notices = {str(path.relative_to(materials/'notices')): digest(path)
     59                             for path in (materials/'notices').rglob('*') if path.is_file()}
     60         installed_notices = {str(path.relative_to(runtime/'share/tricu/notices')): digest(path)
     61                              for path in (runtime/'share/tricu/notices').rglob('*') if path.is_file()}
     62         if expected_notices != installed_notices:
     63             raise ValueError('runtime notice inventory differs')
     64         for name in ('THIRD-PARTY-NOTICES.md', 'REDISTRIBUTION.md', 'license-components.json'):
     65             if digest(materials/name) != digest(runtime/'share/tricu'/name):
     66                 raise ValueError('runtime license documentation differs')
     67     return dict(components=len(components['components']), source_files=len(hashes),
     68                 symbolic_links=len(links), runtime_paired=runtime is not None)
     69 
     70 
     71 def verify_archive(archive, prefix, hashes, links):
     72     observed, observed_links = {}, {}
     73     with tarfile.open(archive, 'r|gz') as stream:
     74         for member in stream:
     75             path = PurePosixPath(member.name)
     76             if path.is_absolute() or '..' in path.parts or not path.parts or path.parts[0] != prefix:
     77                 raise ValueError('unsafe release archive path')
     78             name = str(PurePosixPath(*path.parts[1:]))
     79             if member.isdir():
     80                 continue
     81             if name in observed or name in observed_links:
     82                 raise ValueError('duplicate archive member')
     83             if member.issym():
     84                 observed_links[name] = member.linkname
     85             elif member.isfile():
     86                 with stream.extractfile(member) as contents:
     87                     observed[name] = hashlib.file_digest(contents, 'sha256').hexdigest()
     88             else:
     89                 raise ValueError('unsupported release archive member')
     90     if observed != hashes or observed_links != links:
     91         raise ValueError('release archive contents differ from verified material')
     92 
     93 
     94 def main():
     95     p = argparse.ArgumentParser(description=__doc__)
     96     p.add_argument('--materials', type=Path, required=True)
     97     p.add_argument('--runtime', type=Path)
     98     p.add_argument('--archives', type=Path)
     99     a = p.parse_args()
    100     report = verify(a.materials, a.runtime)
    101     if a.archives:
    102         hashes = json.loads((a.materials/'SHA256.json').read_text())
    103         hashes.update({name: digest(a.materials/name) for name in ('SHA256.json', 'SYMLINKS.json')})
    104         verify_archive(a.archives/'tricu-source-materials.tar.gz', 'tricu-source-materials', hashes,
    105                        json.loads((a.materials/'SYMLINKS.json').read_text()))
    106         runtime_hashes = {str(path.relative_to(a.runtime)): digest(path)
    107                           for path in a.runtime.rglob('*') if path.is_file() and not path.is_symlink()}
    108         verify_archive(a.archives/'tricu-linux-x86_64.tar.gz', 'tricu', runtime_hashes, {})
    109         report['archive_contents_verified'] = True
    110         report['schema'] = 'tricu.redistribution-release.v1'
    111         report['archives'] = {name: dict(sha256=digest(a.archives/name), bytes=(a.archives/name).stat().st_size)
    112                               for name in ('tricu-linux-x86_64.tar.gz', 'tricu-source-materials.tar.gz')}
    113         report['source_manifest_sha256'] = digest(a.materials/'SHA256.json')
    114         report['source_symlinks_sha256'] = digest(a.materials/'SYMLINKS.json')
    115         (a.archives/'release.json').write_text(json.dumps(report, indent=2, sort_keys=True)+'\n')
    116     print(json.dumps(report, sort_keys=True))
    117 
    118 
    119 if __name__ == '__main__':
    120     main()