ffi_provenance.py (6808B)
1 """Generate or check staged producer evidence, separate from installed artifacts.""" 2 3 from __future__ import annotations 4 5 import argparse 6 import hashlib 7 import json 8 import os 9 import sys 10 import tempfile 11 from pathlib import Path 12 from typing import Any 13 14 import ffi_source as source 15 import package_contract as contract 16 17 18 def capture(root: Path, target: str) -> dict[str, Any]: 19 source.reject_build_overrides() 20 source.reject_cargo_configuration(root) 21 config = source.producer_contract(root) 22 build = config["build"] 23 if target not in build["targets"]: 24 raise source.ProvenanceError("producer target is not governed") 25 snapshot = source.source_snapshot(root, config["source_inputs"]) 26 rustc = source.command(root, ["rustc", "-Vv"]).decode().strip() 27 if f"release: {build['rust_version']}\n" not in rustc + "\n": 28 raise source.ProvenanceError( 29 "active Rust compiler differs from producer contract" 30 ) 31 if f"host: {build['host']}\n" not in rustc + "\n": 32 raise source.ProvenanceError("active Rust host differs from producer contract") 33 result = { 34 "schema": "tera.producer-source.v1", 35 "repository": config["repository"], 36 "source": snapshot, 37 "foundation": contract._read_toml(root / config["foundation_lock"]), 38 "cargo_lock_sha256": hashlib.sha256( 39 source.read_source(root, "Cargo.lock") 40 ).hexdigest(), 41 "build": { 42 **config["ffi"], 43 "target": target, 44 "profile": build["profile"], 45 "ios_deployment_target": build["ios_deployment_target"], 46 "rust_flags": build["rust_flags"], 47 "package_rust_flags": source.library_rust_flags(build, target), 48 "source_date_epoch": build["source_date_epoch"], 49 "rustc": rustc, 50 "symbol_reader": source.command( 51 root, [str(symbol_reader(root, build["host"])), "--version"] 52 ) 53 .decode() 54 .strip(), 55 "apple_toolchain": apple_toolchain(root), 56 "feature_graph": source.feature_graph( 57 root, config["ffi"]["package"], target 58 ), 59 }, 60 "generator": { 61 **config["generator"], 62 "target": build["host"], 63 "profile": "dev", 64 "profile_overrides": source.allowed_profile_overrides(), 65 "feature_graph": source.feature_graph( 66 root, config["generator"]["package"], build["host"] 67 ), 68 }, 69 "disposition": "local_staged_source_only", 70 } 71 if source.source_snapshot(root, config["source_inputs"]) != snapshot: 72 raise source.ProvenanceError("producer source changed during capture") 73 return result 74 75 76 def symbol_reader(root: Path, host: str) -> Path: 77 sysroot = Path( 78 source.command(root, ["rustc", "--print", "sysroot"]).decode().strip() 79 ) 80 reader = sysroot / "lib/rustlib" / host / "bin/llvm-nm" 81 if not reader.is_file() or not os.access(reader, os.X_OK): 82 raise source.ProvenanceError("Rust toolchain llvm-tools component is required") 83 return reader 84 85 86 def apple_toolchain(root: Path) -> dict[str, str]: 87 commands = { 88 "xcode": ["xcodebuild", "-version"], 89 "swift": ["xcrun", "swiftc", "--version"], 90 "swiftformat": ["swiftformat", "--version"], 91 "iphoneos_sdk": ["xcrun", "--sdk", "iphoneos", "--show-sdk-build-version"], 92 "iphonesimulator_sdk": [ 93 "xcrun", 94 "--sdk", 95 "iphonesimulator", 96 "--show-sdk-build-version", 97 ], 98 } 99 return { 100 name: source.command(root, argv).decode().strip() 101 for name, argv in commands.items() 102 } 103 104 105 def encoded(value: dict[str, Any]) -> bytes: 106 return (json.dumps(value, sort_keys=True, indent=2) + "\n").encode() 107 108 109 def verify_record(actual: bytes, expected: dict[str, Any]) -> None: 110 if actual != encoded(expected): 111 raise source.ProvenanceError( 112 "producer provenance differs from the exact source/build tuple" 113 ) 114 115 116 def output_path(root: Path, target: str, requested: str | None) -> Path: 117 external = os.environ.get("EXT_BUILD_PROJECT_DIR") 118 if not external or not os.environ.get("EXT_BUILD_RUN_ACTIVE"): 119 raise source.ProvenanceError("producer provenance requires extbuild") 120 base = Path(external).resolve() 121 path = ( 122 Path(requested) 123 if requested 124 else base / "target/tera_ffi/source" / f"{target}.json" 125 ) 126 path = path.absolute() 127 if not path.is_relative_to(base) or path.is_relative_to(root): 128 raise source.ProvenanceError("producer evidence must use external build output") 129 if path.resolve() != path or path.is_symlink(): 130 raise source.ProvenanceError("producer evidence output contains a symlink") 131 return path 132 133 134 def write_atomic(path: Path, data: bytes) -> None: 135 path.parent.mkdir(parents=True, exist_ok=True) 136 temporary: str | None = None 137 try: 138 with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as handle: 139 temporary = handle.name 140 handle.write(data) 141 handle.flush() 142 os.fsync(handle.fileno()) 143 os.replace(temporary, path) 144 temporary = None 145 finally: 146 if temporary: 147 Path(temporary).unlink(missing_ok=True) 148 149 150 def main() -> int: 151 parser = argparse.ArgumentParser(description=__doc__) 152 parser.add_argument("mode", choices=("contract-check", "write", "check")) 153 parser.add_argument("--target") 154 parser.add_argument( 155 "--repo-root", type=Path, default=Path(__file__).resolve().parent.parent 156 ) 157 parser.add_argument("--output") 158 args = parser.parse_args() 159 try: 160 root = args.repo_root.resolve() 161 if args.mode == "contract-check": 162 source.producer_contract(root) 163 print( 164 "FFI producer contract verified; installed artifacts remain separately governed" 165 ) 166 return 0 167 if not args.target: 168 raise source.ProvenanceError("producer target is required") 169 path = output_path(root, args.target, args.output) 170 record = capture(root, args.target) 171 if args.mode == "write": 172 write_atomic(path, encoded(record)) 173 verify_record(contract._read_regular(path), record) 174 except ( 175 source.ProvenanceError, 176 contract.PackageContractError, 177 OSError, 178 ValueError, 179 KeyError, 180 ) as error: 181 print(f"FFI producer provenance: {error}", file=sys.stderr) 182 return 1 183 print( 184 f"FFI producer source {args.mode}: {args.target}; tree={record['source']['tree']}; local staged source only" 185 ) 186 return 0 187 188 189 if __name__ == "__main__": 190 raise SystemExit(main())