87 lines
4.8 KiB
Python
87 lines
4.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Assemble NEW private source trees; no build, install, runtime or Git mutation."""
|
|
import argparse, hashlib, io, json, os, pathlib, stat, subprocess, tarfile
|
|
HERE = pathlib.Path(__file__).resolve().parent
|
|
|
|
def run(argv, **kw):
|
|
return subprocess.check_output(argv, stderr=subprocess.PIPE, **kw)
|
|
|
|
def manifest(root):
|
|
result = {}
|
|
for p in sorted(root.rglob("*")):
|
|
if p.is_symlink():
|
|
raise ValueError(f"Unexpected symlink: {p}")
|
|
if p.is_file():
|
|
result[str(p.relative_to(root))] = {"sha256": hashlib.sha256(p.read_bytes()).hexdigest(), "mode": oct(p.stat().st_mode & 0o777)}
|
|
return result
|
|
|
|
def verify(root, provenance):
|
|
results = {}
|
|
for name, entry in provenance["components"].items():
|
|
actual = manifest(root / name)
|
|
if actual != entry["files"]:
|
|
raise ValueError(f"Source bytes/modes/file list mismatch: {name}")
|
|
results[name] = {"files": len(actual), "manifest_sha256": hashlib.sha256(json.dumps(actual, sort_keys=True).encode()).hexdigest()}
|
|
return results
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument("--project-git", type=pathlib.Path, required=True)
|
|
ap.add_argument("--scene-git", type=pathlib.Path, help="optional read-only verified bare cache; otherwise fetch exact public pin")
|
|
ap.add_argument("--destination", type=pathlib.Path, required=True)
|
|
args = ap.parse_args()
|
|
dest = args.destination.absolute()
|
|
if not dest.is_relative_to(pathlib.Path("/var/tmp")) or dest == pathlib.Path("/var/tmp"):
|
|
raise ValueError("Explicit destination strictly below /var/tmp required")
|
|
if dest.exists() or dest.is_symlink() or dest.resolve() != dest:
|
|
raise ValueError("Destination must be absent, without symlink ancestors")
|
|
if not dest.parent.is_dir():
|
|
raise ValueError("Existing destination parent required")
|
|
# All generated writes, including Git/cache/Python temp files, remain here.
|
|
os.umask(0o077)
|
|
dest.mkdir(mode=0o700)
|
|
if dest.stat().st_uid != os.getuid() or stat.S_IMODE(dest.stat().st_mode) != 0o700:
|
|
raise ValueError("Destination owner/mode mismatch")
|
|
for key, sub in [("TMPDIR", "scratch"), ("TMP", "scratch"), ("TEMP", "scratch"), ("XDG_CACHE_HOME", "cache"), ("CCACHE_DIR", "cache/ccache"), ("PYTHONPYCACHEPREFIX", "pycache"), ("XDG_DATA_HOME", "data")]:
|
|
path = dest / sub
|
|
path.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
os.environ[key] = str(path)
|
|
for key in ("SWAYSOCK", "WAYLAND_DISPLAY", "DISPLAY", "XDG_RUNTIME_DIR"):
|
|
os.environ.pop(key, None)
|
|
p = json.loads((HERE / "provenance.json").read_text())
|
|
scene_git = args.scene_git
|
|
if scene_git is None:
|
|
scene_git = dest / "scene.git"
|
|
run(["git", "init", "--bare", str(scene_git)])
|
|
run(["git", "--git-dir=" + str(scene_git), "fetch", "--depth=1", p["scene_remote"], p["components"]["scene"]["pin"]])
|
|
receipts = []
|
|
for name, entry in p["components"].items():
|
|
git = ["git", "--git-dir=" + str(scene_git)] if name == "scene" else ["git", "-C", str(args.project_git)]
|
|
pin = entry["pin"]
|
|
if run(git + ["rev-parse", pin + "^{commit}"]).decode().strip() != pin:
|
|
raise ValueError("Commit mismatch")
|
|
if run(git + ["rev-parse", pin + "^{tree}"]).decode().strip() != entry["parent_tree"]:
|
|
raise ValueError("Parent tree mismatch")
|
|
patch = HERE / entry["patch"]
|
|
if hashlib.sha256(patch.read_bytes()).hexdigest() != entry["patch_sha256"]:
|
|
raise ValueError("Patch checksum mismatch")
|
|
root = dest / name
|
|
root.mkdir(mode=0o700)
|
|
archive = run(git + ["archive", "--format=tar", pin])
|
|
with tarfile.open(fileobj=io.BytesIO(archive)) as tf:
|
|
tf.extractall(root, filter="data")
|
|
# Disable discovery of any enclosing project repository.
|
|
env = dict(os.environ, GIT_CEILING_DIRECTORIES=str(dest))
|
|
run(["git", "apply", "--check", str(patch)], cwd=root, env=env)
|
|
run(["git", "apply", str(patch)], cwd=root, env=env)
|
|
# git apply replaces changed regular files using process umask. Restore
|
|
# their pinned regular-file mode explicitly inside this private tree.
|
|
for relative in entry["changed_files"]:
|
|
(root / relative).chmod(int(entry["files"][relative]["mode"], 8))
|
|
receipts.append({"component": name, "parent_pin": pin, "parent_tree": entry["parent_tree"], "archive_sha256": hashlib.sha256(archive).hexdigest(), "patch_sha256": entry["patch_sha256"], "forward_apply_rc": 0})
|
|
result = {"identity": p["identity"], "status": "EXACT_FOR_NEW_TREE", "components": verify(dest, p), "receipts": receipts}
|
|
(dest / "assembly-receipt.json").write_text(json.dumps(result, indent=2) + "\n")
|
|
print(json.dumps(result, indent=2))
|
|
|
|
if __name__ == "__main__":
|
|
main()
|