field_ios

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

ffi_source.py (13564B)


      1 """Bounded source capture for the application-owned native producer."""
      2 
      3 from __future__ import annotations
      4 
      5 import hashlib
      6 import os
      7 import re
      8 import stat
      9 import subprocess
     10 from pathlib import Path
     11 from typing import Any
     12 
     13 import package_contract as contract
     14 
     15 MAX_BYTES = 2 * 1024 * 1024
     16 MAX_INPUTS = 2048
     17 INPUTS = [
     18     "test-fixtures/legacy-identifiers.v1.json",
     19     "Cargo.toml",
     20     "Cargo.lock",
     21     "rust-toolchain.toml",
     22     "Makefile",
     23     "core",
     24     "scripts",
     25     "TeraFFI/Makefile",
     26     "TeraFFI/scripts",
     27     "TeraFFI/producer.toml",
     28     "radroots.lib.source-lock.v1.toml",
     29 ]
     30 PRODUCER_PATH = "TeraFFI/producer.toml"
     31 
     32 
     33 class ProvenanceError(Exception):
     34     """A source-free, fail-closed provenance rejection."""
     35 
     36 
     37 def command(root: Path, argv: list[str], data: bytes | None = None) -> bytes:
     38     if argv[0] == "git":
     39         argv = ["git", "--no-replace-objects", *argv[1:]]
     40     try:
     41         result = subprocess.run(
     42             argv, cwd=root, input=data, capture_output=True, check=False, timeout=120
     43         )
     44     except (OSError, subprocess.TimeoutExpired) as error:
     45         raise ProvenanceError("producer inspection command unavailable") from error
     46     if result.returncode or len(result.stdout) > MAX_BYTES:
     47         raise ProvenanceError("producer inspection command failed or exceeded bound")
     48     return result.stdout
     49 
     50 
     51 def read_source(root: Path, relative: str) -> bytes:
     52     path = Path(relative)
     53     if path.is_absolute() or str(path) != relative or ".." in path.parts:
     54         raise ProvenanceError("producer input path is invalid")
     55     current = root
     56     for part in path.parts:
     57         current = current / part
     58         if current.is_symlink():
     59             raise ProvenanceError("producer input contains a symlink")
     60     return contract._read_regular(current, maximum=MAX_BYTES)
     61 
     62 
     63 def producer_contract(root: Path) -> dict[str, Any]:
     64     value = contract._read_toml(root / PRODUCER_PATH)
     65     expected = {
     66         "schema",
     67         "repository",
     68         "foundation_lock",
     69         "source_inputs",
     70         "ffi",
     71         "generator",
     72         "build",
     73     }
     74     contract._exact(set(value), expected, "producer contract fields")
     75     contract._exact(value["schema"], "tera.native-producer.v1", "producer schema")
     76     contract._exact(
     77         value["repository"],
     78         "https://github.com/radrootslabs/tera",
     79         "producer repository",
     80     )
     81     contract._exact(value["source_inputs"], INPUTS, "producer input inventory")
     82     contract._exact(
     83         value["foundation_lock"],
     84         "radroots.lib.source-lock.v1.toml",
     85         "foundation lock path",
     86     )
     87     contract._exact(
     88         value["ffi"],
     89         {
     90             "package": "tera_ffi",
     91             "default_features": True,
     92             "features": [],
     93             "config": "core/crates/tera_ffi/uniffi.toml",
     94         },
     95         "FFI producer selection",
     96     )
     97     contract._exact(
     98         value["generator"],
     99         {
    100             "package": "tera_bindgen",
    101             "default_features": True,
    102             "features": [],
    103         },
    104         "generator selection",
    105     )
    106     validate_build(value["build"])
    107     cargo = contract._read_toml(root / "Cargo.toml")
    108     lock = contract._read_toml(root / value["foundation_lock"])
    109     validate_foundation(cargo, lock)
    110     contract._exact(
    111         lock["lockfile_sha256"],
    112         hashlib.sha256(read_source(root, "Cargo.lock")).hexdigest(),
    113         "foundation consumer Cargo lock digest",
    114     )
    115     toolchain = contract._read_toml(root / "rust-toolchain.toml")
    116     contract._exact(
    117         toolchain,
    118         {
    119             "toolchain": {
    120                 "channel": value["build"]["rust_version"],
    121                 "profile": "minimal",
    122                 "components": ["llvm-tools"],
    123             }
    124         },
    125         "producer toolchain",
    126     )
    127     return value
    128 
    129 
    130 def validate_build(value: Any) -> None:
    131     value = contract._mapping(value, "producer build")
    132     contract._exact(
    133         set(value),
    134         {
    135             "rust_version",
    136             "profile",
    137             "ios_deployment_target",
    138             "rust_flags",
    139             "source_date_epoch",
    140             "host",
    141             "host_dylib_install_name",
    142             "host_linker_reproducible",
    143             "host_oso_prefix",
    144             "targets",
    145         },
    146         "producer build fields",
    147     )
    148     contract._exact(value["rust_version"], "1.97.1", "producer Rust version")
    149     contract._exact(value["profile"], "release", "producer build profile")
    150     contract._exact(
    151         value["ios_deployment_target"], "18.0", "producer deployment target"
    152     )
    153     contract._exact(value["host"], "aarch64-apple-darwin", "producer host")
    154     contract._exact(
    155         value["host_dylib_install_name"],
    156         "@rpath/libtera_ffi.dylib",
    157         "producer host dylib install name",
    158     )
    159     contract._exact(
    160         value["host_linker_reproducible"] is True,
    161         True,
    162         "producer host linker reproducibility",
    163     )
    164     contract._exact(
    165         value["host_oso_prefix"], "{extbuild_root}", "producer host debug-map prefix"
    166     )
    167     contract._exact(
    168         value["rust_flags"],
    169         [
    170             "--remap-path-prefix={producer_root}=/tera",
    171             "--remap-path-prefix={cargo_home}=/cargo",
    172             "--remap-path-prefix={extbuild_root}=/build",
    173         ],
    174         "producer Rust flags",
    175     )
    176     contract._exact(
    177         value["targets"],
    178         [
    179             "aarch64-apple-ios",
    180             "aarch64-apple-ios-sim",
    181             "aarch64-apple-darwin",
    182         ],
    183         "producer targets",
    184     )
    185     if type(value["source_date_epoch"]) is not int or value["source_date_epoch"] <= 0:
    186         raise ProvenanceError("producer source epoch is invalid")
    187 
    188 
    189 def library_rust_flags(build: dict[str, Any], target: str) -> list[str]:
    190     if target == build["host"]:
    191         return [
    192             f"-Clink-arg=-Wl,-install_name,{build['host_dylib_install_name']}",
    193             "-Clink-arg=-Wl,-reproducible",
    194             f"-Clink-arg=-Wl,-oso_prefix,{build['host_oso_prefix']}",
    195         ]
    196     return []
    197 
    198 
    199 def validate_foundation(cargo: dict[str, Any], lock: dict[str, Any]) -> None:
    200     contract._exact(
    201         lock.get("schema"), "radroots.lib.source-lock.v1", "foundation schema"
    202     )
    203     contract._exact(lock.get("repository"), contract.LIB_REMOTE, "foundation remote")
    204     if not contract.GIT_REVISION.fullmatch(str(lock.get("revision", ""))):
    205         raise ProvenanceError("foundation revision is invalid")
    206     for field in (
    207         "workspace_catalog_sha256",
    208         "source_archive_sha256",
    209         "lockfile_sha256",
    210     ):
    211         if not contract.SHA256.fullmatch(str(lock.get(field, ""))):
    212             raise ProvenanceError("foundation digest is invalid")
    213     dependencies = cargo["workspace"]["dependencies"]
    214     selected = {
    215         name: value
    216         for name, value in dependencies.items()
    217         if name.startswith("radroots_")
    218     }
    219     if not selected:
    220         raise ProvenanceError("foundation dependency inventory is empty")
    221     for value in selected.values():
    222         value = contract._mapping(value, "foundation Cargo dependency")
    223         contract._exact(value.get("git"), lock["repository"], "foundation Cargo remote")
    224         contract._exact(value.get("rev"), lock["revision"], "foundation Cargo revision")
    225         contract._exact(
    226             value.get("version"), "=" + lock["version"], "foundation Cargo version"
    227         )
    228 
    229 
    230 def source_snapshot(root: Path, inputs: list[str]) -> dict[str, Any]:
    231     actual_root = (
    232         command(root, ["git", "rev-parse", "--show-toplevel"]).decode().strip()
    233     )
    234     if actual_root != str(root):
    235         raise ProvenanceError("producer source must use its own repository root")
    236     if command(
    237         root, ["git", "ls-files", "--others", "--exclude-standard", "-z", "--", *inputs]
    238     ):
    239         raise ProvenanceError("producer inputs contain untracked source")
    240     reject_ignored_source(root, inputs)
    241     raw = command(root, ["git", "ls-files", "--stage", "-z", "--", *inputs])
    242     rows = raw.rstrip(b"\0").split(b"\0") if raw else []
    243     if not rows or len(rows) > MAX_INPUTS:
    244         raise ProvenanceError("producer input inventory exceeds bounds")
    245     files: dict[str, Any] = {}
    246     tree: dict[str, Any] = {}
    247     for row in rows:
    248         relative, entry = staged_file(root, row)
    249         files[relative] = entry
    250         insert_tree(tree, relative, entry["mode"], entry["git_blob"])
    251     require_input_inventory(inputs, files)
    252     return {"policy": "staged_inputs", "tree": tree_identity(tree), "files": files}
    253 
    254 
    255 def require_input_inventory(inputs: list[str], files: dict[str, Any]) -> None:
    256     for relative in inputs:
    257         if not any(
    258             name == relative or name.startswith(relative + "/") for name in files
    259         ):
    260             raise ProvenanceError("required producer input is missing from index")
    261 
    262 
    263 def reject_ignored_source(root: Path, inputs: list[str]) -> None:
    264     ignored = command(
    265         root,
    266         [
    267             "git",
    268             "ls-files",
    269             "--others",
    270             "--ignored",
    271             "--exclude-standard",
    272             "-z",
    273             "--",
    274             *inputs,
    275             ":(exclude)scripts/persona-verifier/.venv/**",
    276             ":(exclude,glob)**/__pycache__/**",
    277         ],
    278     )
    279     if any(is_input(path, inputs) for path in ignored.decode().split("\0") if path):
    280         raise ProvenanceError(
    281             "producer inputs contain ignored source outside tool caches"
    282         )
    283 
    284 
    285 def is_input(relative: str, inputs: list[str]) -> bool:
    286     return any(relative == name or relative.startswith(name + "/") for name in inputs)
    287 
    288 
    289 def staged_file(root: Path, row: bytes) -> tuple[str, dict[str, Any]]:
    290     header, encoded_path = row.split(b"\t", 1)
    291     mode, oid, stage = header.decode("ascii").split()
    292     relative = encoded_path.decode("utf-8")
    293     if stage != "0" or mode not in ("100644", "100755"):
    294         raise ProvenanceError("producer input has unresolved or unsupported mode")
    295     data = read_source(root, relative)
    296     blob = command(root, ["git", "cat-file", "blob", oid])
    297     actual_mode = (
    298         "100755" if (root / relative).stat().st_mode & stat.S_IXUSR else "100644"
    299     )
    300     if data != blob or mode != actual_mode:
    301         raise ProvenanceError("producer worktree differs from staged source")
    302     return relative, {
    303         "mode": mode,
    304         "git_blob": oid,
    305         "bytes": len(data),
    306         "sha256": hashlib.sha256(data).hexdigest(),
    307     }
    308 
    309 
    310 def reject_cargo_configuration(root: Path) -> None:
    311     locations = [path / ".cargo" for path in (root, *root.parents)]
    312     locations.append(Path(os.environ.get("CARGO_HOME", str(Path.home() / ".cargo"))))
    313     for location in locations:
    314         for name in ("config", "config.toml"):
    315             path = location / name
    316             if path.exists():
    317                 value = contract._read_toml(path)
    318                 if {"build", "target", "env", "profile", "patch", "unstable"} & set(
    319                     value
    320                 ):
    321                     raise ProvenanceError(
    322                         "ungoverned Cargo configuration affects producer build"
    323                     )
    324 
    325 
    326 def insert_tree(tree: dict[str, Any], relative: str, mode: str, oid: str) -> None:
    327     parts = relative.split("/")
    328     for part in parts[:-1]:
    329         tree = tree.setdefault(part, {})
    330     tree[parts[-1]] = (mode, oid)
    331 
    332 
    333 def tree_identity(tree: dict[str, Any]) -> str:
    334     rows = []
    335     for name, value in tree.items():
    336         if isinstance(value, dict):
    337             mode, oid, key = "40000", tree_identity(value), name.encode() + b"/"
    338         else:
    339             mode, oid = value
    340             key = name.encode()
    341         rows.append((key, f"{mode} {name}".encode() + b"\0" + bytes.fromhex(oid)))
    342     body = b"".join(row for _, row in sorted(rows))
    343     return hashlib.sha1(
    344         b"tree " + str(len(body)).encode() + b"\0" + body, usedforsecurity=False
    345     ).hexdigest()
    346 
    347 
    348 def reject_build_overrides() -> None:
    349     forbidden = (
    350         "RUSTFLAGS",
    351         "RUSTC",
    352         "RUSTC_WRAPPER",
    353         "RUSTC_WORKSPACE_WRAPPER",
    354         "CARGO_ENCODED_RUSTFLAGS",
    355         "GIT_INDEX_FILE",
    356         "GIT_DIR",
    357         "GIT_WORK_TREE",
    358         "GIT_OBJECT_DIRECTORY",
    359         "GIT_ALTERNATE_OBJECT_DIRECTORIES",
    360         "GIT_CONFIG_COUNT",
    361         "GIT_CONFIG_PARAMETERS",
    362     )
    363     if any(os.environ.get(name) for name in forbidden):
    364         raise ProvenanceError("ungoverned Rust build override is active")
    365     if any(
    366         re.fullmatch(
    367             r"CARGO_(BUILD_.*|PROFILE_.*|TARGET_.*_(RUSTFLAGS|LINKER|RUNNER))", name
    368         )
    369         and name not in allowed_profile_overrides()
    370         for name in os.environ
    371     ):
    372         raise ProvenanceError("ungoverned Cargo build override is active")
    373 
    374 
    375 def allowed_profile_overrides() -> dict[str, str]:
    376     # Extbuild's development debug policy affects the generator, not release libraries.
    377     name = "CARGO_PROFILE_DEV_DEBUG"
    378     if (
    379         os.environ.get("EXT_BUILD_RUN_ACTIVE")
    380         and os.environ.get(name) == "line-tables-only"
    381     ):
    382         return {name: "line-tables-only"}
    383     return {}
    384 
    385 
    386 def feature_graph(root: Path, package: str, target: str) -> list[str]:
    387     raw = command(
    388         root,
    389         [
    390             "cargo",
    391             "tree",
    392             "--locked",
    393             "--offline",
    394             "-p",
    395             package,
    396             "--target",
    397             target,
    398             "--edges",
    399             "normal,build",
    400             "--prefix",
    401             "none",
    402             "--format",
    403             "{p}|{f}",
    404         ],
    405     ).decode()
    406     return sorted(set(raw.replace(str(root), "<producer-root>").splitlines()))