tricu

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

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