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