#!/usr/bin/env python3 """签发或校验 staging RC 的 Python 运行树字节清单。""" from __future__ import annotations import argparse import hashlib import json import os import stat from pathlib import Path IGNORED_PARTS = frozenset({"__pycache__", ".pytest_cache"}) def sha256_file(path: Path) -> str: """流式计算文件摘要,避免大依赖文件一次性进入内存。""" digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def should_ignore(path: Path) -> bool: """忽略 Python 运行时可再生缓存,其余新增文件一律计入漂移。""" return path.suffix == ".pyc" or any(part in IGNORED_PARTS for part in path.parts) def capture(roots: list[str]) -> dict: """采集普通文件与符号链接;符号链接同时绑定文本目标和解析后文件摘要。""" entries: list[dict] = [] cwd = Path.cwd().resolve() normalized_roots: list[str] = [] for raw_root in roots: root = Path(raw_root) if root.is_absolute() or ".." in root.parts or not root.is_dir() or root.is_symlink(): raise ValueError(f"运行树根必须是仓内非符号链接目录: {raw_root}") normalized_roots.append(root.as_posix()) for path in root.rglob("*"): relative = path.relative_to(cwd) if path.is_absolute() else path if should_ignore(relative): continue metadata = path.lstat() mode = stat.S_IMODE(metadata.st_mode) if path.is_symlink(): target = os.readlink(path) resolved = path.resolve(strict=False) entries.append({ "path": relative.as_posix(), "type": "symlink", "mode": mode, "target": target, "resolvedSha256": sha256_file(resolved) if resolved.is_file() else None, }) elif path.is_file(): entries.append({ "path": relative.as_posix(), "type": "file", "mode": mode, "sha256": sha256_file(path), "size": metadata.st_size, }) entries.sort(key=lambda item: item["path"]) return {"schema": "staging-runtime-tree/1", "roots": normalized_roots, "entries": entries} def write_manifest(output: Path, roots: list[str]) -> None: """以 0600 原子替换清单,避免半写文件被签入 RC。""" payload = capture(roots) output.parent.mkdir(parents=True, exist_ok=True) temporary = output.with_name(output.name + ".tmp") temporary.write_text(json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n", encoding="utf-8") temporary.chmod(0o600) temporary.replace(output) def verify_manifest(manifest: Path, roots: list[str]) -> None: """逐结构比较当前树,新增、删除、权限、字节或链接目标漂移均拒绝。""" expected = json.loads(manifest.read_text(encoding="utf-8")) actual = capture(roots) if expected != actual: raise RuntimeError("Python 运行树与 RC 清单不一致") def main() -> int: parser = argparse.ArgumentParser(description="staging RC Python 运行树完整性清单") subparsers = parser.add_subparsers(dest="command", required=True) capture_parser = subparsers.add_parser("capture") capture_parser.add_argument("--output", required=True, type=Path) capture_parser.add_argument("roots", nargs="+") verify_parser = subparsers.add_parser("verify") verify_parser.add_argument("--manifest", required=True, type=Path) verify_parser.add_argument("roots", nargs="+") args = parser.parse_args() if args.command == "capture": write_manifest(args.output, args.roots) print(f"RUNTIME_TREE_CAPTURE_PASS manifest={args.output}") else: verify_manifest(args.manifest, args.roots) print(f"RUNTIME_TREE_VERIFY_PASS manifest={args.manifest}") return 0 if __name__ == "__main__": raise SystemExit(main())