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