#!/usr/bin/env python3 """Verify the bounded source archive against its manifest; this is not a signature.""" import hashlib import json import re import sys from pathlib import Path, PurePosixPath from safe_json import strict_loads def verify_release(root): root = Path(root).resolve() failures = [] try: manifest_file = root / "RELEASE_MANIFEST.json" if manifest_file.is_symlink() or manifest_file.stat().st_size > 1_048_576: raise ValueError("Unsafe manifest") manifest = strict_loads(manifest_file.read_text(encoding="utf-8")) if not isinstance(manifest, dict) or manifest.get("manifest_format") != "bodul-source-archive-v1": raise ValueError("Unknown manifest format") files = manifest.get("files") if not isinstance(files, dict) or not 1 <= len(files) <= 500: raise ValueError("Invalid file list") folded = set() for name, entry in files.items(): relative = PurePosixPath(name) if not relative.parts or not re.fullmatch(r"[A-Za-z0-9_.\-/]+", name) or relative.is_absolute() or name != relative.as_posix() or any(part in (".", "..") for part in relative.parts) or name == "RELEASE_MANIFEST.json": raise ValueError("Unsafe archive path") if name.casefold() in folded: raise ValueError("Case-insensitive archive path collision") folded.add(name.casefold()) if not isinstance(entry, dict) or set(entry) != {"bytes", "sha256", "sha512"}: raise ValueError("Invalid file entry") if type(entry["bytes"]) is not int or not 0 <= entry["bytes"] <= 2_000_000: raise ValueError("Invalid file size") for algorithm, length in (("sha256", 64), ("sha512", 128)): if not isinstance(entry[algorithm], str) or not re.fullmatch("[0-9a-f]{" + str(length) + "}", entry[algorithm]): raise ValueError("Invalid digest") path = root.joinpath(*relative.parts) if not path.is_relative_to(root) or any(parent.is_symlink() for parent in (path, *path.parents) if parent != root and parent.is_relative_to(root)): failures.append(name + ": unsafe symlink") continue if not path.is_file() or path.stat().st_size != entry["bytes"]: failures.append(name + ": missing file or changed length") continue data = path.read_bytes() if any(hashlib.new(a, data).hexdigest() != entry[a] for a in ("sha256", "sha512")): failures.append(name + ": digest mismatch") # Ignore development machinery; reject extra source/data files. for path in root.rglob("*"): rel = path.relative_to(root) if any(part in (".git", ".venv", "__pycache__") for part in rel.parts): continue if path.is_symlink(): failures.append(rel.as_posix() + ": unexpected symlink") elif path.is_file() and rel.as_posix() not in files and rel.as_posix() != "RELEASE_MANIFEST.json": failures.append(rel.as_posix() + ": unlisted file") return {"verified": not failures, "files_checked": len(files), "scope": "archive_byte_integrity", "authenticated": False, "failures": failures} except (ValueError, TypeError, OSError, KeyError, UnicodeError) as exc: return {"verified": False, "scope": "archive_byte_integrity", "authenticated": False, "failures": [str(exc)]} if __name__ == "__main__": result = verify_release(Path(__file__).resolve().parent) print(json.dumps(result, sort_keys=True)) sys.exit(0 if result["verified"] else 2)