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