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 1513b3e0981030fe6d7ee593b373521ce8fce3c2
parent 93ca054b46aefe1c64587e8b5466781368a4e07f
Author: triesap <tyson@radroots.org>
Date:   Mon, 31 Aug 2026 20:52:35 +0000

test(ios): harden persona evidence verifier

- bound JSON, subprocess, attachment, and bundle inputs
- frame and pin result-bundle content digests
- enforce schema meta-validation and corpus agreement
- lock the offline Python and jsonschema toolchain

Diffstat:
MAGENTS.md | 4++++
MMakefile | 7+++++--
MREADME.md | 11+++++++++++
Mscripts/local-social-fixture.py | 275++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Ascripts/persona-verifier.sh | 10++++++++++
Ascripts/persona-verifier/pyproject.toml | 8++++++++
Ascripts/persona-verifier/uv.lock | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mscripts/test_local_social_fixture.py | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mscripts/verify-package-contract.sh | 33++++++++++++++++++++++++++++++---
Mscripts/xcode.sh | 10+++++-----
10 files changed, 545 insertions(+), 75 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -76,6 +76,10 @@ This file applies to the complete standalone iOS app repository. A closer `cargo extbuild run -- ...`. - `make package-contract-check` is the narrow standalone source-lock, package-lock, privacy, version, and forbidden-root guard. +- Persona qualification must use `scripts/persona-verifier.sh`, the exact + Python and schema dependency lock under `scripts/persona-verifier/**`, and + offline frozen resolution. Populate the exact lock only through + `make persona-verifier-bootstrap`; do not bypass it with ambient Python. - `make swift-quality` applies the checked-in SwiftFormat and SwiftLint policy to repository-owned package, app, unit-test, API-test, and UI-test sources; generated bindings and dependency/build output are excluded. diff --git a/Makefile b/Makefile @@ -7,7 +7,7 @@ SIMULATOR_DESTINATION := platform=iOS Simulator,name=$(SIMULATOR_NAME) .NOTPARALLEL: -.PHONY: all doctor bootstrap ffi-bootstrap artifact-check package-contract-check \ +.PHONY: all doctor bootstrap persona-verifier-bootstrap ffi-bootstrap artifact-check package-contract-check \ swift-quality \ linux-shared-rust \ package-resolve package-build package-test project xcodegen xcode-resolve \ @@ -22,6 +22,9 @@ doctor: ffi-bootstrap: doctor cargo extbuild run -- $(MAKE) -C $(FFI_ROOT) verify +persona-verifier-bootstrap: doctor + cargo extbuild run -- uv sync --project scripts/persona-verifier --frozen + artifact-check: doctor cargo extbuild run -- $(FFI_ROOT)/scripts/verify-installed-artifacts.sh @@ -43,7 +46,7 @@ project xcodegen: doctor xcode-resolve: artifact-check project cargo extbuild run -- scripts/xcode.sh resolve -bootstrap: ffi-bootstrap package-resolve xcode-resolve +bootstrap: persona-verifier-bootstrap ffi-bootstrap package-resolve xcode-resolve package-build: artifact-check package-contract-check cargo extbuild run -- scripts/xcode.sh package-build diff --git a/README.md b/README.md @@ -137,6 +137,17 @@ executed UI path and monotonic fixture-snapshot deltas. The v2 result is reconstructed only from the exact 15 measured attachments and is cross-checked against the final fixture totals; a non-loopback attempt fails the run. +The standalone fixture and result verifier runs under the exact Python 3.14.7 +and `jsonschema` 4.26.0 environment locked in +`scripts/persona-verifier/uv.lock`. `make bootstrap` installs that exact lock +through extbuild once; qualification and package checks then use only +`--offline --frozen` resolution. Every JSON input is read with a +maximum-plus-one bound before decoding, schema files pass Draft 2020-12 +meta-validation, semantic fixtures must agree with their schemas, exported +attachment inventory is exact, and result-bundle hashing uses a +domain-separated length-framed preimage with bounded paths, entries, files, +and aggregate bytes. + The test uses ordinary visible controls, native-generated signing identities, real app stores, and generated Rust FFI. This is deterministic non-human conformance evidence. It does not claim human usability, demographic or diff --git a/scripts/local-social-fixture.py b/scripts/local-social-fixture.py @@ -7,6 +7,7 @@ import argparse import base64 import hashlib import http.server +import importlib.metadata import ipaddress import json import os @@ -14,8 +15,10 @@ import re import signal import socket import socketserver +import stat import subprocess import struct +import sys import tempfile import threading import time @@ -23,6 +26,9 @@ import unicodedata from pathlib import Path from typing import Any +from jsonschema import Draft202012Validator +from jsonschema.exceptions import SchemaError, ValidationError + MAX_HTTP_BODY = 16 * 1024 * 1024 MAX_WEBSOCKET_MESSAGE = 2 * 1024 * 1024 MAX_EVENTS = 256 @@ -90,6 +96,13 @@ PERSONA_XCRESULT_NODE_URL = ( MAX_XCRESULT_JSON_BYTES = 1024 * 1024 MAX_PERSONA_ATTACHMENT_BYTES = 64 * 1024 MAX_PERSONA_ATTACHMENTS_BYTES = 15 * MAX_PERSONA_ATTACHMENT_BYTES +MAX_RESULT_BUNDLE_ENTRIES = 65_536 +MAX_RESULT_BUNDLE_FILE_BYTES = 1024 * 1024 * 1024 +MAX_RESULT_BUNDLE_BYTES = 8 * 1024 * 1024 * 1024 +MAX_RESULT_BUNDLE_RELATIVE_PATH_BYTES = 1024 +RESULT_BUNDLE_DIGEST_DOMAIN = b"radroots.ios.persona_result_bundle.v1\0" +VERIFIER_PYTHON = (3, 14, 7) +VERIFIER_JSONSCHEMA = "4.26.0" PERSONA_ATTACHMENT_NAMES = tuple( f"radroots-local-social-P{persona:02d}-A{attempt:02d}.json" for persona in range(1, 6) @@ -110,7 +123,8 @@ def strict_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: def read_json(path: Path, maximum: int = MAX_JSON_BYTES) -> tuple[bytes, Any]: - raw = path.read_bytes() + with path.open("rb") as stream: + raw = stream.read(maximum + 1) if not raw or len(raw) > maximum: raise ValueError("JSON input is empty or exceeds its byte bound") value = json.loads(raw, object_pairs_hook=strict_object) @@ -118,12 +132,7 @@ def read_json(path: Path, maximum: int = MAX_JSON_BYTES) -> tuple[bytes, Any]: def read_json_bounded(path: Path, maximum: int) -> tuple[bytes, Any]: - with path.open("rb") as stream: - raw = stream.read(maximum + 1) - if not raw or len(raw) > maximum: - raise ValueError("JSON input is empty or exceeds its byte bound") - value = json.loads(raw, object_pairs_hook=strict_object) - return raw, value + return read_json(path, maximum) def exact_keys(value: Any, keys: set[str], name: str) -> dict[str, Any]: @@ -304,7 +313,14 @@ def load_persona_suite(path: Path) -> tuple[bytes, dict[str, Any]]: def validate_schema_file(path: Path, expected_id: str) -> bytes: + raw, _ = load_schema_file(path, expected_id) + return raw + + +def load_schema_file(path: Path, expected_id: str) -> tuple[bytes, dict[str, Any]]: raw, value = read_json(path) + if not isinstance(value, dict): + raise ValueError("schema boundary is invalid") root = exact_keys(value, set(value), "schema") if ( root.get("$schema") != "https://json-schema.org/draft/2020-12/schema" @@ -313,7 +329,49 @@ def validate_schema_file(path: Path, expected_id: str) -> bytes: or root.get("additionalProperties") is not False ): raise ValueError("schema boundary is invalid") - return raw + if schema_contains_external_reference(root): + raise ValueError("schema contains an external reference") + try: + Draft202012Validator.check_schema(root) + except SchemaError as error: + raise ValueError("schema fails Draft 2020-12 meta-validation") from error + return raw, root + + +def schema_contains_external_reference(value: Any) -> bool: + if isinstance(value, dict): + for key, item in value.items(): + if key in {"$ref", "$dynamicRef"} and ( + not isinstance(item, str) or not item.startswith("#/") + ): + return True + if schema_contains_external_reference(item): + return True + elif isinstance(value, list): + return any(schema_contains_external_reference(item) for item in value) + return False + + +def validate_schema_instance( + schema: dict[str, Any], value: Any, name: str +) -> None: + try: + Draft202012Validator(schema).validate(value) + except ValidationError as error: + raise ValueError(f"{name} disagrees with its JSON schema") from error + + +def verify_toolchain_identity() -> None: + if sys.version_info[:3] != VERIFIER_PYTHON: + raise RuntimeError("persona verifier Python identity is unavailable") + try: + schema_version = importlib.metadata.version("jsonschema") + except importlib.metadata.PackageNotFoundError as error: + raise RuntimeError( + "persona verifier schema dependency is unavailable" + ) from error + if schema_version != VERIFIER_JSONSCHEMA: + raise RuntimeError("persona verifier schema dependency is unavailable") def persona_attempts(suite: dict[str, Any]) -> dict[str, dict[str, Any]]: @@ -1272,7 +1330,7 @@ def serve(arguments: argparse.Namespace) -> int: def verify(arguments: argparse.Namespace) -> int: - payload = json.loads(Path(arguments.evidence).read_text(encoding="utf-8")) + _, payload = read_json(Path(arguments.evidence).resolve()) if ( payload.get("schema") != "radroots-ios-local-social-fixture-evidence-v1" or payload.get("accepted_events", 0) < 5 @@ -1289,7 +1347,7 @@ def verify(arguments: argparse.Namespace) -> int: def verify_accessibility(arguments: argparse.Namespace) -> int: - payload = json.loads(Path(arguments.evidence).read_text(encoding="utf-8")) + _, payload = read_json(Path(arguments.evidence).resolve()) if ( payload.get("schema") != "radroots-ios-local-social-fixture-evidence-v1" or payload.get("accepted_events") != 0 @@ -1304,8 +1362,8 @@ def verify_accessibility(arguments: argparse.Namespace) -> int: def verify_persona_fixture(arguments: argparse.Namespace) -> int: - raw, _ = load_persona_suite(Path(arguments.fixture).resolve()) - fixture_schema = validate_schema_file( + raw, suite = load_persona_suite(Path(arguments.fixture).resolve()) + fixture_schema, fixture_schema_value = load_schema_file( Path(arguments.fixture_schema).resolve(), "https://radroots.org/schemas/ios/local-social-personas.v1.schema.json", ) @@ -1321,6 +1379,7 @@ def verify_persona_fixture(arguments: argparse.Namespace) -> int: Path(arguments.result_v2_schema).resolve(), "https://radroots.org/schemas/ios/local-social-persona-results.v2.schema.json", ) + validate_schema_instance(fixture_schema_value, suite, "persona fixture") print( "local-social persona fixtures verified: " f"fixture={hashlib.sha256(raw).hexdigest()} " @@ -1333,11 +1392,12 @@ def verify_persona_fixture(arguments: argparse.Namespace) -> int: def verify_bud11_corpus(arguments: argparse.Namespace) -> int: - raw, _ = load_bud11_mutation_corpus(Path(arguments.corpus).resolve()) - schema = validate_schema_file( + raw, corpus = load_bud11_mutation_corpus(Path(arguments.corpus).resolve()) + schema, schema_value = load_schema_file( Path(arguments.schema).resolve(), "https://radroots.org/schemas/ios/bud11-upload-authorization-mutations.v1.schema.json", ) + validate_schema_instance(schema_value, corpus, "BUD-11 corpus") print( "BUD-11 mutation corpus verified: " f"corpus={hashlib.sha256(raw).hexdigest()} " @@ -1346,29 +1406,100 @@ def verify_bud11_corpus(arguments: argparse.Namespace) -> int: return 0 -def directory_digest(path: Path) -> str: +def bounded_directory_inventory( + path: Path, + maximum_entries: int = MAX_RESULT_BUNDLE_ENTRIES, + maximum_relative_path_bytes: int = MAX_RESULT_BUNDLE_RELATIVE_PATH_BYTES, +) -> list[tuple[int, bytes, Path]]: + if path.is_symlink() or not path.is_dir(): + raise ValueError("result bundle is not a regular directory") + pending = [(path, Path())] + inventory: list[tuple[int, bytes, Path]] = [] + while pending: + directory, relative_directory = pending.pop() + with os.scandir(directory) as entries: + for entry in entries: + relative_path = relative_directory / entry.name + try: + relative = relative_path.as_posix().encode("utf-8") + except UnicodeError as error: + raise ValueError("result bundle path is not UTF-8") from error + if not relative or len(relative) > maximum_relative_path_bytes: + raise ValueError("result bundle path exceeds its byte bound") + if len(inventory) >= maximum_entries: + raise ValueError("result bundle exceeds its entry bound") + if entry.is_symlink(): + raise ValueError("result bundle contains a symbolic link") + item = Path(entry.path) + if entry.is_dir(follow_symlinks=False): + inventory.append((0, relative, item)) + pending.append((item, relative_path)) + elif entry.is_file(follow_symlinks=False): + inventory.append((1, relative, item)) + else: + raise ValueError("result bundle contains an unsupported entry") + return sorted(inventory, key=lambda item: item[1]) + + +def directory_digest( + path: Path, + maximum_entries: int = MAX_RESULT_BUNDLE_ENTRIES, + maximum_file_bytes: int = MAX_RESULT_BUNDLE_FILE_BYTES, + maximum_total_bytes: int = MAX_RESULT_BUNDLE_BYTES, +) -> str: + inventory = bounded_directory_inventory(path, maximum_entries) digest = hashlib.sha256() - for item in sorted( - path.rglob("*"), key=lambda value: value.relative_to(path).as_posix() - ): - relative = item.relative_to(path).as_posix().encode("utf-8") - if item.is_symlink(): - raise ValueError("result bundle contains a symbolic link") - if item.is_dir(): - digest.update(b"d\0" + relative + b"\0") + digest.update(RESULT_BUNDLE_DIGEST_DOMAIN) + digest.update(len(inventory).to_bytes(4, "big")) + total_bytes = 0 + for kind, relative, item in inventory: + digest.update(bytes((kind,))) + digest.update(len(relative).to_bytes(8, "big")) + digest.update(relative) + if kind == 0: continue - if not item.is_file(): - raise ValueError("result bundle contains an unsupported entry") - digest.update(b"f\0" + relative + b"\0") - with item.open("rb") as stream: - while chunk := stream.read(64 * 1024): - digest.update(chunk) + descriptor = os.open( + item, + os.O_RDONLY | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0), + ) + try: + before = os.fstat(descriptor) + if not stat.S_ISREG(before.st_mode) or before.st_size < 0: + raise ValueError("result bundle contains an unsupported entry") + size = before.st_size + if size > maximum_file_bytes or total_bytes > maximum_total_bytes - size: + raise ValueError("result bundle exceeds its byte bound") + digest.update(size.to_bytes(8, "big")) + remaining = size + with os.fdopen(descriptor, "rb", closefd=False) as stream: + while remaining: + chunk = stream.read(min(64 * 1024, remaining)) + if not chunk: + raise ValueError("result bundle file changed while hashing") + digest.update(chunk) + remaining -= len(chunk) + if stream.read(1): + raise ValueError("result bundle file changed while hashing") + after = os.fstat(descriptor) + if ( + before.st_dev != after.st_dev + or before.st_ino != after.st_ino + or before.st_size != after.st_size + or before.st_mode != after.st_mode + or before.st_mtime_ns != after.st_mtime_ns + or before.st_ctime_ns != after.st_ctime_ns + ): + raise ValueError("result bundle file changed while hashing") + total_bytes += size + finally: + os.close(descriptor) return digest.hexdigest() def simulator_metadata(udid: str, result_bundle: Path) -> dict[str, str]: - devices = json.loads( - subprocess.check_output(["xcrun", "simctl", "list", "devices", "--json"]) + devices = run_json_command_bounded( + ["xcrun", "simctl", "list", "devices", "--json"], + MAX_XCRESULT_JSON_BYTES, ) matches = [ (runtime, device) @@ -1380,18 +1511,17 @@ def simulator_metadata(udid: str, result_bundle: Path) -> dict[str, str]: raise ValueError("simulator identity is unavailable") runtime, simulator = matches[0] runtime_version = runtime.rsplit("iOS-", 1)[-1].replace("-", ".") - summary = json.loads( - subprocess.check_output( - [ - "xcrun", - "xcresulttool", - "get", - "test-results", - "summary", - "--path", - str(result_bundle), - ] - ) + summary = run_json_command_bounded( + [ + "xcrun", + "xcresulttool", + "get", + "test-results", + "summary", + "--path", + str(result_bundle), + ], + MAX_XCRESULT_JSON_BYTES, ) result_devices = [ row.get("device") @@ -1794,13 +1924,31 @@ def exact_persona_test_node(value: Any) -> dict[str, Any]: def run_json_command_bounded(command: list[str], maximum: int) -> Any: - with tempfile.TemporaryFile() as output: - subprocess.run(command, stdout=output, check=True) - size = output.tell() - if size <= 0 or size > maximum: - raise ValueError("command JSON output exceeds its byte bound") - output.seek(0) - return json.loads(output.read(), object_pairs_hook=strict_object) + process = subprocess.Popen(command, stdout=subprocess.PIPE) + assert process.stdout is not None + raw = process.stdout.read(maximum + 1) + process.stdout.close() + if len(raw) > maximum: + process.kill() + process.wait() + raise ValueError("command JSON output exceeds its byte bound") + return_code = process.wait() + if return_code != 0: + raise subprocess.CalledProcessError(return_code, command) + if not raw: + raise ValueError("command JSON output exceeds its byte bound") + return json.loads(raw, object_pairs_hook=strict_object) + + +def exported_attachment_inventory(path: Path) -> set[str]: + inventory = bounded_directory_inventory( + path, + len(PERSONA_ATTACHMENT_NAMES) + 1, + 255, + ) + if any(kind != 1 or b"/" in relative for kind, relative, _ in inventory): + raise ValueError("xcresult attachment export inventory is invalid") + return {relative.decode("utf-8") for _, relative, _ in inventory} def load_exported_persona_attachments( @@ -1809,6 +1957,7 @@ def load_exported_persona_attachments( *, require_measured_network: bool, ) -> list[tuple[bytes, dict[str, Any]]]: + inventory = exported_attachment_inventory(export_directory) manifest_raw, manifest_value = read_json_bounded( export_directory / "manifest.json", MAX_XCRESULT_JSON_BYTES ) @@ -1828,6 +1977,7 @@ def load_exported_persona_attachments( ): raise ValueError("xcresult attachment test binding is invalid") attachments_by_name: dict[str, tuple[bytes, dict[str, Any]]] = {} + exported_names: set[str] = set() total_bytes = 0 allowed_manifest_keys = { "exportedFileName", @@ -1856,6 +2006,7 @@ def load_exported_persona_attachments( name not in PERSONA_ATTACHMENT_NAMES or name in attachments_by_name or not isinstance(exported, str) + or exported in exported_names or not 1 <= len(exported.encode("utf-8")) <= 255 or Path(exported).name != exported or row_value["isAssociatedWithFailure"] is not False @@ -1865,6 +2016,7 @@ def load_exported_persona_attachments( or not isinstance(row_value["deviceId"], str) ): raise ValueError("xcresult attempt attachment identity is invalid") + exported_names.add(exported) path = export_directory / exported if path.is_symlink() or not path.is_file(): raise ValueError("xcresult attempt attachment is not a regular file") @@ -1882,6 +2034,8 @@ def load_exported_persona_attachments( attachments_by_name[name] = (raw, attempt) if tuple(sorted(attachments_by_name)) != tuple(sorted(PERSONA_ATTACHMENT_NAMES)): raise ValueError("xcresult persona attachment inventory is incomplete") + if inventory != {"manifest.json", *exported_names}: + raise ValueError("xcresult attachment export inventory is invalid") return [attachments_by_name[name] for name in PERSONA_ATTACHMENT_NAMES] @@ -2275,18 +2429,19 @@ def validate_persona_result( def verify_persona(arguments: argparse.Namespace) -> int: fixture_raw, suite = load_persona_suite(Path(arguments.fixture).resolve()) - fixture_schema_raw = validate_schema_file( + fixture_schema_raw, fixture_schema = load_schema_file( Path(arguments.fixture_schema).resolve(), "https://radroots.org/schemas/ios/local-social-personas.v1.schema.json", ) - attempt_schema_raw = validate_schema_file( + attempt_schema_raw, attempt_schema = load_schema_file( Path(arguments.attempt_schema).resolve(), "https://radroots.org/schemas/ios/local-social-persona-attempt-evidence.v1.schema.json", ) - result_schema_raw = validate_schema_file( + result_schema_raw, result_schema = load_schema_file( Path(arguments.result_v2_schema).resolve(), "https://radroots.org/schemas/ios/local-social-persona-results.v2.schema.json", ) + validate_schema_instance(fixture_schema, suite, "persona fixture") evidence_path = Path(arguments.evidence).resolve() _, evidence_value = read_json(evidence_path) evidence = validate_persona_evidence(evidence_value, suite) @@ -2300,6 +2455,8 @@ def verify_persona(arguments: argparse.Namespace) -> int: attachments = extract_persona_attempt_attachments( result_bundle, suite, require_measured_network=True ) + for _, attempt in attachments: + validate_schema_instance(attempt_schema, attempt, "persona attempt evidence") simulator = simulator_metadata(arguments.simulator_id, result_bundle) result = reconstruct_persona_result_v2( suite, @@ -2311,6 +2468,7 @@ def verify_persona(arguments: argparse.Namespace) -> int: result_bundle_sha256=directory_digest(result_bundle), forward_repairs=arguments.forward_repair_commit, ) + validate_schema_instance(result_schema, result, "persona v2 result") if ( result["run_id"] != arguments.run_id or result["source"] @@ -2333,7 +2491,7 @@ def verify_persona(arguments: argparse.Namespace) -> int: raise ValueError("persona v2 result is noncanonical") print( "local-social measured persona result verified: " - f"{hashlib.sha256(output.read_bytes()).hexdigest()}" + f"{hashlib.sha256(raw).hexdigest()}" ) return 0 @@ -2360,21 +2518,23 @@ def verify_persona_result_file( def verify_persona_result(arguments: argparse.Namespace) -> int: fixture_raw, suite = load_persona_suite(Path(arguments.fixture).resolve()) - fixture_schema_raw = validate_schema_file( + fixture_schema_raw, fixture_schema = load_schema_file( Path(arguments.fixture_schema).resolve(), "https://radroots.org/schemas/ios/local-social-personas.v1.schema.json", ) - result_schema_raw = validate_schema_file( + result_schema_raw, result_schema = load_schema_file( Path(arguments.result_schema).resolve(), "https://radroots.org/schemas/ios/local-social-persona-results.v1.schema.json", ) - verify_persona_result_file( + validate_schema_instance(fixture_schema, suite, "persona fixture") + result = verify_persona_result_file( Path(arguments.result).resolve(), suite, hashlib.sha256(fixture_raw).hexdigest(), hashlib.sha256(fixture_schema_raw).hexdigest(), hashlib.sha256(result_schema_raw).hexdigest(), ) + validate_schema_instance(result_schema, result, "persona v1 result") print("local-social persona result contract verified") return 0 @@ -2425,6 +2585,7 @@ def parser() -> argparse.ArgumentParser: def main() -> int: + verify_toolchain_identity() arguments = parser().parse_args() if arguments.command == "serve": return serve(arguments) diff --git a/scripts/persona-verifier.sh b/scripts/persona-verifier.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu + +repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) + +exec uv run \ + --project "$repo_root/scripts/persona-verifier" \ + --offline \ + --frozen \ + python "$repo_root/scripts/local-social-fixture.py" "$@" diff --git a/scripts/persona-verifier/pyproject.toml b/scripts/persona-verifier/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "radroots-persona-verifier" +version = "0.0.0" +requires-python = "==3.14.7" +dependencies = ["jsonschema==4.26.0"] + +[tool.uv] +package = false diff --git a/scripts/persona-verifier/uv.lock b/scripts/persona-verifier/uv.lock @@ -0,0 +1,100 @@ +version = 1 +revision = 3 +requires-python = "==3.14.7" + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "radroots-persona-verifier" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "jsonschema" }, +] + +[package.metadata] +requires-dist = [{ name = "jsonschema", specifier = "==4.26.0" }] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, +] diff --git a/scripts/test_local_social_fixture.py b/scripts/test_local_social_fixture.py @@ -472,6 +472,103 @@ class LocalSocialFixtureTests(unittest.TestCase): with self.assertRaisesRegex(ValueError, "duplicate JSON member"): fixture.read_json(duplicate) + def test_json_reads_enforce_maximum_plus_one_before_decoding(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory, "bounded.json") + path.write_bytes(b'{"x":1}') + self.assertEqual(fixture.read_json(path, 7)[1], {"x": 1}) + path.write_bytes(b'{"x":1}\n') + with self.assertRaisesRegex(ValueError, "byte bound"): + fixture.read_json(path, 7) + + def test_schemas_pass_meta_validation_and_match_semantic_corpora(self) -> None: + fixture_raw, suite = fixture.load_persona_suite( + Path("test-fixtures/local-social-personas.v1.json") + ) + self.assertTrue(fixture_raw) + _, persona_schema = fixture.load_schema_file( + Path("test-fixtures/local-social-personas.v1.schema.json"), + "https://radroots.org/schemas/ios/local-social-personas.v1.schema.json", + ) + fixture.validate_schema_instance(persona_schema, suite, "persona fixture") + _, corpus = fixture.load_bud11_mutation_corpus( + Path("test-fixtures/bud11-upload-authorization-mutations.v1.json") + ) + _, corpus_schema = fixture.load_schema_file( + Path( + "test-fixtures/bud11-upload-authorization-mutations.v1.schema.json" + ), + "https://radroots.org/schemas/ios/bud11-upload-authorization-mutations.v1.schema.json", + ) + fixture.validate_schema_instance(corpus_schema, corpus, "BUD-11 corpus") + + changed = copy.deepcopy(suite) + changed["unexpected"] = True + with self.assertRaisesRegex(ValueError, "disagrees"): + fixture.validate_schema_instance(persona_schema, changed, "persona fixture") + + with tempfile.TemporaryDirectory() as directory: + invalid = Path(directory, "invalid.schema.json") + invalid.write_text( + json.dumps( + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://radroots.org/schemas/ios/invalid.json", + "type": "object", + "additionalProperties": False, + "properties": {"x": {"type": "not-a-json-schema-type"}}, + } + ), + encoding="utf-8", + ) + with self.assertRaisesRegex(ValueError, "meta-validation"): + fixture.load_schema_file( + invalid, "https://radroots.org/schemas/ios/invalid.json" + ) + invalid.write_text( + json.dumps( + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://radroots.org/schemas/ios/invalid.json", + "type": "object", + "additionalProperties": False, + "properties": { + "x": {"$ref": "https://example.com/external.json"} + }, + } + ), + encoding="utf-8", + ) + with self.assertRaisesRegex(ValueError, "external reference"): + fixture.load_schema_file( + invalid, "https://radroots.org/schemas/ios/invalid.json" + ) + + def test_result_bundle_digest_is_framed_bounded_and_pinned(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "a.txt").write_bytes(b"A") + (root / "nested").mkdir() + (root / "nested" / "b.bin").write_bytes(b"\x00B") + self.assertEqual( + fixture.directory_digest(root, 3, 2, 3), + "25fb5f36c3b044de2716ddfbbd95c2e5eb39cf38a5bcf2feb793cd57ce27147a", + ) + with self.assertRaisesRegex(ValueError, "entry bound"): + fixture.directory_digest(root, 2, 2, 3) + with self.assertRaisesRegex(ValueError, "byte bound"): + fixture.directory_digest(root, 3, 1, 3) + with self.assertRaisesRegex(ValueError, "byte bound"): + fixture.directory_digest(root, 3, 2, 2) + + alternate = root / "alternate" + alternate.mkdir() + (alternate / "a").write_bytes(b".txtA") + self.assertNotEqual( + fixture.directory_digest(root, 5, 8, 16), + fixture.directory_digest(alternate, 1, 8, 8), + ) + def test_fixture_freezes_exact_persona_and_flow_matrix(self) -> None: _, suite = fixture.load_persona_suite( Path("test-fixtures/local-social-personas.v1.json") @@ -597,6 +694,7 @@ class LocalSocialFixtureTests(unittest.TestCase): ), result, ) + fixture.validate_schema_instance(result_schema, result, "persona v1 result") for mutation in ( lambda value: value.update({"unexpected": True}), lambda value: value["simulator"].update({"os": "iOS 17.7"}), @@ -681,6 +779,21 @@ class LocalSocialFixtureTests(unittest.TestCase): self.assertEqual(result["retrievals"], 3) self.assertEqual(result["non_loopback_attempts"], 0) self.assertEqual(len(result["attachments"]), 15) + _, attempt_schema = fixture.load_schema_file( + Path( + "test-fixtures/local-social-persona-attempt-evidence.v1.schema.json" + ), + "https://radroots.org/schemas/ios/local-social-persona-attempt-evidence.v1.schema.json", + ) + for _, attempt in attachments: + fixture.validate_schema_instance( + attempt_schema, attempt, "persona attempt evidence" + ) + _, result_schema = fixture.load_schema_file( + Path("test-fixtures/local-social-persona-results.v2.schema.json"), + "https://radroots.org/schemas/ios/local-social-persona-results.v2.schema.json", + ) + fixture.validate_schema_instance(result_schema, result, "persona v2 result") _, pending = self.persona_attempt_attachments(measured=False) with self.assertRaisesRegex(ValueError, "not measured"): @@ -770,6 +883,14 @@ class LocalSocialFixtureTests(unittest.TestCase): self.assertEqual(len(loaded), 15) self.assertEqual(loaded[0][1]["attempt_id"], "P01-A01") + unexpected = root / "unexpected.json" + unexpected.write_text("{}", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "entry bound"): + fixture.load_exported_persona_attachments( + root, suite, require_measured_network=True + ) + unexpected.unlink() + rows[0]["suggestedHumanReadableName"] = rows[1][ "suggestedHumanReadableName" ] @@ -864,15 +985,15 @@ class LocalSocialFixtureTests(unittest.TestCase): } with mock.patch.object( - fixture.subprocess, - "check_output", - side_effect=[json.dumps(simctl_devices), json.dumps(result_summary)], - ) as check_output: + fixture, + "run_json_command_bounded", + side_effect=[simctl_devices, result_summary], + ) as run_json: self.assertEqual( fixture.simulator_metadata(udid.lower(), result_bundle), {"udid": udid, "os": "iOS 26.5", "architecture": "arm64"}, ) - commands = [call.args[0] for call in check_output.call_args_list] + commands = [call.args[0] for call in run_json.call_args_list] self.assertEqual(commands[0][:3], ["xcrun", "simctl", "list"]) self.assertEqual(commands[1][:3], ["xcrun", "xcresulttool", "get"]) self.assertNotIn("spawn", commands[0] + commands[1]) @@ -896,14 +1017,39 @@ class LocalSocialFixtureTests(unittest.TestCase): mutation(changed) with ( mock.patch.object( - fixture.subprocess, - "check_output", - side_effect=[json.dumps(simctl_devices), json.dumps(changed)], + fixture, + "run_json_command_bounded", + side_effect=[simctl_devices, changed], ), self.assertRaises(ValueError), ): fixture.simulator_metadata(udid, result_bundle) + def test_subprocess_json_is_bounded_before_decoding(self) -> None: + command = [ + fixture.sys.executable, + "-c", + "import sys; sys.stdout.write('{\\\"x\\\":1}')", + ] + self.assertEqual(fixture.run_json_command_bounded(command, 7), {"x": 1}) + with self.assertRaisesRegex(ValueError, "byte bound"): + fixture.run_json_command_bounded(command, 6) + + def test_verifier_toolchain_identity_is_exact(self) -> None: + fixture.verify_toolchain_identity() + with ( + mock.patch.object(fixture.sys, "version_info", (3, 14, 6)), + self.assertRaisesRegex(RuntimeError, "Python identity"), + ): + fixture.verify_toolchain_identity() + with ( + mock.patch.object( + fixture.importlib.metadata, "version", return_value="4.25.1" + ), + self.assertRaisesRegex(RuntimeError, "schema dependency"), + ): + fixture.verify_toolchain_identity() + def test_control_is_bounded_and_deny_unknown(self) -> None: with tempfile.TemporaryDirectory() as directory: path = Path(directory, "control.json") diff --git a/scripts/verify-package-contract.sh b/scripts/verify-package-contract.sh @@ -139,10 +139,10 @@ for fixture in \ do test -f "$repo_root/test-fixtures/$fixture" done -python3 "$repo_root/scripts/local-social-fixture.py" verify-bud11-corpus \ +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" -python3 "$repo_root/scripts/local-social-fixture.py" verify-persona-fixture \ +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" \ @@ -154,8 +154,33 @@ grep -Fq 'radroots.ios.local-social.persona-attempt-evidence.v1' \ "$repo_root/RadrootsUITests/RadrootsRemoteQualificationUITests.swift" ( cd "$repo_root" - python3 -m unittest scripts/test_local_social_fixture.py + uv run --project scripts/persona-verifier --offline --frozen \ + python -m unittest 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"' \ @@ -191,6 +216,8 @@ 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' \ diff --git a/scripts/xcode.sh b/scripts/xcode.sh @@ -231,13 +231,13 @@ case "$operation" in exit 1 fi fi - python3 scripts/local-social-fixture.py verify-persona-fixture \ + sh scripts/persona-verifier.sh verify-persona-fixture \ --fixture "$persona_fixture" \ --fixture-schema test-fixtures/local-social-personas.v1.schema.json \ --result-schema test-fixtures/local-social-persona-results.v1.schema.json \ --attempt-schema test-fixtures/local-social-persona-attempt-evidence.v1.schema.json \ --result-v2-schema test-fixtures/local-social-persona-results.v2.schema.json - python3 scripts/local-social-fixture.py serve \ + sh scripts/persona-verifier.sh serve \ --relay-port "$relay_port" \ --blossom-port "$blossom_port" \ --evidence "$evidence" \ @@ -245,7 +245,7 @@ case "$operation" in --control "$control" \ --persona-fixture "$persona_fixture" & else - python3 scripts/local-social-fixture.py serve \ + sh scripts/persona-verifier.sh serve \ --relay-port "$relay_port" \ --blossom-port "$blossom_port" \ --evidence "$evidence" \ @@ -314,7 +314,7 @@ case "$operation" in exit "$test_status" fi if [[ "$scenario" == persona ]]; then - persona_result_command=(python3 scripts/local-social-fixture.py "$evidence_command" \ + persona_result_command=(sh scripts/persona-verifier.sh "$evidence_command" \ --fixture "$persona_fixture" \ --fixture-schema test-fixtures/local-social-personas.v1.schema.json \ --result-schema test-fixtures/local-social-persona-results.v1.schema.json \ @@ -332,7 +332,7 @@ case "$operation" in fi "${persona_result_command[@]}" else - python3 scripts/local-social-fixture.py "$evidence_command" --evidence "$evidence" + sh scripts/persona-verifier.sh "$evidence_command" --evidence "$evidence" fi ;; remote-ui-test)