#!/usr/bin/env python3 """Build reproducible, passive Ed25519-signed runtime release artifacts.""" import argparse import json import os from pathlib import Path import runpy import shutil import stat import subprocess import sys import tempfile import zipfile ENGINE = runpy.run_path(str(Path(__file__).resolve().parents[1] / 'hydra/update_engine.py')) def build(source, output, private_key, version, sequence): source, output, private_key = Path(source).resolve(), Path(output).absolute(), Path(private_key).absolute() if private_key.is_symlink() or not private_key.is_file(): raise ValueError('Private key must be an existing regular file outside Git') info = private_key.stat() if info.st_uid != os.geteuid() or info.st_mode & 0o077: raise ValueError('Private key must be user-owned and private (0600)') git = subprocess.run(['git', '-C', str(private_key.parent), 'rev-parse', '--show-toplevel'], capture_output=True, text=True) if private_key.is_relative_to(source) or git.returncode == 0: raise ValueError('Private key must remain outside Git and the source/build context') commit = subprocess.run(['git', '-C', str(source), 'rev-parse', 'HEAD'], check=True, capture_output=True, text=True).stdout.strip() files = {} for path in sorted((source / 'hydra').glob('*.py')): name = 'usr/lib/hydra/hydra/' + path.name if ENGINE['allowed'](name): if path.is_symlink(): raise ValueError('Source payload symlink refused') files[name] = (ENGINE['read_bytes'](path, ENGINE['FILE_LIMIT']), 0o644) files['usr/lib/hydra/bin/hydra'] = (ENGINE['read_bytes'](source / 'bin/hydra', ENGINE['FILE_LIMIT']), 0o755) for name in sorted(ENGINE['EXACT'] - {'usr/lib/hydra/bin/hydra'}): path = source / 'image/rootfs' / name if path.exists(): files[name] = (ENGINE['read_bytes'](path, ENGINE['FILE_LIMIT']), 0o644) # Construct privately, verify our complete artifacts, then publish a new directory. with tempfile.TemporaryDirectory(prefix='hydra-release-build-') as temporary: temporary = Path(temporary) with zipfile.ZipFile(temporary / 'runtime.zip', 'w', compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: for name, (data, mode) in sorted(files.items()): info = zipfile.ZipInfo(name, (1980, 1, 1, 0, 0, 0)) info.create_system = 3 info.external_attr = (stat.S_IFREG | mode) << 16 info.compress_type = zipfile.ZIP_DEFLATED archive.writestr(info, data, compresslevel=9) data = ENGINE['read_bytes'](temporary / 'runtime.zip', ENGINE['LIMIT']) manifest = {'format': 1, 'product': 'Hydra OS', 'scope': 'runtime', 'channel': 'stable', 'architecture': 'x86_64', 'version': version, 'sequence': sequence, 'source_commit': commit, 'archive': {'name': 'runtime.zip', 'size': len(data), 'sha256': ENGINE['digest'](data)}, 'files': {name: {'size': len(content), 'mode': mode, 'sha256': ENGINE['digest'](content)} for name, (content, mode) in sorted(files.items())}} raw = (json.dumps(manifest, sort_keys=True, separators=(',', ':')) + '\n').encode() (temporary / 'manifest.json').write_bytes(raw) subprocess.run(['openssl', 'pkeyutl', '-sign', '-rawin', '-inkey', str(private_key), '-in', str(temporary / 'manifest.json'), '-out', str(temporary / 'manifest.sig')], check=True, capture_output=True, timeout=15) subprocess.run(['openssl', 'pkey', '-in', str(private_key), '-pubout', '-out', str(temporary / 'public.pem')], check=True, capture_output=True, timeout=15) ENGINE['validate_manifest'](raw, (temporary / 'manifest.sig').read_bytes(), temporary / 'public.pem') ENGINE['validate_archive'](data, manifest) output.mkdir(mode=0o755) # Existing version directories are immutable. try: for name in ('manifest.json', 'manifest.sig', 'runtime.zip'): ENGINE['atomic_write'](output / name, (temporary / name).read_bytes(), 0o644) ENGINE['fsync_dir'](output.parent) except BaseException: shutil.rmtree(output) raise return manifest def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--source', type=Path, default=Path(__file__).resolve().parents[1]) parser.add_argument('--output', type=Path, required=True) parser.add_argument('--private-key', type=Path, required=True) parser.add_argument('--version', required=True) parser.add_argument('--sequence', type=int, required=True) args = parser.parse_args(argv) try: print(json.dumps(build(args.source, args.output, args.private_key, args.version, args.sequence), indent=2)) return 0 except (ValueError, OSError, subprocess.SubprocessError) as exc: print('hydra-release: ' + str(exc), file=sys.stderr) return 1 if __name__ == '__main__': raise SystemExit(main())