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 c63002bcc4d3f6656e93aabe4fca6bd771376629
parent 90aff2dd3c0e5a602a6d113124feb9fe5c9fda0f
Author: triesap <tyson@radroots.org>
Date:   Tue,  1 Sep 2026 03:47:28 +0000

test(ios): structure package contract verification

- parse package inputs as bounded structured contracts
- execute fixture and package verifier behavior tests
- reject comment and unreachable-text false positives
- retire behavior-bearing grep assertions

Diffstat:
MREADME.md | 7+++++++
Ascripts/package_contract.py | 496+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/test_package_contract.py | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mscripts/verify-package-contract.sh | 317+++++--------------------------------------------------------------------------
4 files changed, 636 insertions(+), 300 deletions(-)

diff --git a/README.md b/README.md @@ -166,6 +166,13 @@ The Rust source lock, generated bindings, XCFramework hashes, provenance, Swift package locks, privacy manifests, and public API snapshots are checked as part of the release lane. +`make package-contract-check` evaluates the Swift package manifest and parses +the TOML, plist, JSON, xcconfig, project-package, and lock inputs as structured, +bounded data. It also runs the locked fixture and verifier unit suites. +Comments, examples, unreachable source, and arbitrary matching text cannot +satisfy a behavior-bearing package assertion; application behavior is proven +by the compiled Swift and simulator test lanes. + The unsigned release-evidence lane also regenerates a deterministic CycloneDX SBOM from the locked Rust and Swift dependency graphs and binds it to the checked-in locks, API snapshots, privacy inputs, Xcode project, XCFramework diff --git a/scripts/package_contract.py b/scripts/package_contract.py @@ -0,0 +1,496 @@ +#!/usr/bin/env python3 +"""Structured standalone package-contract verification for the iOS capsule.""" + +from __future__ import annotations + +import argparse +import json +import os +import plistlib +import re +import subprocess +import sys +import tempfile +import tomllib +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +MAX_CONTRACT_BYTES = 2 * 1024 * 1024 +GIT_REVISION = re.compile(r"^[0-9a-f]{40}$") +SHA256 = re.compile(r"^[0-9a-f]{64}$") +APPLE_KIT_REMOTE = "https://github.com/radrootslabs/apple_kit.git" +LIB_REMOTE = "https://github.com/radrootslabs/lib" +SECP256K1_REMOTE = "https://github.com/21-DOT-DEV/swift-secp256k1.git" +SECP256K1_REVISION = "e70a10e036a55fffea31568f0af92d69b6d449cd" + + +class PackageContractError(Exception): + """A stable, source-free package-contract rejection.""" + + +def _read_regular(path: Path, *, maximum: int = MAX_CONTRACT_BYTES) -> bytes: + try: + if path.is_symlink() or not path.is_file(): + raise PackageContractError("required contract input is not a regular file") + size = path.stat().st_size + if size < 0 or size > maximum: + raise PackageContractError("required contract input exceeds its byte limit") + value = path.read_bytes() + except OSError as error: + raise PackageContractError("required contract input cannot be read") from error + if len(value) != size: + raise PackageContractError("required contract input changed while reading") + return value + + +def _read_text(path: Path) -> str: + try: + return _read_regular(path).decode("utf-8") + except UnicodeDecodeError as error: + raise PackageContractError("required contract input is not UTF-8") from error + + +def _read_toml(path: Path) -> dict[str, Any]: + try: + value = tomllib.loads(_read_text(path)) + except tomllib.TOMLDecodeError as error: + raise PackageContractError("required TOML contract is malformed") from error + if not isinstance(value, dict): + raise PackageContractError("required TOML contract is not an object") + return value + + +def _read_json(path: Path) -> dict[str, Any]: + try: + value = json.loads(_read_text(path)) + except json.JSONDecodeError as error: + raise PackageContractError("required JSON contract is malformed") from error + if not isinstance(value, dict): + raise PackageContractError("required JSON contract is not an object") + return value + + +def _read_plist(path: Path) -> dict[str, Any]: + try: + value = plistlib.loads(_read_regular(path)) + except (plistlib.InvalidFileException, ValueError, TypeError) as error: + raise PackageContractError("required plist contract is malformed") from error + if not isinstance(value, dict): + raise PackageContractError("required plist contract is not a dictionary") + return value + + +def _mapping(value: object, key: str) -> Mapping[str, Any]: + if not isinstance(value, Mapping): + raise PackageContractError(f"structured contract field is invalid: {key}") + return value + + +def _exact(value: object, expected: object, key: str) -> None: + if value != expected: + raise PackageContractError(f"structured contract field differs: {key}") + + +def parse_make_assignments(text: str) -> dict[str, str]: + assignments: dict[str, str] = {} + expression = re.compile(r"^override ([A-Z0-9_]+) := ([^\r\n]+)$") + for line in text.splitlines(): + match = expression.fullmatch(line) + if match is None: + if line.strip() and not line.lstrip().startswith("#"): + raise PackageContractError( + "source-lock contains an unsupported statement" + ) + continue + key, value = match.groups() + if key in assignments: + raise PackageContractError("source-lock assignment is duplicated") + assignments[key] = value + return assignments + + +def parse_xcconfig_assignments(text: str) -> dict[str, str]: + assignments: dict[str, str] = {} + expression = re.compile(r"^([A-Z][A-Z0-9_]*)\s*=\s*(\S(?:.*\S)?)$") + for raw in text.splitlines(): + line = raw.strip() + if not line or line.startswith("//") or line.startswith("#"): + continue + match = expression.fullmatch(line) + if match is None: + raise PackageContractError("xcconfig contains an unsupported statement") + key, value = match.groups() + if key in assignments: + raise PackageContractError("xcconfig assignment is duplicated") + assignments[key] = value + return assignments + + +def parse_project_package(text: str, package_name: str) -> dict[str, str]: + lines = text.splitlines() + packages_line = next( + (index for index, line in enumerate(lines) if line == "packages:"), + None, + ) + if packages_line is None: + raise PackageContractError("project package inventory is absent") + expected_header = f" {package_name}:" + packages_end = next( + ( + index + for index in range(packages_line + 1, len(lines)) + if lines[index] and not lines[index].startswith((" ", "#")) + ), + len(lines), + ) + start = next( + ( + index + for index in range(packages_line + 1, packages_end) + if lines[index] == expected_header + ), + None, + ) + if start is None: + raise PackageContractError("project package entry is absent") + values: dict[str, str] = {} + for line in lines[start + 1 :]: + if line and not line.startswith(" "): + break + match = re.fullmatch(r" ([a-z_]+): (\S+)", line) + if match is None: + if line.strip(): + raise PackageContractError("project package entry is malformed") + continue + key, value = match.groups() + if key in values: + raise PackageContractError("project package field is duplicated") + values[key] = value + return values + + +def validate_resolved(document: dict[str, Any], apple_revision: str) -> None: + _exact(document.get("version"), 3, "package lock version") + pins = document.get("pins") + if not isinstance(pins, list) or len(pins) != 2: + raise PackageContractError("package lock pin inventory differs") + selected: dict[str, str] = {} + for pin in pins: + item = _mapping(pin, "package lock pin") + _exact(item.get("kind"), "remoteSourceControl", "package lock pin kind") + location = item.get("location") + state = _mapping(item.get("state"), "package lock pin state") + revision = state.get("revision") + if not isinstance(location, str) or not location.startswith("https://"): + raise PackageContractError("package lock location is invalid") + if not isinstance(revision, str) or GIT_REVISION.fullmatch(revision) is None: + raise PackageContractError("package lock revision is invalid") + if location in selected: + raise PackageContractError("package lock location is duplicated") + selected[location] = revision + _exact(selected.get(APPLE_KIT_REMOTE), apple_revision, "AppleKit package pin") + _exact( + selected.get(SECP256K1_REMOTE), + SECP256K1_REVISION, + "secp256k1 package pin", + ) + + +def _swift_package(repo_root: Path) -> dict[str, Any]: + with tempfile.TemporaryFile() as stdout, tempfile.TemporaryFile() as stderr: + try: + result = subprocess.run( + [ + "swift", + "package", + "--package-path", + str(repo_root), + "dump-package", + ], + check=False, + stdout=stdout, + stderr=stderr, + timeout=60, + ) + except (OSError, subprocess.TimeoutExpired) as error: + raise PackageContractError( + "Swift package manifest cannot be evaluated" + ) from error + stdout.seek(0) + output = stdout.read(MAX_CONTRACT_BYTES + 1) + if result.returncode != 0 or len(output) > MAX_CONTRACT_BYTES: + raise PackageContractError("Swift package manifest evaluation failed") + try: + value = json.loads(output) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise PackageContractError("Swift package manifest output is malformed") from error + if not isinstance(value, dict): + raise PackageContractError("Swift package manifest output is not an object") + return value + + +def _apple_revision(package: dict[str, Any]) -> str: + dependencies = package.get("dependencies") + if not isinstance(dependencies, list): + raise PackageContractError("Swift package dependencies are absent") + matches: list[str] = [] + for dependency in dependencies: + item = _mapping(dependency, "Swift package dependency") + source = item.get("sourceControl") + if not isinstance(source, list) or len(source) != 1: + continue + identity = _mapping(source[0], "Swift package source") + remote = identity.get("location") + revision = identity.get("requirement") + remote_values = remote.get("remote") if isinstance(remote, dict) else None + if ( + isinstance(remote_values, list) + and remote_values == [{"urlString": APPLE_KIT_REMOTE}] + and isinstance(revision, dict) + and isinstance(revision.get("revision"), list) + and len(revision["revision"]) == 1 + ): + matches.append(revision["revision"][0]) + if ( + len(matches) != 1 + or not isinstance(matches[0], str) + or GIT_REVISION.fullmatch(matches[0]) is None + ): + raise PackageContractError("AppleKit dependency is not one exact revision") + return matches[0] + + +def _validate_privacy(document: dict[str, Any]) -> None: + _exact(document.get("NSPrivacyTracking"), False, "privacy tracking") + _exact(document.get("NSPrivacyTrackingDomains"), [], "privacy tracking domains") + _exact(document.get("NSPrivacyCollectedDataTypes"), [], "privacy collected data") + _exact( + document.get("NSPrivacyAccessedAPITypes"), + [ + { + "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryUserDefaults", + "NSPrivacyAccessedAPITypeReasons": ["CA92.1"], + } + ], + "privacy accessed APIs", + ) + + +def _validate_app_plist(document: dict[str, Any]) -> None: + for key in ( + "NSCameraUsageDescription", + "NSFaceIDUsageDescription", + "NSLocalNetworkUsageDescription", + ): + value = document.get(key) + if not isinstance(value, str) or not value.strip(): + raise PackageContractError(f"required plist purpose is absent: {key}") + _exact( + document.get("NSAppTransportSecurity"), + {"NSAllowsLocalNetworking": True}, + "app transport security", + ) + for forbidden in ("NSBonjourServices", "NSPhotoLibraryUsageDescription"): + if forbidden in document: + raise PackageContractError(f"forbidden plist field is present: {forbidden}") + + +def _validate_ui_test_plist(document: dict[str, Any]) -> None: + required = { + "RADROOTS_IOS_UI_TEST_FIXTURE_CONTROL", + "RADROOTS_IOS_UI_TEST_FIXTURE_EVIDENCE", + "RADROOTS_IOS_UI_TEST_NETWORK_PROFILE", + "RADROOTS_IOS_UI_TEST_SOURCE_COMMIT", + "RADROOTS_IOS_UI_TEST_SOURCE_TREE", + "RADROOTS_IOS_UI_TEST_APP_BUILD_SHA256", + "RADROOTS_IOS_UI_TEST_SIMULATOR_ID", + } + if required.difference(document): + raise PackageContractError("UI test plist inventory is incomplete") + + +def verify(repo_root: Path) -> tuple[str, str]: + root = repo_root.resolve() + for forbidden in ("docs", ".github", ".act"): + path = root / forbidden + if path.exists() or path.is_symlink(): + raise PackageContractError("forbidden public repository root exists") + + cargo = _read_toml(root / "Cargo.toml") + workspace = _mapping(cargo.get("workspace"), "Cargo workspace") + workspace_package = _mapping(workspace.get("package"), "Cargo workspace package") + _exact( + workspace_package.get("repository"), + "https://github.com/radrootslabs/tera", + "Cargo repository", + ) + ffi_dependency = _mapping( + _mapping(workspace.get("dependencies"), "Cargo workspace dependencies").get( + "radroots_mobile_ffi" + ), + "Cargo FFI dependency", + ) + + source = parse_make_assignments(_read_text(root / "RadrootsFFI/source.lock")) + required_source = { + "RADROOTS_FIELD_LIB_GIT_URL", + "RADROOTS_FIELD_LIB_GIT_REV", + "RADROOTS_FIELD_FFI_CRATE_VERSION", + "RADROOTS_FIELD_SOURCE_DATE_EPOCH", + "RADROOTS_FIELD_FFI_DEVICE_SHA256", + "RADROOTS_FIELD_FFI_SIMULATOR_SHA256", + "RADROOTS_FIELD_FFI_SWIFT_SHA256", + "RADROOTS_FIELD_FFI_HEADER_SHA256", + "RADROOTS_FIELD_FFI_MODULEMAP_SHA256", + "RADROOTS_FIELD_FFI_API_SHA256", + "RADROOTS_FIELD_FFI_XCFRAMEWORK_SHA256", + } + if set(source) != required_source: + raise PackageContractError("FFI source-lock field inventory differs") + _exact(source["RADROOTS_FIELD_LIB_GIT_URL"], LIB_REMOTE, "FFI Lib remote") + lib_revision = source["RADROOTS_FIELD_LIB_GIT_REV"] + if GIT_REVISION.fullmatch(lib_revision) is None: + raise PackageContractError("FFI Lib revision is invalid") + for key in required_source: + if key.endswith("SHA256") and SHA256.fullmatch(source[key]) is None: + raise PackageContractError("FFI source-lock digest is invalid") + release_version = source["RADROOTS_FIELD_FFI_CRATE_VERSION"] + _exact(release_version, "0.1.0-alpha", "FFI release version") + if set(ffi_dependency) != {"git", "rev", "version"}: + raise PackageContractError("Cargo FFI dependency field inventory differs") + _exact(ffi_dependency.get("git"), LIB_REMOTE, "Cargo FFI remote") + _exact(ffi_dependency.get("rev"), lib_revision, "Cargo FFI revision") + _exact(ffi_dependency.get("version"), "=0.1.0-alpha", "Cargo FFI version") + epoch = source["RADROOTS_FIELD_SOURCE_DATE_EPOCH"] + if not epoch.isascii() or not epoch.isdecimal() or int(epoch) <= 0: + raise PackageContractError("FFI source date epoch is invalid") + + consumer = _read_toml(root / "radroots.lib.source-lock.v1.toml") + _exact(consumer.get("repository"), LIB_REMOTE, "consumer Lib remote") + _exact(consumer.get("revision"), lib_revision, "consumer Lib revision") + _exact(consumer.get("version"), release_version, "consumer Lib version") + + package = _swift_package(root) + _exact(package.get("name"), "radroots_ios_app", "Swift package name") + _exact(package.get("defaultLocalization"), "en", "Swift localization") + apple_revision = _apple_revision(package) + project = parse_project_package(_read_text(root / "project.yml"), "RadrootsKit") + if set(project) != {"url", "revision"}: + raise PackageContractError("project AppleKit field inventory differs") + _exact(project.get("url"), APPLE_KIT_REMOTE, "project AppleKit remote") + _exact(project.get("revision"), apple_revision, "project AppleKit revision") + + _validate_privacy(_read_plist(root / "Radroots/Resources/PrivacyInfo.xcprivacy")) + _validate_app_plist(_read_plist(root / "Radroots/Info.plist")) + _validate_ui_test_plist(_read_plist(root / "RadrootsUITests/Info.plist")) + + base = parse_xcconfig_assignments(_read_text(root / "Radroots/Config/Base.xcconfig")) + debug = parse_xcconfig_assignments(_read_text(root / "Radroots/Config/Debug.xcconfig")) + if set(base) != { + "RADROOTS_FIELD_IOS_RUNTIME_MODE", + "RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS", + "RADROOTS_FIELD_IOS_BLOSSOM_ORIGINS", + "RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX", + }: + raise PackageContractError("base xcconfig field inventory differs") + if set(debug) != { + "RADROOTS_FIELD_IOS_RUNTIME_MODE", + "PRODUCT_BUNDLE_IDENTIFIER", + "RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS", + "RADROOTS_FIELD_IOS_BLOSSOM_ORIGINS", + "RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX", + }: + raise PackageContractError("debug xcconfig field inventory differs") + _exact( + base.get("RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS"), + "wss:$(SLASH)$(SLASH)radroots.org$(SLASH)", + "base relay", + ) + _exact( + base.get("RADROOTS_FIELD_IOS_BLOSSOM_ORIGINS"), + "https:$(SLASH)$(SLASH)blossom.radroots.org", + "base Blossom origin", + ) + _exact( + debug.get("RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS"), + "ws:$(SLASH)$(SLASH)127.0.0.1:21000", + "debug relay", + ) + _exact( + debug.get("RADROOTS_FIELD_IOS_BLOSSOM_ORIGINS"), + "http:$(SLASH)$(SLASH)127.0.0.1:21100", + "debug Blossom origin", + ) + + resolved_paths = ( + root / "Package.resolved", + root / "Radroots.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + ) + resolved = [_read_json(path) for path in resolved_paths] + for document in resolved: + validate_resolved(document, apple_revision) + if resolved[0].get("pins") != resolved[1].get("pins"): + raise PackageContractError("Swift and Xcode package locks disagree") + + verifier_project = _read_toml(root / "scripts/persona-verifier/pyproject.toml") + verifier_lock = _read_toml(root / "scripts/persona-verifier/uv.lock") + verifier_metadata = _mapping(verifier_project.get("project"), "verifier project") + _exact( + verifier_metadata.get("requires-python"), + "==3.14.7", + "verifier Python", + ) + _exact( + verifier_metadata.get("dependencies"), + ["jsonschema==4.26.0"], + "verifier dependencies", + ) + _exact(verifier_lock.get("requires-python"), "==3.14.7", "verifier lock Python") + package_rows = verifier_lock.get("package") + if not isinstance(package_rows, list): + raise PackageContractError("verifier lock package inventory is invalid") + locked_packages = { + item.get("name"): item.get("version") + for item in package_rows + if isinstance(item, dict) + } + _exact(locked_packages.get("jsonschema"), "4.26.0", "verifier jsonschema lock") + + required_files = ( + ".swiftformat", + ".swiftlint.yml", + "scripts/local-social-fixture.py", + "scripts/swift-quality.sh", + "scripts/linux-shared-rust.sh", + "test-fixtures/bud11-upload-authorization-mutations.v1.json", + "test-fixtures/bud11-upload-authorization-mutations.v1.schema.json", + "test-fixtures/local-social-personas.v1.json", + "test-fixtures/local-social-personas.v1.schema.json", + "test-fixtures/local-social-persona-results.v1.schema.json", + "test-fixtures/local-social-persona-attempt-evidence.v1.schema.json", + "test-fixtures/local-social-persona-results.v2.schema.json", + ) + for relative in required_files: + _read_regular(root / relative) + for relative in ("scripts/swift-quality.sh", "scripts/linux-shared-rust.sh"): + if not os.access(root / relative, os.X_OK): + raise PackageContractError("required package command is not executable") + return release_version, apple_revision + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--repo-root", type=Path, required=True) + arguments = parser.parse_args(argv) + try: + version, apple_revision = verify(arguments.repo_root) + except PackageContractError as error: + print(f"package_contract: {error}", file=sys.stderr) + return 1 + print(f"package contracts agree at {version}; apple_kit@{apple_revision}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_package_contract.py b/scripts/test_package_contract.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import copy +import json +import plistlib +import sys +import unittest +from pathlib import Path + +SCRIPTS = Path(__file__).resolve().parent +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + +import package_contract as contract + + +class PackageContractTests(unittest.TestCase): + def test_current_package_contract_is_structurally_exact(self) -> None: + version, revision = contract.verify(SCRIPTS.parent) + self.assertEqual(version, "0.1.0-alpha") + self.assertRegex(revision, r"^[0-9a-f]{40}$") + + def test_comment_token_does_not_define_xcconfig_field(self) -> None: + values = contract.parse_xcconfig_assignments( + "// RADROOTS_FIELD_IOS_RUNTIME_MODE = production\n" + ) + self.assertNotIn("RADROOTS_FIELD_IOS_RUNTIME_MODE", values) + + def test_dead_metadata_text_does_not_define_cargo_repository(self) -> None: + document = { + "workspace": { + "package": {}, + "metadata": { + "example": 'repository = "https://github.com/radrootslabs/tera"' + }, + } + } + package = contract._mapping(document["workspace"]["package"], "package") + with self.assertRaisesRegex(contract.PackageContractError, "differs"): + contract._exact( + package.get("repository"), + "https://github.com/radrootslabs/tera", + "Cargo repository", + ) + + def test_plist_value_token_does_not_define_required_key(self) -> None: + document = plistlib.loads( + plistlib.dumps( + { + "Comment": ( + "NSCameraUsageDescription NSFaceIDUsageDescription " + "NSLocalNetworkUsageDescription" + ), + "NSAppTransportSecurity": {"NSAllowsLocalNetworking": True}, + } + ) + ) + with self.assertRaisesRegex(contract.PackageContractError, "purpose"): + contract._validate_app_plist(document) + + def test_duplicate_source_lock_assignment_is_rejected(self) -> None: + with self.assertRaisesRegex(contract.PackageContractError, "duplicated"): + contract.parse_make_assignments( + "override RADROOTS_FIELD_LIB_GIT_REV := " + "a" * 40 + "\n" + "override RADROOTS_FIELD_LIB_GIT_REV := " + "b" * 40 + "\n" + ) + + def test_source_lock_dead_assignment_is_rejected(self) -> None: + with self.assertRaisesRegex(contract.PackageContractError, "unsupported"): + contract.parse_make_assignments( + "ifneq ($(UNREACHABLE),)\n" + "override RADROOTS_FIELD_LIB_GIT_REV := " + + "a" * 40 + + "\nendif\n" + ) + + def test_duplicate_xcconfig_assignment_is_rejected(self) -> None: + with self.assertRaisesRegex(contract.PackageContractError, "duplicated"): + contract.parse_xcconfig_assignments("FIELD = one\nFIELD = two\n") + + def test_package_lock_rejects_pin_drift(self) -> None: + document = json.loads( + (SCRIPTS.parent / "Package.resolved").read_text(encoding="utf-8") + ) + apple_revision = next( + pin["state"]["revision"] + for pin in document["pins"] + if pin["location"] == contract.APPLE_KIT_REMOTE + ) + drifted = copy.deepcopy(document) + drifted["pins"][0]["state"]["revision"] = "f" * 40 + with self.assertRaises(contract.PackageContractError): + contract.validate_resolved(drifted, apple_revision) + + def test_project_package_comment_does_not_define_entry(self) -> None: + with self.assertRaisesRegex(contract.PackageContractError, "absent"): + contract.parse_project_package( + "packages:\n# RadrootsKit:\n# url: " + + contract.APPLE_KIT_REMOTE + + "\n", + "RadrootsKit", + ) + + def test_project_package_dead_section_does_not_define_entry(self) -> None: + with self.assertRaisesRegex(contract.PackageContractError, "absent"): + contract.parse_project_package( + "packages:\n RadrootsApp:\n path: .\n" + "targets:\n RadrootsKit:\n url: " + + contract.APPLE_KIT_REMOTE + + "\n", + "RadrootsKit", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify-package-contract.sh b/scripts/verify-package-contract.sh @@ -2,309 +2,26 @@ set -eu repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) -package="$repo_root/Package.swift" -source_lock="$repo_root/RadrootsFFI/source.lock" -consumer_lock="$repo_root/radroots.lib.source-lock.v1.toml" +python_project="$repo_root/scripts/persona-verifier" -grep -Fq 'repository = "https://github.com/radrootslabs/tera"' "$repo_root/Cargo.toml" -grep -Fq '9F54FC4930051FC4611B37D3 /* ios_app */' \ - "$repo_root/Radroots.xcodeproj/project.pbxproj" +uv run --project "$python_project" --offline --frozen \ + python "$repo_root/scripts/package_contract.py" --repo-root "$repo_root" -for forbidden_root in docs .github .act -do - if [ -e "$repo_root/$forbidden_root" ] || [ -L "$repo_root/$forbidden_root" ]; then - echo "error: forbidden public repository root exists: $forbidden_root" >&2 - exit 1 - fi -done - -make_value() { - key=$1 - awk -v key="$key" '$2 == key && $3 == ":=" { print $4 }' "$source_lock" -} - -release_version=$(make_value RADROOTS_FIELD_FFI_CRATE_VERSION) -lib_revision=$(make_value RADROOTS_FIELD_LIB_GIT_REV) -apple_revision=$(sed -n '/url: "https:\/\/github.com\/radrootslabs\/apple_kit.git"/{n;s/.*revision: "\([0-9a-f]*\)".*/\1/p;}' "$package") - -if ! printf '%s\n' "$apple_revision" | grep -Eq '^[0-9a-f]{40}$'; then - echo "error: apple_kit must use an exact 40-character Git revision" >&2 - exit 1 -fi - -grep -Fq "repository = \"https://github.com/radrootslabs/lib\"" "$consumer_lock" -grep -Fq "revision = \"$lib_revision\"" "$consumer_lock" -grep -Fq "version = \"$release_version\"" "$consumer_lock" -grep -Fq "public static let version = \"$release_version\"" "$repo_root/Radroots/App/AppEntry.swift" -grep -Fq "revision: $apple_revision" "$repo_root/project.yml" -grep -Fq "XCTAssertEqual(RadrootsAppRelease.version, \"$release_version\")" \ - "$repo_root/RadrootsPublicAPITests/RadrootsAppPublicAPITests.swift" -grep -Fq "NSPrivacyAccessedAPICategoryUserDefaults" \ - "$repo_root/Radroots/Resources/PrivacyInfo.xcprivacy" -grep -Fq "CA92.1" "$repo_root/Radroots/Resources/PrivacyInfo.xcprivacy" -plutil -lint "$repo_root/Radroots/Resources/PrivacyInfo.xcprivacy" >/dev/null -plutil -lint "$repo_root/Radroots/Info.plist" >/dev/null -plutil -lint "$repo_root/RadrootsUITests/Info.plist" >/dev/null - -grep -Fq '<key>NSCameraUsageDescription</key>' "$repo_root/Radroots/Info.plist" -grep -Fq '<key>NSFaceIDUsageDescription</key>' "$repo_root/Radroots/Info.plist" -grep -Fq '<key>NSLocalNetworkUsageDescription</key>' "$repo_root/Radroots/Info.plist" -if ! plutil -extract NSAppTransportSecurity json -o - "$repo_root/Radroots/Info.plist" \ - | jq -e ' - type == "object" and - keys == ["NSAllowsLocalNetworking"] and - .NSAllowsLocalNetworking == true - ' >/dev/null -then - echo "error: ATS must allow only explicitly selected local-network development" >&2 - exit 1 -fi -if grep -Fq '<key>NSBonjourServices</key>' "$repo_root/Radroots/Info.plist"; then - echo "error: physical-device development must not enable Bonjour discovery" >&2 - exit 1 -fi -if grep -Fq '<key>NSPhotoLibraryUsageDescription</key>' "$repo_root/Radroots/Info.plist"; then - echo "error: PHPicker must not claim broad photo-library access" >&2 - exit 1 -fi - -grep -Fq 'RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS = ws:$(SLASH)$(SLASH)127.0.0.1:21000' \ - "$repo_root/Radroots/Config/Debug.xcconfig" -grep -Fq 'RADROOTS_FIELD_IOS_BLOSSOM_ORIGINS = http:$(SLASH)$(SLASH)127.0.0.1:21100' \ - "$repo_root/Radroots/Config/Debug.xcconfig" -grep -Fq 'RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS = wss:$(SLASH)$(SLASH)radroots.org$(SLASH)' \ - "$repo_root/Radroots/Config/Base.xcconfig" -grep -Fq 'RADROOTS_FIELD_IOS_BLOSSOM_ORIGINS = https:$(SLASH)$(SLASH)blossom.radroots.org' \ - "$repo_root/Radroots/Config/Base.xcconfig" -grep -Fq '<key>RADROOTS_IOS_UI_TEST_FIXTURE_CONTROL</key>' \ - "$repo_root/RadrootsUITests/Info.plist" -grep -Fq '<key>RADROOTS_IOS_UI_TEST_FIXTURE_EVIDENCE</key>' \ - "$repo_root/RadrootsUITests/Info.plist" -grep -Fq '<key>RADROOTS_IOS_UI_TEST_NETWORK_PROFILE</key>' \ - "$repo_root/RadrootsUITests/Info.plist" -for evidence_key in \ - RADROOTS_IOS_UI_TEST_SOURCE_COMMIT \ - RADROOTS_IOS_UI_TEST_SOURCE_TREE \ - RADROOTS_IOS_UI_TEST_APP_BUILD_SHA256 \ - RADROOTS_IOS_UI_TEST_SIMULATOR_ID -do - grep -Fq "<key>$evidence_key</key>" "$repo_root/RadrootsUITests/Info.plist" -done -grep -Fq 'local-social-ui-test)' "$repo_root/scripts/xcode.sh" -grep -Fq 'RadrootsUITests/RadrootsRemoteQualificationUITests/testLocalSocialFiveFlowScenario' \ - "$repo_root/scripts/xcode.sh" -grep -Fq 'RadrootsUITests/RadrootsRemoteQualificationUITests/testLocalSocialAccessibilitySemantics' \ - "$repo_root/scripts/xcode.sh" -grep -Fq 'RadrootsUITests/RadrootsRemoteQualificationUITests/testLocalSocialDeterministicPersonas' \ - "$repo_root/scripts/xcode.sh" -grep -Fq 'requested_content_size=large' "$repo_root/scripts/xcode.sh" -grep -Fq 'requested_content_size=accessibility-extra-extra-extra-large' \ - "$repo_root/scripts/xcode.sh" -grep -Fq 'xcrun simctl ui "$simulator_id" content_size "$requested_content_size"' \ - "$repo_root/scripts/xcode.sh" -grep -Fq 'xcrun simctl ui "$simulator_id" content_size "$previous_content_size"' \ - "$repo_root/scripts/xcode.sh" -grep -Fq 'verify-accessibility' "$repo_root/scripts/local-social-fixture.py" -grep -Fq 'verify-persona-fixture' "$repo_root/scripts/local-social-fixture.py" -grep -Fq 'verify-bud11-corpus' "$repo_root/scripts/local-social-fixture.py" -grep -Fq 'verify-persona' "$repo_root/scripts/local-social-fixture.py" -grep -Fq 'verify-persona-result' "$repo_root/scripts/local-social-fixture.py" -grep -Fq 'RADROOTS_IOS_UI_TEST_FIXTURE_EVIDENCE' "$repo_root/scripts/xcode.sh" -grep -Fq 'LoopbackConnectionFactory' "$repo_root/scripts/local-social-fixture.py" -grep -Fq 'attachments = extract_persona_attempt_attachments(' \ - "$repo_root/scripts/local-social-fixture.py" -grep -Fq 'result = reconstruct_persona_result_v2(' \ - "$repo_root/scripts/local-social-fixture.py" -grep -Fq -- '--attempt-schema test-fixtures/local-social-persona-attempt-evidence.v1.schema.json' \ - "$repo_root/scripts/xcode.sh" -grep -Fq -- '--result-v2-schema test-fixtures/local-social-persona-results.v2.schema.json' \ - "$repo_root/scripts/xcode.sh" -if rg -n 'pending_step_258' "$repo_root/RadrootsUITests" --glob '*.swift'; then - echo "error: XCUITest must emit measured network evidence" >&2 - exit 1 -fi -grep -Fq 'add.tap()' "$repo_root/RadrootsUITests/RadrootsRemoteQualificationUITests.swift" -if rg -n 'coordinate\(' "$repo_root/RadrootsUITests" --glob '*.swift'; then - echo "error: UI qualification must not use coordinate taps" >&2 - exit 1 -fi -for fixture in \ - bud11-upload-authorization-mutations.v1.json \ - bud11-upload-authorization-mutations.v1.schema.json \ - local-social-personas.v1.json \ - local-social-personas.v1.schema.json \ - local-social-persona-results.v1.schema.json \ - local-social-persona-attempt-evidence.v1.schema.json \ - local-social-persona-results.v2.schema.json -do - test -f "$repo_root/test-fixtures/$fixture" -done -sh "$repo_root/scripts/persona-verifier.sh" verify-bud11-corpus \ - --corpus "$repo_root/test-fixtures/bud11-upload-authorization-mutations.v1.json" \ - --schema "$repo_root/test-fixtures/bud11-upload-authorization-mutations.v1.schema.json" -sh "$repo_root/scripts/persona-verifier.sh" verify-persona-fixture \ - --fixture "$repo_root/test-fixtures/local-social-personas.v1.json" \ - --fixture-schema "$repo_root/test-fixtures/local-social-personas.v1.schema.json" \ - --result-schema "$repo_root/test-fixtures/local-social-persona-results.v1.schema.json" \ - --attempt-schema "$repo_root/test-fixtures/local-social-persona-attempt-evidence.v1.schema.json" \ - --result-v2-schema "$repo_root/test-fixtures/local-social-persona-results.v2.schema.json" -grep -Fq 'RadrootsUITests/testLocalSocialDeterministicPersonas' \ - "$repo_root/scripts/local-social-fixture.py" -grep -Fq 'radroots.ios.local-social.persona-attempt-evidence.v1' \ - "$repo_root/RadrootsUITests/RadrootsRemoteQualificationUITests.swift" ( - cd "$repo_root" - uv run --project scripts/persona-verifier --offline --frozen \ - python -m unittest scripts/test_local_social_fixture.py + cd "$repo_root" + uv run --project "$python_project" --offline --frozen \ + python -m unittest \ + scripts/test_package_contract.py \ + scripts/test_local_social_fixture.py ) -test -f "$repo_root/scripts/persona-verifier/pyproject.toml" -test -f "$repo_root/scripts/persona-verifier/uv.lock" -grep -Fq 'requires-python = "==3.14.7"' \ - "$repo_root/scripts/persona-verifier/pyproject.toml" -grep -Fq 'jsonschema==4.26.0' \ - "$repo_root/scripts/persona-verifier/pyproject.toml" -grep -Fq 'requires-python = "==3.14.7"' \ - "$repo_root/scripts/persona-verifier/uv.lock" -grep -Fq 'name = "jsonschema"' "$repo_root/scripts/persona-verifier/uv.lock" -grep -Fq 'version = "4.26.0"' "$repo_root/scripts/persona-verifier/uv.lock" -grep -Fq -- '--offline' "$repo_root/scripts/persona-verifier.sh" -grep -Fq -- '--frozen' "$repo_root/scripts/persona-verifier.sh" -grep -Fq 'sh scripts/persona-verifier.sh verify-persona-fixture' \ - "$repo_root/scripts/xcode.sh" -grep -Fq 'Draft202012Validator.check_schema(root)' \ - "$repo_root/scripts/local-social-fixture.py" -grep -Fq 'RESULT_BUNDLE_DIGEST_DOMAIN = b"radroots.ios.persona_result_bundle.v1\0"' \ - "$repo_root/scripts/local-social-fixture.py" -grep -Fq 'MAX_RESULT_BUNDLE_ENTRIES = 65_536' \ - "$repo_root/scripts/local-social-fixture.py" -if rg -n 'python3 scripts/local-social-fixture\.py' "$repo_root/scripts"; then - echo "error: persona verifier bypasses its locked Python environment" >&2 - exit 1 -fi -grep -Fq 'performAccessibilityAudit' \ - "$repo_root/RadrootsUITests/RadrootsRemoteQualificationUITests.swift" -grep -Fq 'element.identifier == "radroots.add.submit"' \ - "$repo_root/RadrootsUITests/RadrootsRemoteQualificationUITests.swift" -grep -Fq 'return issue.auditType == .textClipped' \ - "$repo_root/RadrootsUITests/RadrootsRemoteQualificationUITests.swift" -grep -Fq 'issue.auditType == .contrast || issue.auditType == .textClipped' \ - "$repo_root/RadrootsUITests/RadrootsRemoteQualificationUITests.swift" -grep -Fq -- '-UIAccessibilityReduceMotionEnabled' \ - "$repo_root/RadrootsUITests/RadrootsRemoteQualificationUITests.swift" -grep -Fq 'accessibility-extra-extra-extra-large' "$repo_root/scripts/xcode.sh" -grep -Fq '#if DEBUG' "$repo_root/Radroots/App/RadrootsRemoteQualification.swift" -grep -Fq 'case "simulator": self = .isolatedLoopback' \ - "$repo_root/Radroots/App/RadrootsRemoteQualification.swift" -grep -Fq 'case "public": self = .publicEndpoint' \ - "$repo_root/Radroots/App/RadrootsRemoteQualification.swift" -grep -Fq 'if let qualification, qualification.automatesIdentity' \ - "$repo_root/Radroots/State/RadrootsIdentityStore.swift" -grep -Fq 'qualification?.automatesIdentity == true' \ - "$repo_root/Radroots/State/RadrootsSessionStore.swift" -grep -Fq 'radroots.ios.remote-qualification.authorization-digest.v1\0' \ - "$repo_root/Radroots/App/RadrootsRemoteQualificationEvidence.swift" -grep -Fq 'try qualificationEvidenceStore?.cleanup()' \ - "$repo_root/Radroots/State/RadrootsSessionStore.swift" -if rg -n \ - 'try\?[[:space:]]+RadrootsRemoteQualificationEvidence|RadrootsRemoteQualificationBlossomAuthorization|data\.write\([[:space:]]*to:[^\n]*document' \ - "$repo_root/Radroots" \ - --glob '*.swift' -then - echo "error: qualification evidence may persist or ignore signed authorization material" >&2 - exit 1 -fi -if rg -n 'if qualification != nil|automatesQualificationIdentity: qualification != nil' \ - "$repo_root/Radroots" --glob '*.swift' -then - echo "error: qualification existence must not grant automated identity authority" >&2 - exit 1 -fi -test -f "$repo_root/scripts/local-social-fixture.py" -test -f "$repo_root/.swiftformat" -test -f "$repo_root/.swiftlint.yml" -test -x "$repo_root/scripts/swift-quality.sh" -test -x "$repo_root/scripts/linux-shared-rust.sh" -grep -Fq -- '--no-cache' "$repo_root/scripts/swift-quality.sh" -grep -Fq 'swift-quality: doctor' "$repo_root/Makefile" -grep -Fq 'linux-shared-rust: doctor' "$repo_root/Makefile" -grep -Fq 'verify: swift-quality linux-shared-rust' "$repo_root/Makefile" -grep -Fq 'persona-verifier-bootstrap: doctor' "$repo_root/Makefile" -grep -Fq 'bootstrap: persona-verifier-bootstrap' "$repo_root/Makefile" - -if rg -n \ - 'AsyncImage|URLSession\.shared|Data\(contentsOf:[[:space:]]*URL' \ - "$repo_root/Radroots" \ - --glob '*.swift' -then - echo "error: production Swift source contains an ungoverned media/network access path" >&2 - exit 1 -fi - -test -f "$repo_root/Radroots/Runtime/RadrootsUserMessages.swift" -test -f "$repo_root/Radroots/Resources/en.lproj/Localizable.strings" -grep -Fq 'enum RadrootsUserMessageKey: String, CaseIterable, Sendable' \ - "$repo_root/Radroots/Runtime/RadrootsUserMessages.swift" -grep -Fq 'RadrootsUserMessages.text(for: error, fallback:' \ - "$repo_root/Radroots/State/RadrootsAddStore.swift" -if rg -n '\(error as\? LocalizedError\)|\.localizedDescription' \ - "$repo_root/Radroots" --glob '*.swift' -then - echo "error: production UI projects a raw localized dependency error" >&2 - exit 1 -fi -if rg -n 'failure\.safeMessage' \ - "$repo_root/Radroots/App" \ - "$repo_root/Radroots/State" \ - "$repo_root/Radroots/Views" \ - "$repo_root/Radroots/Runtime/RadrootsLifecycleCoordinator.swift" \ - --glob '*.swift' -then - echo "error: presentation state bypasses the governed user-message catalog" >&2 - exit 1 -fi -test -f "$repo_root/Radroots/Runtime/RadrootsCheckedTime.swift" -grep -Fq 'UInt64(exactly: scaled.rounded(.down))' \ - "$repo_root/Radroots/Runtime/RadrootsCheckedTime.swift" -grep -Fq 'generation.addingReportingOverflow(1)' \ - "$repo_root/Radroots/Runtime/RadrootsCheckedTime.swift" -if rg -n \ - 'UInt64\([^[:cntrl:]]*timeIntervalSince1970|Int64\([^[:cntrl:]]*timeIntervalSince1970|\(upgraded\.generation \?\? 0\) \+ 1' \ - "$repo_root/Radroots" --glob '*.swift' -then - echo "error: production source bypasses checked time or generation conversion" >&2 - exit 1 -fi -for resolved in \ - "$repo_root/Package.resolved" \ - "$repo_root/Radroots.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved" -do - if [ ! -f "$resolved" ]; then - echo "error: missing Swift package lock: $resolved" >&2 - exit 1 - fi - jq -e --arg apple_revision "$apple_revision" ' - (.version == 3) and - (.pins | length == 2) and - (all(.pins[]; - (.kind == "remoteSourceControl") and - (.location | startswith("https://")) and - (.state.revision | test("^[0-9a-f]{40}$")))) and - (any(.pins[]; - .location == "https://github.com/radrootslabs/apple_kit.git" and - .state.revision == $apple_revision)) and - (any(.pins[]; - .location == "https://github.com/21-DOT-DEV/swift-secp256k1.git" and - .state.revision == "e70a10e036a55fffea31568f0af92d69b6d449cd")) - ' "$resolved" >/dev/null -done - -package_pins=$(jq -cS '.pins | sort_by(.identity)' "$repo_root/Package.resolved") -project_pins=$(jq -cS '.pins | sort_by(.identity)' \ - "$repo_root/Radroots.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved") -if [ "$package_pins" != "$project_pins" ]; then - echo "error: Swift package and Xcode project locks disagree" >&2 - exit 1 -fi +sh "$repo_root/scripts/persona-verifier.sh" verify-bud11-corpus \ + --corpus "$repo_root/test-fixtures/bud11-upload-authorization-mutations.v1.json" \ + --schema "$repo_root/test-fixtures/bud11-upload-authorization-mutations.v1.schema.json" -echo "package contracts agree at $release_version; apple_kit@$apple_revision" +sh "$repo_root/scripts/persona-verifier.sh" verify-persona-fixture \ + --fixture "$repo_root/test-fixtures/local-social-personas.v1.json" \ + --fixture-schema "$repo_root/test-fixtures/local-social-personas.v1.schema.json" \ + --result-schema "$repo_root/test-fixtures/local-social-persona-results.v1.schema.json" \ + --attempt-schema "$repo_root/test-fixtures/local-social-persona-attempt-evidence.v1.schema.json" \ + --result-v2-schema "$repo_root/test-fixtures/local-social-persona-results.v2.schema.json"