"""Verify historical experiments or restore them to an explicit directory. No experiment is regenerated and no historical fingerprint is rewritten. Restore uses the reorganized experiment paths listed in catalog.json. """ from __future__ import annotations import argparse import hashlib import json from pathlib import Path, PurePosixPath import tarfile EXPERIMENTS = Path(__file__).resolve().parents[2] / "experiments" def digest(content: bytes) -> str: return hashlib.sha256(content).hexdigest() def safe_path(base: Path, name: str) -> Path: relative = PurePosixPath(name) if not name or relative.is_absolute() or ".." in relative.parts or "\\" in name: raise ValueError(f"Unsafe archive path: {name}") target = base / relative if not target.resolve().is_relative_to(base.resolve()): raise ValueError(f"Archive path escapes destination: {name}") return target def load_catalog(experiments: Path = EXPERIMENTS) -> dict: catalog = json.loads((experiments / "catalog.json").read_text()) if catalog.get("schema_version") != 1: raise ValueError("Unsupported archive catalog") return catalog def archive_blobs(catalog: dict, experiments: Path) -> dict[str, bytes]: archive = safe_path(experiments, catalog["archive"]) if digest(archive.read_bytes()) != catalog["archive_sha256"]: raise ValueError("Archive checksum mismatch") expected = {e["member"] for e in catalog["entries"] if e["location"] == "archive"} with tarfile.open(archive) as stream: members = stream.getmembers() if {m.name for m in members} != expected or len(members) != len(expected): raise ValueError("Archive member list mismatch") if any(not member.isfile() for member in members): raise ValueError("Only regular archive files are supported") return {member.name: stream.extractfile(member).read() for member in members} def verify(experiments: Path = EXPERIMENTS) -> dict: catalog = load_catalog(experiments) blobs = archive_blobs(catalog, experiments) for entry in catalog["entries"]: content = ( blobs[entry["member"]] if entry["location"] == "archive" else safe_path(experiments.parent, entry["path"]).read_bytes() ) if len(content) != entry["bytes"] or digest(content) != entry["sha256"]: raise ValueError(f"Experiment checksum mismatch: {entry['original_path']}") return { "entries": len(catalog["entries"]), "archived": sum(e["location"] == "archive" for e in catalog["entries"]), "source_commit": catalog["source_commit"], } def restore( destination: Path, originals: list[str] | None = None, *, experiments: Path = EXPERIMENTS ) -> list[Path]: """Validate the complete selection before writing; never overwrite a file.""" catalog = load_catalog(experiments) wanted = set(originals or []) known = {entry["original_path"] for entry in catalog["entries"]} if wanted - known: raise ValueError("Unknown original experiment path") blobs = archive_blobs(catalog, experiments) plan = {} for entry in catalog["entries"]: if entry["location"] != "archive" or (wanted and entry["original_path"] not in wanted): continue target = safe_path(destination, entry["path"]) content = blobs[entry["member"]] if len(content) != entry["bytes"] or digest(content) != entry["sha256"]: raise ValueError("Archived experiment checksum mismatch") if target.exists(): raise FileExistsError(f"Restore refuses to replace: {target}") if target in plan and plan[target] != content: raise ValueError("Conflicting restore destinations") plan[target] = content for target, content in plan.items(): target.parent.mkdir(parents=True, exist_ok=True) with target.open("xb") as stream: stream.write(content) return list(plan) def main(): parser = argparse.ArgumentParser(description=__doc__) commands = parser.add_subparsers(dest="command", required=True) commands.add_parser("verify") commands.add_parser("list") restore_parser = commands.add_parser("restore") restore_parser.add_argument("--destination", required=True, type=Path) restore_parser.add_argument("--original-path", action="append") args = parser.parse_args() if args.command == "verify": print(json.dumps(verify())) elif args.command == "list": for entry in load_catalog()["entries"]: print(f"{entry['location']:7} {entry['original_path']} -> {entry['path']}") else: restored = restore(args.destination, args.original_path) print(json.dumps({"restored": len(restored), "destination": str(args.destination)})) if __name__ == "__main__": main()