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_artifacts.py (8402B)


      1 """Exact staged native artifact inventory and source-tuple verification."""
      2 
      3 from __future__ import annotations
      4 
      5 import hashlib
      6 import plistlib
      7 import re
      8 from pathlib import Path
      9 from typing import Any
     10 
     11 import ffi_provenance as provenance
     12 import ffi_source as source
     13 import package_contract as contract
     14 
     15 TARGETS = ("aarch64-apple-ios", "aarch64-apple-ios-sim", "aarch64-apple-darwin")
     16 MAX_ARTIFACT_BYTES = 256 * 1024 * 1024
     17 FRAMEWORK = "TeraFFI.xcframework"
     18 MODULE = "TeraKitBindings"
     19 MANIFEST = "provenance.json"
     20 
     21 
     22 def expected_paths() -> set[str]:
     23     paths = {
     24         "generated/TeraKitBindings.swift",
     25         "generated/TeraFFI.h",
     26         "generated/TeraFFI.modulemap",
     27         "headers/TeraFFI.h",
     28         "headers/module.modulemap",
     29         f"{FRAMEWORK}/Info.plist",
     30         "api/TeraKitBindings.symbols.json",
     31         "abi_symbols.json",
     32     }
     33     for target in TARGETS:
     34         extension = "dylib" if target == TARGETS[-1] else "a"
     35         paths.add(f"native/{target}/libtera_ffi.{extension}")
     36         paths.add(f"source/{target}.json")
     37     for platform in ("ios-arm64", "ios-arm64-simulator"):
     38         for relative in (
     39             "libtera_ffi.a",
     40             "Headers/TeraFFI.h",
     41             "Headers/module.modulemap",
     42         ):
     43             paths.add(f"{FRAMEWORK}/{platform}/{relative}")
     44     return paths
     45 
     46 
     47 def regular_path(root: Path, relative: str) -> Path:
     48     path = Path(relative)
     49     if path.is_absolute() or str(path) != relative or ".." in path.parts:
     50         raise source.ProvenanceError("native artifact path is invalid")
     51     current = root
     52     for part in path.parts:
     53         current = current / part
     54         if current.is_symlink():
     55             raise source.ProvenanceError("native artifact path contains a symlink")
     56     if not current.is_file():
     57         raise source.ProvenanceError("native artifact file is missing")
     58     return current
     59 
     60 
     61 def file_record(root: Path, relative: str) -> dict[str, Any]:
     62     path = regular_path(root, relative)
     63     size = path.stat().st_size
     64     if size <= 0 or size > MAX_ARTIFACT_BYTES:
     65         raise source.ProvenanceError("native artifact exceeds its byte bound")
     66     digest = hashlib.sha256()
     67     with path.open("rb") as handle:
     68         while data := handle.read(1024 * 1024):
     69             digest.update(data)
     70     if path.stat().st_size != size:
     71         raise source.ProvenanceError("native artifact changed during verification")
     72     return {"path": relative, "bytes": size, "sha256": digest.hexdigest()}
     73 
     74 
     75 def inventory(root: Path) -> list[dict[str, Any]]:
     76     paths = set()
     77     for path in root.rglob("*"):
     78         if path.is_symlink():
     79             raise source.ProvenanceError("native artifact inventory contains a symlink")
     80         if path.is_file() and path.relative_to(root).as_posix() != MANIFEST:
     81             paths.add(path.relative_to(root).as_posix())
     82     if paths != expected_paths():
     83         raise source.ProvenanceError("native artifact inventory differs")
     84     return [file_record(root, relative) for relative in sorted(paths)]
     85 
     86 
     87 def validate_module_files(root: Path) -> None:
     88     generated = root / "generated"
     89     header = contract._read_regular(generated / "TeraFFI.h")
     90     modulemap = contract._read_regular(generated / "TeraFFI.modulemap")
     91     if b"module TeraFFI {" not in modulemap or b'header "TeraFFI.h"' not in modulemap:
     92         raise source.ProvenanceError("generated native module identity differs")
     93     swift = contract._read_regular(generated / "TeraKitBindings.swift")
     94     if b"import TeraFFI" not in swift:
     95         raise source.ProvenanceError("generated Swift module does not import its FFI")
     96     for prefix in (
     97         "headers",
     98         f"{FRAMEWORK}/ios-arm64/Headers",
     99         f"{FRAMEWORK}/ios-arm64-simulator/Headers",
    100     ):
    101         if contract._read_regular(root / prefix / "TeraFFI.h") != header:
    102             raise source.ProvenanceError(
    103                 "packaged FFI header differs from generated header"
    104             )
    105         if contract._read_regular(root / prefix / "module.modulemap") != modulemap:
    106             raise source.ProvenanceError(
    107                 "packaged FFI module map differs from generated module map"
    108             )
    109 
    110 
    111 def validate_framework(root: Path) -> None:
    112     info = plistlib.loads(contract._read_regular(root / FRAMEWORK / "Info.plist"))
    113     libraries = info.get("AvailableLibraries", [])
    114     expected = {
    115         "ios-arm64": None,
    116         "ios-arm64-simulator": "simulator",
    117     }
    118     if len(libraries) != 2 or {
    119         item.get("LibraryIdentifier") for item in libraries
    120     } != set(expected):
    121         raise source.ProvenanceError("XCFramework platform inventory differs")
    122     for item in libraries:
    123         validate_platform(item, expected[item["LibraryIdentifier"]])
    124     for target, platform in zip(TARGETS[:2], expected, strict=True):
    125         first = file_record(root, f"native/{target}/libtera_ffi.a")
    126         packaged = file_record(root, f"{FRAMEWORK}/{platform}/libtera_ffi.a")
    127         if (first["bytes"], first["sha256"]) != (packaged["bytes"], packaged["sha256"]):
    128             raise source.ProvenanceError(
    129                 "XCFramework library differs from its built target"
    130             )
    131 
    132 
    133 def validate_platform(item: dict[str, Any], variant: str | None) -> None:
    134     if (
    135         item.get("SupportedArchitectures") != ["arm64"]
    136         or item.get("SupportedPlatform") != "ios"
    137         or item.get("SupportedPlatformVariant") != variant
    138         or item.get("LibraryPath") != "libtera_ffi.a"
    139         or item.get("HeadersPath") != "Headers"
    140     ):
    141         raise source.ProvenanceError("XCFramework library contract differs")
    142 
    143 
    144 def validate_abi(root: Path) -> None:
    145     symbols = contract._read_json(root / "abi_symbols.json")
    146     if set(symbols) != set(TARGETS):
    147         raise source.ProvenanceError("native ABI target inventory differs")
    148     host = symbols[TARGETS[-1]]
    149     if not isinstance(host, list) or not host or host != sorted(set(host)):
    150         raise source.ProvenanceError("native ABI symbols are invalid")
    151     if any(symbols[target] != host for target in TARGETS):
    152         raise source.ProvenanceError("native target ABI symbols differ")
    153     header = contract._read_regular(root / "generated/TeraFFI.h").decode()
    154     declared = set(
    155         re.findall(r"\b((?:ffi|uniffi)_tera_ffi_[A-Za-z0-9_]+)\s*\(", header)
    156     )
    157     if not declared or not declared.issubset(host):
    158         raise source.ProvenanceError(
    159             "generated header declares an unavailable native symbol"
    160         )
    161 
    162 
    163 def manifest(root: Path, records: dict[str, dict[str, Any]]) -> dict[str, Any]:
    164     if set(records) != set(TARGETS):
    165         raise source.ProvenanceError("producer source target inventory differs")
    166     source_tree = records[TARGETS[0]]["source"]["tree"]
    167     for target, record in records.items():
    168         if (
    169             record["source"]["tree"] != source_tree
    170             or record["build"]["target"] != target
    171         ):
    172             raise source.ProvenanceError("producer source tuples disagree")
    173         provenance.verify_record(
    174             contract._read_regular(root / "source" / f"{target}.json"), record
    175         )
    176     validate_module_files(root)
    177     validate_framework(root)
    178     validate_abi(root)
    179     validate_api(root)
    180     return {
    181         "schema": "radroots.artifact-manifest.v2",
    182         "product": "tera",
    183         "target": "ios",
    184         "language": "swift",
    185         "external_names": ["TeraFFI", "TeraKitBindings"],
    186         "source": {
    187             "repository": records[TARGETS[0]]["repository"],
    188             "tree": source_tree,
    189         },
    190         "source_records": {target: f"source/{target}.json" for target in TARGETS},
    191         "files": inventory(root),
    192         "disposition": "local_candidate_not_installed",
    193     }
    194 
    195 
    196 def validate_api(root: Path) -> None:
    197     value = contract._read_json(root / "api/TeraKitBindings.symbols.json")
    198     if (
    199         value.get("schema") != "radroots.swift-api-snapshot.v1"
    200         or value.get("module", {}).get("name") != MODULE
    201         or not isinstance(value.get("symbols"), list)
    202         or not value["symbols"]
    203     ):
    204         raise source.ProvenanceError("generated Swift API snapshot identity differs")
    205 
    206 
    207 def check(root: Path, records: dict[str, dict[str, Any]]) -> dict[str, Any]:
    208     expected = manifest(root, records)
    209     if contract._read_regular(root / MANIFEST) != provenance.encoded(expected):
    210         raise source.ProvenanceError("native artifact provenance is stale")
    211     return expected