#!/usr/bin/env python3 """Verify a Stackchain Dashboard release bundle before promotion.""" from __future__ import annotations import argparse import hashlib import json import sys import tarfile from pathlib import Path, PurePosixPath from build_release import manifest_for, runtime_files_at_commit def one(directory: Path, pattern: str) -> Path: matches = sorted(directory.glob(pattern)) if len(matches) != 1: raise ValueError(f"expected exactly one {pattern} file, found {len(matches)}") return matches[0] def digest(data: bytes) -> str: return hashlib.sha256(data).hexdigest() def verify(input_dir: Path, commit: str, repository: Path) -> None: archive_path = one(input_dir, "*.tar.gz") manifest_path = one(input_dir, "*.manifest.json") checksum_path = one(input_dir, "*.sha256") manifest = json.loads(manifest_path.read_text()) if manifest.get("commit") != commit: raise ValueError("manifest commit does not match the tested commit") artifact = manifest.get("artifact", {}) archive_data = archive_path.read_bytes() archive_digest = digest(archive_data) if artifact.get("name") != archive_path.name: raise ValueError("manifest artifact name does not match the bundle") if artifact.get("size") != len(archive_data) or artifact.get("sha256") != archive_digest: raise ValueError("bundle does not match its manifest") checksum_parts = checksum_path.read_text().strip().split() if checksum_parts != [archive_digest, archive_path.name]: raise ValueError("checksum file does not match the bundle") expected_files = manifest.get("files") if not isinstance(expected_files, dict): raise ValueError("manifest files must be an object") source_files = runtime_files_at_commit(repository, commit) source_manifest = manifest_for(source_files, commit)["files"] if expected_files != source_manifest: raise ValueError("bundle manifest does not match the declared Git commit") source_by_name = {item.name: item for item in source_files} with tarfile.open(archive_path, "r:gz") as archive: members = archive.getmembers() for member in members: path = PurePosixPath(member.name) if path.is_absolute() or ".." in path.parts or not member.isfile(): raise ValueError("bundle contains an unsafe member") names = {member.name for member in members} if names != set(expected_files) | {"release-manifest.json"}: raise ValueError("bundle contents do not match the manifest") embedded_file = archive.extractfile("release-manifest.json") if embedded_file is None: raise ValueError("bundle manifest is missing") embedded = json.load(embedded_file) if embedded.get("commit") != commit or embedded.get("files") != expected_files: raise ValueError("embedded manifest does not match the public manifest") for name, metadata in expected_files.items(): bundled = archive.extractfile(name) if bundled is None: raise ValueError(f"bundle member is missing: {name}") data = bundled.read() if metadata != {"sha256": digest(data), "size": len(data)}: raise ValueError(f"bundle member failed verification: {name}") if data != source_by_name[name].data: raise ValueError(f"bundle member differs from the declared Git commit: {name}") def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--input-dir", type=Path, required=True) parser.add_argument("--commit", required=True) parser.add_argument("--repository", type=Path, default=Path.cwd()) return parser.parse_args() def main() -> None: args = parse_args() try: verify(args.input_dir, args.commit, args.repository) except (OSError, ValueError, json.JSONDecodeError, tarfile.TarError) as error: print(f"release verification failed: {error}", file=sys.stderr) raise SystemExit(1) from error print(f"verified release bundle for {args.commit}") if __name__ == "__main__": main()