142 lines
7.2 KiB
Python
Executable File
142 lines
7.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Assemble and sign a versioned local release from a verified offline build pair."""
|
|
import argparse
|
|
import importlib.machinery
|
|
import importlib.util
|
|
import json
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
|
|
from release_artifacts import artifacts, compare, digest, read_lock
|
|
|
|
project = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def packed_tree(source, destination, epoch):
|
|
def normalized(info):
|
|
info.uid = info.gid = 0
|
|
info.uname = info.gname = ''
|
|
info.mtime = epoch
|
|
info.pax_headers = {}
|
|
if not (info.isdir() or info.isfile() or info.issym() or info.islnk()):
|
|
raise ValueError(f'Special file in release source: {info.name}')
|
|
return info
|
|
# Single-threaded Zstandard and ordered normalized tar metadata provide a
|
|
# deterministic archive without loading the input snapshot into memory.
|
|
with destination.open('xb') as output:
|
|
compressor = subprocess.Popen(['zstd', '-q', '-T1', '-10', '-c'], stdin=subprocess.PIPE, stdout=output)
|
|
try:
|
|
with tarfile.open(fileobj=compressor.stdin, mode='w|', format=tarfile.PAX_FORMAT) as archive:
|
|
for path in sorted(source.iterdir()):
|
|
archive.add(path, arcname=path.name, filter=normalized)
|
|
compressor.stdin.close()
|
|
if compressor.wait() != 0:
|
|
raise ValueError('Release archive compression failed')
|
|
finally:
|
|
if compressor.poll() is None:
|
|
compressor.kill()
|
|
compressor.wait()
|
|
|
|
|
|
def run(args):
|
|
snapshot = args.inputs.absolute()
|
|
output = args.output_directory.absolute()
|
|
key = args.key.absolute()
|
|
if key.is_symlink() or not key.is_file():
|
|
raise ValueError('Signing key must be a regular file, not a symlink')
|
|
if output.exists() or output.is_symlink():
|
|
raise ValueError('Release output must be a new directory')
|
|
if key.is_relative_to(output) or key.is_relative_to(snapshot):
|
|
raise ValueError('Signing key must be outside the release and frozen-input directories')
|
|
loader = importlib.machinery.SourceFileLoader('fds_frozen_inputs', str(project / 'tools/frozen-inputs'))
|
|
spec = importlib.util.spec_from_loader(loader.name, loader)
|
|
frozen = importlib.util.module_from_spec(spec)
|
|
loader.exec_module(frozen)
|
|
lock = frozen.verify(snapshot)
|
|
_, lock_hash = read_lock(project)
|
|
if lock_hash != digest(snapshot / 'lock.json'):
|
|
raise ValueError('Current build uses a different frozen input snapshot')
|
|
# Documentation is part of source identity too. Refuse to sign binaries
|
|
# under the identity of a checkout changed after freezing its source.
|
|
with tempfile.TemporaryDirectory(prefix='release-source-check.', dir=project / 'out') as temporary:
|
|
current = Path(temporary)
|
|
for name in frozen.OWNED:
|
|
frozen.copy(project / name, current / 'project' / name)
|
|
if frozen.source_digest(frozen.inventory(current)) != lock['source_sha256']:
|
|
raise ValueError('Current source differs from the frozen release source')
|
|
comparison = compare(project, args.comparison_build)
|
|
if comparison['status'] != 'passed':
|
|
raise ValueError('Independent build differs; run tools/compare-builds for a mismatch report')
|
|
for profile in ('cli', 'development', 'recovery'):
|
|
root = (project / f'out/rootfs-{profile}.tar').resolve(strict=True).parent
|
|
for name in ('build-inputs.sha256', 'fds-tools-inputs.sha256'):
|
|
subprocess.run(['sha256sum', '--quiet', '-c', str(root / name)], cwd=project, check=True)
|
|
files = artifacts(project)
|
|
version, epoch = lock['version'], lock['source_epoch']
|
|
signer = project / 'target/x86_64-unknown-linux-gnu/release/fds-release'
|
|
if not signer.is_file():
|
|
raise ValueError('Build the host signer with make signing first')
|
|
output.mkdir(mode=0o700)
|
|
for name, source in files.items():
|
|
# Rootfs tars remain in the reproducibility record. Their complete
|
|
# contents are already represented in the installable images.
|
|
if name.startswith('rootfs-'):
|
|
continue
|
|
if name.endswith('.xbps'):
|
|
target = name
|
|
elif name.startswith('initramfs.cpio'):
|
|
target = f'fds-initramfs-{version}.cpio' + name.removeprefix('initramfs.cpio')
|
|
else:
|
|
stem, suffix = name.rsplit('.', 1)
|
|
target = f'{stem}-{version}.{suffix}'
|
|
shutil.copyfile(source, output / target)
|
|
packed_tree(snapshot, output / f'fds-build-inputs-{version}.tar.zst', epoch)
|
|
packed_tree(snapshot / 'project', output / f'fds-source-{version}.tar.zst', epoch)
|
|
(output / 'reproducibility.json').write_text(json.dumps(comparison, indent=2) + '\n')
|
|
shutil.copyfile(snapshot / 'lock.json', output / 'build-inputs.json')
|
|
for profile in ('cli', 'development', 'recovery'):
|
|
root = (project / f'out/rootfs-{profile}.tar').resolve(strict=True).parent
|
|
shutil.copyfile(root / 'packages.json', output / f'packages-{profile}.json')
|
|
shutil.copyfile(project / 'docs/releases.md', output / 'RELEASE-VERIFICATION.md')
|
|
shutil.copyfile(project / 'docs/internal-storage.md', output / 'INSTALLATION.md')
|
|
shutil.copyfile(project / 'docs/eeprom.md', output / 'EEPROM.md')
|
|
shutil.copyfile(project / 'tools/release-readme.md', output / 'README.md')
|
|
shutil.copyfile(project / 'LICENSE', output / 'LICENSE')
|
|
subprocess.run([str(signer), 'public-key', str(key), str(output / 'signer.pub')], check=True)
|
|
manifest = {'format': 1, 'version': version, 'source_epoch': epoch,
|
|
'source_sha256': lock['source_sha256'], 'void_commit': lock['void_commit'],
|
|
'hardware_validation': 'deferred', 'files': []}
|
|
for path in sorted(output.iterdir()):
|
|
manifest['files'].append({'name': path.name, 'bytes': path.stat().st_size, 'sha256': digest(path)})
|
|
if len(manifest['files']) > 64:
|
|
raise ValueError('Release exceeds the bounded manifest artifact count')
|
|
(output / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
|
|
subprocess.run([str(signer), 'sign', str(output), '--key', str(key)], check=True)
|
|
subprocess.run([str(signer), 'verify', str(output), '--key', str(output / 'signer.pub')], check=True)
|
|
print(f'PASS: signed and verified local FDS/OS {version} release: {output}')
|
|
print('Hardware validation is deferred. Publishing and hardware installation are separate actions.')
|
|
print('Distribute the public-key fingerprint independently; the bundled key alone does not establish trust.')
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--inputs', type=Path, required=True)
|
|
parser.add_argument('--comparison-build', type=Path, required=True)
|
|
parser.add_argument('--output-directory', type=Path, required=True)
|
|
parser.add_argument('--key', type=Path, required=True)
|
|
args = parser.parse_args()
|
|
if args.comparison_build.resolve() == project:
|
|
parser.error('Comparison must be a second independently restored build tree')
|
|
try:
|
|
run(args)
|
|
except (OSError, ValueError, KeyError, subprocess.CalledProcessError) as error:
|
|
sys.exit(f'ERROR: {error}')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|