field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

commit 14f54250ff0f548bf0db7841ca7769c544e0b5c4
parent 7e5e049bf3d2cf6b400848c25d4d4a89b6cd4895
Author: triesap <tyson@radroots.org>
Date:   Tue,  8 Sep 2026 20:11:50 +0000

tera: build native artifacts from owned rust

- stage device simulator and host libraries from the owned workspace
- generate matching bindings framework api and nonempty source-bound provenance
- verify native symbols with the pinned rust toolchain llvm reader
- select explicit linux packages and reject stale or mismatched candidates

Diffstat:
MAGENTS.md | 3+++
MMakefile | 7+++++++
MREADME.md | 5+++++
MRadrootsFFI/Makefile | 8++++++++
MRadrootsFFI/producer.toml | 5+++++
Mrust-toolchain.toml | 1+
Ascripts/ffi-artifacts.sh | 6++++++
Ascripts/ffi_artifacts.py | 211+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/ffi_build.py | 382+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mscripts/ffi_provenance.py | 17+++++++++++++++++
Mscripts/ffi_source.py | 27++++++++++++++++++++++++++-
Mscripts/linux-shared-rust.sh | 5+++--
Mscripts/swift-quality.sh | 3+++
Ascripts/test_ffi_artifacts.py | 161+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mscripts/verify-package-contract.sh | 1+
15 files changed, 839 insertions(+), 3 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -25,6 +25,9 @@ This file applies to the complete standalone iOS app repository. A closer `make ffi-source-check`. Source records identify a staged input tree and exact build tuple under extbuild output. They do not establish installed artifacts or remote release qualification; installed provenance remains separate. + `make ffi-candidate-build ffi-candidate-check` builds and validates the exact + owned native artifact bundle in external staging. Its nonempty v2 artifact + manifest is separate from the installed legacy v1 manifest until cutover. - Human specifications, decisions, migration history, runbooks, and qualification evidence are parent-owned under `docs/oss/ios_app/**`. They are absent from a standalone clone and must never become a build, test, diff --git a/Makefile b/Makefile @@ -10,6 +10,7 @@ SIMULATOR_DESTINATION := platform=iOS Simulator,name=$(SIMULATOR_NAME) .PHONY: all doctor bootstrap persona-verifier-bootstrap ffi-bootstrap artifact-check package-contract-check \ ffi-source-write ffi-source-check \ + ffi-candidate-build ffi-candidate-check \ swift-quality maintainability-check \ linux-shared-rust \ package-resolve package-build package-test project xcodegen xcode-resolve \ @@ -36,6 +37,12 @@ ffi-source-write: doctor ffi-source-check: doctor cargo extbuild run -- scripts/ffi-provenance.sh check --target '$(FFI_TARGET)' +ffi-candidate-build: doctor + cargo extbuild run -- $(MAKE) -C $(FFI_ROOT) candidate-build + +ffi-candidate-check: doctor + cargo extbuild run -- $(MAKE) -C $(FFI_ROOT) candidate-check + package-contract-check: doctor cargo extbuild run -- scripts/verify-package-contract.sh diff --git a/README.md b/README.md @@ -27,6 +27,11 @@ toolchains under extbuild output. Select a supported target with `FFI_TARGET`. This is local source evidence; the installed native artifacts retain their existing source lock until the native cutover. +`make ffi-candidate-build ffi-candidate-check` builds the owned device, +simulator and host libraries, generates matching Swift and API outputs, and +verifies the staged XCFramework and provenance. Candidates remain under +extbuild output; this command does not install them into the native app. + ## Requirements - macOS with Xcode and an iOS 18-or-newer simulator diff --git a/RadrootsFFI/Makefile b/RadrootsFFI/Makefile @@ -1,4 +1,5 @@ SHELL := /bin/bash +.DEFAULT_GOAL := all .SHELLFLAGS := -eu -o pipefail -c include source.lock @@ -67,6 +68,13 @@ SWIFT_SYMBOLGRAPH := $(shell xcrun --sdk iphonesimulator --find swift-symbolgrap SWIFT_API_TARGET := arm64-apple-ios$(IPHONEOS_DEPLOYMENT_TARGET)-simulator .PHONY: all clean distclean print-config sync-source ensure-toolchain build generate package install api-snapshot-render api-snapshot-write api-snapshot-check provenance verify +.PHONY: candidate-build candidate-check + +candidate-build: + ../scripts/ffi-artifacts.sh build + +candidate-check: + ../scripts/ffi-artifacts.sh check all: verify @echo "done" diff --git a/RadrootsFFI/producer.toml b/RadrootsFFI/producer.toml @@ -22,6 +22,11 @@ features = [] rust_version = "1.97.1" profile = "release" ios_deployment_target = "18.0" +rust_flags = [ + "--remap-path-prefix={producer_root}=/tera", + "--remap-path-prefix={cargo_home}=/cargo", + "--remap-path-prefix={extbuild_root}=/build", +] source_date_epoch = 1787871027 host = "aarch64-apple-darwin" targets = ["aarch64-apple-ios", "aarch64-apple-ios-sim", "aarch64-apple-darwin"] diff --git a/rust-toolchain.toml b/rust-toolchain.toml @@ -1,3 +1,4 @@ [toolchain] channel = "1.97.1" profile = "minimal" +components = ["llvm-tools"] diff --git a/scripts/ffi-artifacts.sh b/scripts/ffi-artifacts.sh @@ -0,0 +1,6 @@ +#!/bin/sh +set -eu + +repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +exec uv run --offline --frozen --project "$repo_root/scripts/persona-verifier" \ + python "$repo_root/scripts/ffi_build.py" "$@" diff --git a/scripts/ffi_artifacts.py b/scripts/ffi_artifacts.py @@ -0,0 +1,211 @@ +"""Exact staged native artifact inventory and source-tuple verification.""" + +from __future__ import annotations + +import hashlib +import plistlib +import re +from pathlib import Path +from typing import Any + +import ffi_provenance as provenance +import ffi_source as source +import package_contract as contract + +TARGETS = ("aarch64-apple-ios", "aarch64-apple-ios-sim", "aarch64-apple-darwin") +MAX_ARTIFACT_BYTES = 256 * 1024 * 1024 +FRAMEWORK = "TeraFFI.xcframework" +MODULE = "TeraKitBindings" +MANIFEST = "provenance.json" + + +def expected_paths() -> set[str]: + paths = { + "generated/TeraKitBindings.swift", + "generated/TeraFFI.h", + "generated/TeraFFI.modulemap", + "headers/TeraFFI.h", + "headers/module.modulemap", + f"{FRAMEWORK}/Info.plist", + "api/TeraKitBindings.symbols.json", + "abi_symbols.json", + } + for target in TARGETS: + extension = "dylib" if target == TARGETS[-1] else "a" + paths.add(f"native/{target}/libtera_ffi.{extension}") + paths.add(f"source/{target}.json") + for platform in ("ios-arm64", "ios-arm64-simulator"): + for relative in ( + "libtera_ffi.a", + "Headers/TeraFFI.h", + "Headers/module.modulemap", + ): + paths.add(f"{FRAMEWORK}/{platform}/{relative}") + return paths + + +def regular_path(root: Path, relative: str) -> Path: + path = Path(relative) + if path.is_absolute() or str(path) != relative or ".." in path.parts: + raise source.ProvenanceError("native artifact path is invalid") + current = root + for part in path.parts: + current = current / part + if current.is_symlink(): + raise source.ProvenanceError("native artifact path contains a symlink") + if not current.is_file(): + raise source.ProvenanceError("native artifact file is missing") + return current + + +def file_record(root: Path, relative: str) -> dict[str, Any]: + path = regular_path(root, relative) + size = path.stat().st_size + if size <= 0 or size > MAX_ARTIFACT_BYTES: + raise source.ProvenanceError("native artifact exceeds its byte bound") + digest = hashlib.sha256() + with path.open("rb") as handle: + while data := handle.read(1024 * 1024): + digest.update(data) + if path.stat().st_size != size: + raise source.ProvenanceError("native artifact changed during verification") + return {"path": relative, "bytes": size, "sha256": digest.hexdigest()} + + +def inventory(root: Path) -> list[dict[str, Any]]: + paths = set() + for path in root.rglob("*"): + if path.is_symlink(): + raise source.ProvenanceError("native artifact inventory contains a symlink") + if path.is_file() and path.relative_to(root).as_posix() != MANIFEST: + paths.add(path.relative_to(root).as_posix()) + if paths != expected_paths(): + raise source.ProvenanceError("native artifact inventory differs") + return [file_record(root, relative) for relative in sorted(paths)] + + +def validate_module_files(root: Path) -> None: + generated = root / "generated" + header = contract._read_regular(generated / "TeraFFI.h") + modulemap = contract._read_regular(generated / "TeraFFI.modulemap") + if b"module TeraFFI {" not in modulemap or b'header "TeraFFI.h"' not in modulemap: + raise source.ProvenanceError("generated native module identity differs") + swift = contract._read_regular(generated / "TeraKitBindings.swift") + if b"import TeraFFI" not in swift: + raise source.ProvenanceError("generated Swift module does not import its FFI") + for prefix in ( + "headers", + f"{FRAMEWORK}/ios-arm64/Headers", + f"{FRAMEWORK}/ios-arm64-simulator/Headers", + ): + if contract._read_regular(root / prefix / "TeraFFI.h") != header: + raise source.ProvenanceError( + "packaged FFI header differs from generated header" + ) + if contract._read_regular(root / prefix / "module.modulemap") != modulemap: + raise source.ProvenanceError( + "packaged FFI module map differs from generated module map" + ) + + +def validate_framework(root: Path) -> None: + info = plistlib.loads(contract._read_regular(root / FRAMEWORK / "Info.plist")) + libraries = info.get("AvailableLibraries", []) + expected = { + "ios-arm64": None, + "ios-arm64-simulator": "simulator", + } + if len(libraries) != 2 or { + item.get("LibraryIdentifier") for item in libraries + } != set(expected): + raise source.ProvenanceError("XCFramework platform inventory differs") + for item in libraries: + validate_platform(item, expected[item["LibraryIdentifier"]]) + for target, platform in zip(TARGETS[:2], expected, strict=True): + first = file_record(root, f"native/{target}/libtera_ffi.a") + packaged = file_record(root, f"{FRAMEWORK}/{platform}/libtera_ffi.a") + if (first["bytes"], first["sha256"]) != (packaged["bytes"], packaged["sha256"]): + raise source.ProvenanceError( + "XCFramework library differs from its built target" + ) + + +def validate_platform(item: dict[str, Any], variant: str | None) -> None: + if ( + item.get("SupportedArchitectures") != ["arm64"] + or item.get("SupportedPlatform") != "ios" + or item.get("SupportedPlatformVariant") != variant + or item.get("LibraryPath") != "libtera_ffi.a" + or item.get("HeadersPath") != "Headers" + ): + raise source.ProvenanceError("XCFramework library contract differs") + + +def validate_abi(root: Path) -> None: + symbols = contract._read_json(root / "abi_symbols.json") + if set(symbols) != set(TARGETS): + raise source.ProvenanceError("native ABI target inventory differs") + host = symbols[TARGETS[-1]] + if not isinstance(host, list) or not host or host != sorted(set(host)): + raise source.ProvenanceError("native ABI symbols are invalid") + if any(symbols[target] != host for target in TARGETS): + raise source.ProvenanceError("native target ABI symbols differ") + header = contract._read_regular(root / "generated/TeraFFI.h").decode() + declared = set( + re.findall(r"\b((?:ffi|uniffi)_tera_ffi_[A-Za-z0-9_]+)\s*\(", header) + ) + if not declared or not declared.issubset(host): + raise source.ProvenanceError( + "generated header declares an unavailable native symbol" + ) + + +def manifest(root: Path, records: dict[str, dict[str, Any]]) -> dict[str, Any]: + if set(records) != set(TARGETS): + raise source.ProvenanceError("producer source target inventory differs") + source_tree = records[TARGETS[0]]["source"]["tree"] + for target, record in records.items(): + if ( + record["source"]["tree"] != source_tree + or record["build"]["target"] != target + ): + raise source.ProvenanceError("producer source tuples disagree") + provenance.verify_record( + contract._read_regular(root / "source" / f"{target}.json"), record + ) + validate_module_files(root) + validate_framework(root) + validate_abi(root) + validate_api(root) + return { + "schema": "radroots.artifact-manifest.v2", + "product": "tera", + "target": "ios", + "language": "swift", + "external_names": ["TeraFFI", "TeraKitBindings"], + "source": { + "repository": records[TARGETS[0]]["repository"], + "tree": source_tree, + }, + "source_records": {target: f"source/{target}.json" for target in TARGETS}, + "files": inventory(root), + "disposition": "local_candidate_not_installed", + } + + +def validate_api(root: Path) -> None: + value = contract._read_json(root / "api/TeraKitBindings.symbols.json") + if ( + value.get("schema") != "radroots.swift-api-snapshot.v1" + or value.get("module", {}).get("name") != MODULE + or not isinstance(value.get("symbols"), list) + or not value["symbols"] + ): + raise source.ProvenanceError("generated Swift API snapshot identity differs") + + +def check(root: Path, records: dict[str, dict[str, Any]]) -> dict[str, Any]: + expected = manifest(root, records) + if contract._read_regular(root / MANIFEST) != provenance.encoded(expected): + raise source.ProvenanceError("native artifact provenance is stale") + return expected diff --git a/scripts/ffi_build.py b/scripts/ffi_build.py @@ -0,0 +1,382 @@ +"""Build and verify native candidates from the sole owned Rust workspace.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Any + +import ffi_artifacts as artifacts +import ffi_provenance as provenance +import ffi_source as source +import package_contract as contract + + +def build_roots(root: Path) -> tuple[Path, Path]: + if not os.environ.get("EXT_BUILD_RUN_ACTIVE"): + raise source.ProvenanceError("native artifact commands require extbuild") + project = Path(os.environ["EXT_BUILD_PROJECT_DIR"]).resolve() + target = Path(os.environ["CARGO_TARGET_DIR"]).resolve() + if not target.is_relative_to(project) or project.is_relative_to(root): + raise source.ProvenanceError("native outputs must use the external build root") + return project, target + + +def build_environment( + root: Path, project: Path, config: dict[str, Any] +) -> dict[str, str]: + source.reject_build_overrides() + values = { + "producer_root": str(root), + "extbuild_root": str(project), + "cargo_home": os.environ.get("CARGO_HOME", str(Path.home() / ".cargo")), + } + environment = dict(os.environ) + environment.pop("RADROOTS_CONSUMER_REVISION", None) + environment.update( + { + "CARGO_ENCODED_RUSTFLAGS": "\x1f".join( + flag.format(**values) for flag in config["build"]["rust_flags"] + ), + "IPHONEOS_DEPLOYMENT_TARGET": config["build"]["ios_deployment_target"], + "SOURCE_DATE_EPOCH": str(config["build"]["source_date_epoch"]), + "RADROOTS_LIB_REVISION": contract._read_toml( + root / config["foundation_lock"] + )["revision"], + } + ) + return environment + + +def run( + root: Path, logs: Path, name: str, argv: list[str], environment: dict[str, str] +) -> Path: + print(f"native candidate: {name}", flush=True) + log = logs / f"{name}.txt" + with log.open("wb") as output: + try: + result = subprocess.run( + argv, + cwd=root, + env=environment, + stdout=output, + stderr=subprocess.STDOUT, + timeout=1800, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as error: + raise source.ProvenanceError( + f"native command unavailable or timed out: {name}" + ) from error + if result.returncode or log.stat().st_size > 64 * 1024 * 1024: + raise source.ProvenanceError( + f"native command failed; retained diagnostic: {name}" + ) + return log + + +def build_libraries( + root: Path, bundle: Path, target_root: Path, logs: Path, env: dict[str, str] +) -> None: + for target in artifacts.TARGETS: + run( + root, + logs, + "build-" + target, + [ + "cargo", + "build", + "--manifest-path", + str(root / "Cargo.toml"), + "-p", + "tera_ffi", + "--release", + "--locked", + "--target", + target, + ], + env, + ) + extension = "dylib" if target == artifacts.TARGETS[-1] else "a" + destination = bundle / "native" / target + destination.mkdir(parents=True) + shutil.copyfile( + target_root / target / "release" / f"libtera_ffi.{extension}", + destination / f"libtera_ffi.{extension}", + ) + + +def generate_bindings( + root: Path, bundle: Path, logs: Path, env: dict[str, str] +) -> None: + generated = bundle / "generated" + generated.mkdir() + run( + root, + logs, + "generate-swift", + [ + "cargo", + "run", + "--manifest-path", + str(root / "Cargo.toml"), + "-p", + "tera_bindgen", + "--locked", + "--", + "generate", + str(bundle / "native/aarch64-apple-darwin/libtera_ffi.dylib"), + "--library", + "--language", + "swift", + "--metadata-no-deps", + "--out-dir", + str(generated), + "--config", + str(root / "core/crates/tera_ffi/uniffi.toml"), + ], + env, + ) + headers = bundle / "headers" + headers.mkdir() + shutil.copyfile(generated / "TeraFFI.h", headers / "TeraFFI.h") + shutil.copyfile(generated / "TeraFFI.modulemap", headers / "module.modulemap") + + +def package_framework( + root: Path, bundle: Path, logs: Path, env: dict[str, str] +) -> None: + argv = ["xcodebuild", "-create-xcframework"] + for target in artifacts.TARGETS[:2]: + argv += [ + "-library", + str(bundle / "native" / target / "libtera_ffi.a"), + "-headers", + str(bundle / "headers"), + ] + argv += ["-output", str(bundle / artifacts.FRAMEWORK)] + run(root, logs, "package-xcframework", argv, env) + + +def generate_api( + root: Path, + bundle: Path, + work: Path, + logs: Path, + env: dict[str, str], + deployment: str, +) -> None: + sdk = ( + source.command(root, ["xcrun", "--sdk", "iphonesimulator", "--show-sdk-path"]) + .decode() + .strip() + ) + module = work / "module" + symbols = work / "symbols" + module.mkdir() + symbols.mkdir() + common = [ + "-module-name", + artifacts.MODULE, + "-target", + f"arm64-apple-ios{deployment}-simulator", + "-sdk", + sdk, + "-I", + str(bundle / "headers"), + "-module-cache-path", + str(work / "module-cache"), + ] + run( + root, + logs, + "compile-swift-module", + [ + "xcrun", + "--sdk", + "iphonesimulator", + "swiftc", + "-emit-module", + "-parse-as-library", + *common, + "-emit-module-path", + str(module / f"{artifacts.MODULE}.swiftmodule"), + str(bundle / "generated/TeraKitBindings.swift"), + ], + env, + ) + run( + root, + logs, + "extract-swift-api", + [ + "xcrun", + "--sdk", + "iphonesimulator", + "swift-symbolgraph-extract", + *common, + "-I", + str(module), + "-minimum-access-level", + "public", + "-skip-inherited-docs", + "-skip-synthesized-members", + "-output-dir", + str(symbols), + ], + env, + ) + graph = json.loads((symbols / f"{artifacts.MODULE}.symbols.json").read_text()) + output = normalize_api(graph) + (bundle / "api").mkdir() + (bundle / "api/TeraKitBindings.symbols.json").write_bytes( + (json.dumps(output, sort_keys=True, separators=(",", ":")) + "\n").encode() + ) + + +def normalize_api(graph: dict[str, Any]) -> dict[str, Any]: + symbols = [ + { + "kind": item["kind"]["identifier"], + "precise": item["identifier"]["precise"], + "path": item["pathComponents"], + "access": item["accessLevel"], + "declaration": item.get("declarationFragments"), + } + for item in graph["symbols"] + ] + relationships = [ + {key: item[key] for key in ("kind", "source", "target")} + for item in graph["relationships"] + ] + return { + "schema": "radroots.swift-api-snapshot.v1", + "generator": graph["metadata"]["generator"], + "module": graph["module"], + "symbols": sorted(symbols, key=lambda item: item["precise"]), + "relationships": sorted( + relationships, + key=lambda item: (item["kind"], item["source"], item["target"]), + ), + } + + +def record_abi(root: Path, bundle: Path, logs: Path, env: dict[str, str]) -> None: + symbols = {} + reader = provenance.symbol_reader( + root, source.producer_contract(root)["build"]["host"] + ) + for target in artifacts.TARGETS: + extension = "dylib" if target == artifacts.TARGETS[-1] else "a" + log = run( + root, + logs, + "symbols-" + target, + [ + str(reader), + "--extern-only", + "--just-symbol-name", + "--defined-only", + str(bundle / "native" / target / f"libtera_ffi.{extension}"), + ], + env, + ) + symbols[target] = sorted( + set( + re.findall( + r"^_((?:ffi|uniffi)_tera_ffi_[A-Za-z0-9_]+)$", + log.read_text(), + re.MULTILINE, + ) + ) + ) + (bundle / "abi_symbols.json").write_bytes(provenance.encoded(symbols)) + + +def capture_sources(root: Path) -> dict[str, dict[str, Any]]: + return {target: provenance.capture(root, target) for target in artifacts.TARGETS} + + +def build( + root: Path, + project: Path, + target_root: Path, + records: dict[str, dict[str, Any]], + destination: Path, +) -> dict[str, Any]: + config = source.producer_contract(root) + environment = build_environment(root, project, config) + destination.parent.mkdir(parents=True, exist_ok=True) + logs = Path(tempfile.mkdtemp(prefix="build-", dir=destination.parent)) + # Logs survive failure. Only this invocation's temporary workspace is cleaned. + with tempfile.TemporaryDirectory( + prefix="work-", dir=destination.parent + ) as temporary: + work = Path(temporary) + bundle = work / "bundle" + bundle.mkdir() + build_libraries(root, bundle, target_root, logs, environment) + generate_bindings(root, bundle, logs, environment) + package_framework(root, bundle, logs, environment) + generate_api( + root, + bundle, + work, + logs, + environment, + config["build"]["ios_deployment_target"], + ) + record_abi(root, bundle, logs, environment) + if capture_sources(root) != records: + raise source.ProvenanceError( + "producer changed during native artifact build" + ) + (bundle / "source").mkdir() + for target, record in records.items(): + (bundle / "source" / f"{target}.json").write_bytes( + provenance.encoded(record) + ) + result = artifacts.manifest(bundle, records) + (bundle / artifacts.MANIFEST).write_bytes(provenance.encoded(result)) + artifacts.check(bundle, records) + if destination.exists(): + raise source.ProvenanceError("native candidate destination already exists") + bundle.rename(destination) + return result + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("mode", choices=("build", "check")) + args = parser.parse_args() + root = Path(__file__).resolve().parent.parent + try: + project, target_root = build_roots(root) + records = capture_sources(root) + tree = records[artifacts.TARGETS[0]]["source"]["tree"] + destination = project / "target/tera_ffi/candidates" / tree + if args.mode == "build" and not destination.exists(): + build(root, project, target_root, records, destination) + artifacts.check(destination, records) + except ( + source.ProvenanceError, + contract.PackageContractError, + OSError, + ValueError, + KeyError, + ) as error: + print(f"native candidate: {error}", file=sys.stderr) + return 1 + print(f"native candidate {args.mode}: tree={tree}; {destination}; not installed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ffi_provenance.py b/scripts/ffi_provenance.py @@ -43,8 +43,14 @@ def capture(root: Path, target: str) -> dict[str, Any]: "target": target, "profile": build["profile"], "ios_deployment_target": build["ios_deployment_target"], + "rust_flags": build["rust_flags"], "source_date_epoch": build["source_date_epoch"], "rustc": rustc, + "symbol_reader": source.command( + root, [str(symbol_reader(root, build["host"])), "--version"] + ) + .decode() + .strip(), "apple_toolchain": apple_toolchain(root), "feature_graph": source.feature_graph( root, config["ffi"]["package"], target @@ -54,6 +60,7 @@ def capture(root: Path, target: str) -> dict[str, Any]: **config["generator"], "target": build["host"], "profile": "dev", + "profile_overrides": source.allowed_profile_overrides(), "feature_graph": source.feature_graph( root, config["generator"]["package"], build["host"] ), @@ -65,6 +72,16 @@ def capture(root: Path, target: str) -> dict[str, Any]: return result +def symbol_reader(root: Path, host: str) -> Path: + sysroot = Path( + source.command(root, ["rustc", "--print", "sysroot"]).decode().strip() + ) + reader = sysroot / "lib/rustlib" / host / "bin/llvm-nm" + if not reader.is_file() or not os.access(reader, os.X_OK): + raise source.ProvenanceError("Rust toolchain llvm-tools component is required") + return reader + + def apple_toolchain(root: Path) -> dict[str, str]: commands = { "xcode": ["xcodebuild", "-version"], diff --git a/scripts/ffi_source.py b/scripts/ffi_source.py @@ -113,6 +113,7 @@ def producer_contract(root: Path) -> dict[str, Any]: "toolchain": { "channel": value["build"]["rust_version"], "profile": "minimal", + "components": ["llvm-tools"], } }, "producer toolchain", @@ -128,6 +129,7 @@ def validate_build(value: Any) -> None: "rust_version", "profile", "ios_deployment_target", + "rust_flags", "source_date_epoch", "host", "targets", @@ -141,6 +143,15 @@ def validate_build(value: Any) -> None: ) contract._exact(value["host"], "aarch64-apple-darwin", "producer host") contract._exact( + value["rust_flags"], + [ + "--remap-path-prefix={producer_root}=/tera", + "--remap-path-prefix={cargo_home}=/cargo", + "--remap-path-prefix={extbuild_root}=/build", + ], + "producer Rust flags", + ) + contract._exact( value["targets"], [ "aarch64-apple-ios", @@ -320,12 +331,26 @@ def reject_build_overrides() -> None: if any(os.environ.get(name) for name in forbidden): raise ProvenanceError("ungoverned Rust build override is active") if any( - re.fullmatch(r"CARGO_(BUILD_.*|TARGET_.*_(RUSTFLAGS|LINKER|RUNNER))", name) + re.fullmatch( + r"CARGO_(BUILD_.*|PROFILE_.*|TARGET_.*_(RUSTFLAGS|LINKER|RUNNER))", name + ) + and name not in allowed_profile_overrides() for name in os.environ ): raise ProvenanceError("ungoverned Cargo build override is active") +def allowed_profile_overrides() -> dict[str, str]: + # Extbuild's development debug policy affects the generator, not release libraries. + name = "CARGO_PROFILE_DEV_DEBUG" + if ( + os.environ.get("EXT_BUILD_RUN_ACTIVE") + and os.environ.get(name) == "line-tables-only" + ): + return {name: "line-tables-only"} + return {} + + def feature_graph(root: Path, package: str, target: str) -> list[str]: raw = command( root, diff --git a/scripts/linux-shared-rust.sh b/scripts/linux-shared-rust.sh @@ -31,6 +31,7 @@ docker run --rm --init --platform linux/amd64 \ set -euo pipefail [[ "$(uname -s)" == "Linux" ]] [[ "$(uname -m)" == "x86_64" ]] - cargo check --workspace --all-targets --locked - cargo test --workspace --all-targets --locked + packages=(-p radroots_ios_source_lock -p tera_core -p tera_ffi -p tera_bindgen) + cargo check "${packages[@]}" --all-targets --locked + cargo test "${packages[@]}" --all-targets --locked ' diff --git a/scripts/swift-quality.sh b/scripts/swift-quality.sh @@ -21,6 +21,9 @@ readonly -a MAINTAINABILITY_RULES=( ) readonly -a PYTHON_QUALITY_PATHS=( scripts/ffi_source.py + scripts/ffi_artifacts.py + scripts/ffi_build.py + scripts/test_ffi_artifacts.py scripts/ffi_provenance.py scripts/test_ffi_provenance.py scripts/maintainability_ratchet.py diff --git a/scripts/test_ffi_artifacts.py b/scripts/test_ffi_artifacts.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import copy +import os +import plistlib +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +SCRIPTS = Path(__file__).resolve().parent +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + +import ffi_artifacts as artifacts # noqa: E402 +import ffi_build as builder # noqa: E402 +import ffi_provenance as provenance # noqa: E402 +import ffi_source as source # noqa: E402 +import package_contract as contract # noqa: E402 + + +class NativeArtifactTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name).resolve() + for relative in artifacts.expected_paths(): + path = self.root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"synthetic verifier fixture; not an executable library\n") + self.records = { + target: { + "repository": "https://github.com/radrootslabs/tera", + "source": {"tree": "a" * 40}, + "build": {"target": target}, + } + for target in artifacts.TARGETS + } + for target, record in self.records.items(): + (self.root / "source" / f"{target}.json").write_bytes( + provenance.encoded(record) + ) + self.install_headers() + self.install_framework_info() + symbols = {target: ["ffi_tera_ffi_fixture"] for target in artifacts.TARGETS} + (self.root / "abi_symbols.json").write_bytes(provenance.encoded(symbols)) + (self.root / "api/TeraKitBindings.symbols.json").write_bytes( + provenance.encoded( + { + "schema": "radroots.swift-api-snapshot.v1", + "module": {"name": artifacts.MODULE}, + "symbols": [{"synthetic": True}], + } + ) + ) + (self.root / artifacts.MANIFEST).write_bytes( + provenance.encoded(artifacts.manifest(self.root, self.records)) + ) + + def install_headers(self) -> None: + header = b"void ffi_tera_ffi_fixture(void);\n" + modulemap = b'module TeraFFI { header "TeraFFI.h" export * }\n' + for prefix in ( + "headers", + "TeraFFI.xcframework/ios-arm64/Headers", + "TeraFFI.xcframework/ios-arm64-simulator/Headers", + ): + (self.root / prefix / "TeraFFI.h").write_bytes(header) + (self.root / prefix / "module.modulemap").write_bytes(modulemap) + (self.root / "generated/TeraFFI.h").write_bytes(header) + (self.root / "generated/TeraFFI.modulemap").write_bytes(modulemap) + (self.root / "generated/TeraKitBindings.swift").write_text("import TeraFFI\n") + + def install_framework_info(self) -> None: + libraries = [] + for identifier, variant in ( + ("ios-arm64", None), + ("ios-arm64-simulator", "simulator"), + ): + record = { + "LibraryIdentifier": identifier, + "SupportedArchitectures": ["arm64"], + "SupportedPlatform": "ios", + "LibraryPath": "libtera_ffi.a", + "HeadersPath": "Headers", + } + if variant: + record["SupportedPlatformVariant"] = variant + libraries.append(record) + (self.root / "TeraFFI.xcframework/Info.plist").write_bytes( + plistlib.dumps({"AvailableLibraries": libraries}) + ) + + def test_consistent_fixture_is_checked_without_claiming_build_execution( + self, + ) -> None: + result = artifacts.check(self.root, self.records) + self.assertEqual(result["disposition"], "local_candidate_not_installed") + self.assertEqual(len(result["files"]), len(artifacts.expected_paths())) + + def test_tampered_or_missing_built_library_is_rejected(self) -> None: + path = self.root / "native/aarch64-apple-ios/libtera_ffi.a" + path.write_bytes(b"changed synthetic bytes") + with self.assertRaisesRegex(source.ProvenanceError, "differs from its built"): + artifacts.check(self.root, self.records) + path.unlink() + with self.assertRaisesRegex(source.ProvenanceError, "missing"): + artifacts.check(self.root, self.records) + + def test_mismatched_generated_and_packaged_headers_are_rejected(self) -> None: + (self.root / "headers/TeraFFI.h").write_text("void other(void);\n") + with self.assertRaisesRegex(source.ProvenanceError, "header differs"): + artifacts.check(self.root, self.records) + + def test_target_source_tuple_mismatch_is_rejected(self) -> None: + records = copy.deepcopy(self.records) + records[artifacts.TARGETS[0]]["build"]["target"] = artifacts.TARGETS[1] + with self.assertRaisesRegex(source.ProvenanceError, "tuples disagree"): + artifacts.check(self.root, records) + + def test_cross_target_abi_mismatch_is_rejected(self) -> None: + symbols = contract._read_json(self.root / "abi_symbols.json") + symbols[artifacts.TARGETS[0]] = ["ffi_tera_ffi_other"] + (self.root / "abi_symbols.json").write_bytes(provenance.encoded(symbols)) + with self.assertRaisesRegex(source.ProvenanceError, "ABI symbols differ"): + artifacts.check(self.root, self.records) + + def test_extra_files_including_nested_provenance_are_rejected(self) -> None: + (self.root / "headers/provenance.json").write_text("{}\n") + with self.assertRaisesRegex(source.ProvenanceError, "inventory differs"): + artifacts.check(self.root, self.records) + + def test_symlink_cannot_supply_artifact_bytes(self) -> None: + path = self.root / "native/aarch64-apple-ios/libtera_ffi.a" + path.unlink() + path.symlink_to(self.root / "native/aarch64-apple-ios-sim/libtera_ffi.a") + with self.assertRaisesRegex(source.ProvenanceError, "symlink"): + artifacts.check(self.root, self.records) + + def test_build_environment_applies_recorded_flags_and_foundation_identity( + self, + ) -> None: + config = source.producer_contract(SCRIPTS.parent) + with patch.dict(os.environ, {"RADROOTS_CONSUMER_REVISION": "f" * 40}): + environment = builder.build_environment(SCRIPTS.parent, self.root, config) + self.assertNotIn("RADROOTS_CONSUMER_REVISION", environment) + self.assertIn("=/tera", environment["CARGO_ENCODED_RUSTFLAGS"]) + self.assertEqual( + environment["RADROOTS_LIB_REVISION"], + contract._read_toml(SCRIPTS.parent / "radroots.lib.source-lock.v1.toml")[ + "revision" + ], + ) + self.assertEqual( + environment["SOURCE_DATE_EPOCH"], str(config["build"]["source_date_epoch"]) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify-package-contract.sh b/scripts/verify-package-contract.sh @@ -15,6 +15,7 @@ sh "$repo_root/scripts/ffi-provenance.sh" contract-check python -m unittest \ scripts/test_package_contract.py \ scripts/test_ffi_provenance.py \ + scripts/test_ffi_artifacts.py \ scripts/test_local_social_fixture.py )