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:
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
)