redistribution.py (9655B)
1 #!/usr/bin/env python3 2 """Collect source/notice evidence for actual ELF and archive inputs. Fail closed.""" 3 import argparse 4 import hashlib 5 import json 6 from pathlib import Path 7 import re 8 import shutil 9 import tarfile 10 11 from portable import dependencies 12 13 14 def digest(path): 15 with path.open('rb') as stream: 16 return hashlib.file_digest(stream, 'sha256').hexdigest() 17 18 19 def owner(path, components): 20 path = path.resolve(strict=True) 21 matches = [row for row in components 22 if any(path.is_relative_to(Path(root)) for root in row['roots'])] 23 if len(matches) != 1: 24 raise ValueError(f'expected one source owner for {path}, found {[r["name"] for r in matches]}') 25 return matches[0] 26 27 28 def license_name(name): 29 if Path(name).suffix.lower() in ('.hs', '.json', '.sh', '.py', '.c', '.h'): 30 return False 31 return bool(re.match(r'^(copying|licen[cs]es?|copyright|notices?|authors)([._-].*|\d.*)?$', 32 Path(name).name, re.I)) 33 34 35 def notices(source, component): 36 """Preserve named texts and leading C-family copyright/permission blocks. 37 38 Header notices cover vendored implementations whose terms live beside code 39 (for example cryptonite's SHA-256 implementation). Source-only notices are 40 included conservatively and indexed by source component. 41 """ 42 def read_notice(name, stream): 43 if license_name(name): 44 return stream.read() 45 if Path(name).suffix.lower() not in ('.c', '.h', '.cc', '.cpp', '.hpp', '.s'): 46 return None 47 if component.startswith('glibc-') and Path(name).name not in ('crti.S', 'crtn.S', 'start.S', 'abi-note.c', 'static-reloc.c'): 48 return None 49 if component.startswith('gcc-') and not re.search(r'/(libgcc|libatomic|include)/|/gcc/crtstuff\.c$', name): 50 return None 51 if component.startswith('ghc-') and not re.search(r'(^|/)(libraries|rts|includes)/', name): 52 return None 53 data = stream.read(65536) 54 match = re.match(rb'\s*((?:/\*.*?\*/\s*)+)', data, re.S) 55 if match and re.search(rb'copyright|permission|redistribution|SPDX-License', match[1], re.I): 56 return match[1] 57 return None 58 if source.is_dir(): 59 for path in sorted(source.rglob('*')): 60 if path.is_file() and not path.is_symlink(): 61 with path.open('rb') as stream: 62 data = read_notice(str(path), stream) 63 if data: 64 yield str(path.relative_to(source)), data 65 else: 66 with tarfile.open(source, 'r:*') as archive: 67 for member in archive: 68 if member.isfile(): 69 with archive.extractfile(member) as stream: 70 data = read_notice(member.name, stream) 71 if data: 72 yield member.name, data 73 74 75 def copy_input(source, target): 76 if source.is_dir(): 77 shutil.copytree(source, target, symlinks=True) 78 else: 79 shutil.copyfile(source, target) 80 81 82 def main(): 83 p = argparse.ArgumentParser(description=__doc__) 84 for name in ('components', 'host', 'link-evidence', 'source', 'nixpkgs', 'flake-utils', 'systems', 'policy', 'output'): 85 p.add_argument('--'+name, type=Path, required=True) 86 a = p.parse_args() 87 components = json.loads(a.components.read_text()) 88 policy = json.loads(a.policy.read_text())['components'] 89 link_map = (a.link_evidence/'embedding-link.map').read_text() 90 paths = sorted(set(re.findall(r'/nix/store/[^\s()]+?\.(?:a|o)(?=[(\s]|$)', link_map))) 91 if not any(path.endswith('.a') for path in paths): 92 raise ValueError('archive linkage evidence missing') 93 selected = {} 94 inputs = [] 95 for raw in paths: 96 path = Path(raw).resolve(strict=True) 97 component = owner(path, components) 98 selected[component['name']] = component 99 inputs.append(dict(kind='link-input', path=str(path), sha256=digest(path), component=component['name'])) 100 shared = {} 101 for name, path in dependencies(a.host/'lib/libtricu-haskell.so').items(): 102 component = owner(path, components) 103 # System glibc is supplied by the destination OS. The link map selects 104 # the source materials for glibc startup objects. 105 if component['name'].startswith('glibc-'): 106 continue 107 selected[component['name']] = component 108 shared[name] = component['name'] 109 inputs.append(dict(kind='shared-library', soname=name, path=str(path), 110 sha256=digest(path), component=component['name'])) 111 unknown = set(selected) - set(policy) 112 if unknown: 113 raise ValueError(f'license review required for new components: {sorted(unknown)}') 114 a.output.mkdir(parents=True, exist_ok=False) 115 sources = a.output/'sources' 116 sources.mkdir() 117 notice_root = a.output/'notices' 118 notice_root.mkdir() 119 rows = [] 120 for name, component in sorted(selected.items()): 121 if Path(name).name != name: 122 raise ValueError('unsafe component identity') 123 directory = sources/name 124 directory.mkdir() 125 upstream = Path(component['source']) 126 filename = 'upstream' if upstream.is_dir() else upstream.name 127 copy_input(upstream, directory/filename) 128 copied_patches = [] 129 for index, patch in enumerate(component['patches']): 130 destination = f'patch-{index:03d}-'+Path(patch).name 131 copy_input(Path(patch), directory/destination) 132 copied_patches.append(destination) 133 if component['cabalRevision']: 134 shutil.copyfile(component['cabalRevision'], directory/'revised.cabal') 135 (directory/'recipe.json').write_text(json.dumps(component, indent=2, sort_keys=True)+'\n') 136 files = [] 137 unique = {} 138 for index, (original, data) in enumerate(notices(upstream, name)): 139 # Preserve source path in the manifest; numbered names avoid collisions 140 # and prevent arbitrary paths in upstream archive names being written. 141 folder = notice_root/name 142 folder.mkdir(exist_ok=True) 143 checksum = hashlib.sha256(data).hexdigest() 144 destination = unique.setdefault(checksum, folder/f'{index:04d}-{Path(original).name}.txt') 145 if not destination.exists(): 146 destination.write_bytes(data) 147 files.append(dict(source_path=original, file=str(destination.relative_to(a.output)), 148 sha256=checksum)) 149 if not files: 150 raise ValueError(f'no license/notice texts found for {name}') 151 for required in policy[name]['required_notices']: 152 if not any(row['source_path'] == required or row['source_path'].endswith('/'+required) for row in files): 153 raise ValueError(f'missing required notice: {name}: {required}') 154 rows.append(dict(name=name, version=component['version'], homepage=component['homepage'], 155 terms=policy[name]['terms'], 156 license_metadata=component['licenseMetadata'], upstream_file=filename, 157 patches=copied_patches, notices=files)) 158 # Complete authored build recipes, lock, overlays and pinned Nixpkgs recipes 159 # accompany upstream sources/patches for inspection and rebuilding. 160 copy_input(a.source, sources/'tricu') 161 copy_input(a.nixpkgs, sources/'nixpkgs') 162 copy_input(a.flake_utils, sources/'flake-utils') 163 copy_input(a.systems, sources/'systems') 164 shutil.copytree(a.link_evidence, a.output/'link-evidence') 165 shutil.copyfile(a.policy, a.output/'redistribution-policy.json') 166 shutil.copyfile(a.source/'LICENSE', a.output/'LICENSE') 167 shutil.copyfile(a.source/'ext/haskell/REDISTRIBUTION.md', a.output/'REDISTRIBUTION.md') 168 report = dict(schema='tricu.redistribution.v1', components=rows, inputs=inputs, 169 shared_owners=shared, 170 host_sha256=digest(a.host/'lib/libtricu-haskell.so'), 171 coverage='Conservative archive/object input coverage, including discarded members; shared runtime inputs') 172 (a.output/'components.json').write_text(json.dumps(report, indent=2, sort_keys=True)+'\n') 173 index = ['# Third-party notices', '', 174 'tricu is licensed under ISC; see LICENSE. The following source distributions', 175 'supply linked code or bundled native libraries. Complete upstream notice', 176 'texts are retained conservatively, including source-only subcomponents.', 177 'The upstream texts govern licensing; Nix metadata provides an index.', '', 178 'Matching source materials and build recipes accompany this release.', ''] 179 for row in rows: 180 index += [f'## {row["name"]}', row['homepage'], '', row['terms'], '', 181 f'License/notice texts: `notices/{row["name"]}/`.', 182 f'Source and patch inputs: `sources/{row["name"]}/`.', ''] 183 (a.output/'THIRD-PARTY-NOTICES.md').write_text('\n'.join(index)+'\n') 184 (a.output/'license-components.json').write_text(json.dumps(rows, indent=2, sort_keys=True)+'\n') 185 hashes = {str(path.relative_to(a.output)): digest(path) 186 for path in sorted(a.output.rglob('*')) if path.is_file() and not path.is_symlink()} 187 (a.output/'SHA256.json').write_text(json.dumps(hashes, indent=2, sort_keys=True)+'\n') 188 links = {str(path.relative_to(a.output)): str(path.readlink()) 189 for path in sorted(a.output.rglob('*')) if path.is_symlink()} 190 (a.output/'SYMLINKS.json').write_text(json.dumps(links, indent=2, sort_keys=True)+'\n') 191 192 193 if __name__ == '__main__': 194 main()