local-social-fixture.py (100862B)
1 #!/usr/bin/env python3 2 """Bounded loopback Nostr and Blossom fixture for iOS simulator tests.""" 3 4 from __future__ import annotations 5 6 import argparse 7 import base64 8 import hashlib 9 import http.server 10 import importlib.metadata 11 import ipaddress 12 import json 13 import os 14 import re 15 import signal 16 import socket 17 import socketserver 18 import stat 19 import subprocess 20 import struct 21 import sys 22 import tempfile 23 import threading 24 import time 25 import unicodedata 26 from pathlib import Path 27 from typing import Any 28 29 from jsonschema import Draft202012Validator 30 from jsonschema.exceptions import SchemaError, ValidationError 31 32 MAX_HTTP_BODY = 16 * 1024 * 1024 33 MAX_WEBSOCKET_MESSAGE = 2 * 1024 * 1024 34 MAX_EVENTS = 256 35 MAX_BLOBS = 16 36 MAX_JSON_BYTES = 64 * 1024 37 BUD11_EVENT_KIND = 24_242 38 BUD11_CONTENT_MAX_BYTES = 4_096 39 BUD11_AUTHORIZATION_MAX_BYTES = 16 * 1024 40 BUD11_AUTHORIZATION_ENCODED_MAX_BYTES = 21_846 41 BUD11_MAX_LIFETIME_SECONDS = 300 42 BUD11_MAX_CREATED_AGE_SECONDS = 300 43 BUD11_SERVER_DOMAIN = "127.0.0.1" 44 BUD11_MUTATION_SCHEMA = "tera.ios.local-social.bud11-mutations.v1" 45 BUD11_MUTATIONS = ( 46 ("canonical", "none", "http", True), 47 ("wrong-scheme", "authorization_scheme", "http", False), 48 ("padded-base64", "padded_base64", "http", False), 49 ("wrong-kind", "wrong_kind", "http", False), 50 ("empty-content", "empty_content", "http", False), 51 ("oversized-content", "oversized_content", "http", False), 52 ("leading-content-space", "leading_content_space", "http", False), 53 ("control-content", "control_content", "http", False), 54 ("missing-action", "missing_action", "http", False), 55 ("wrong-action", "wrong_action", "http", False), 56 ("duplicate-action", "duplicate_action", "http", False), 57 ("wrong-hash", "wrong_hash", "http", False), 58 ("duplicate-hash", "duplicate_hash", "http", False), 59 ("missing-server", "missing_server", "http", False), 60 ("wrong-server", "wrong_server", "http", False), 61 ("uppercase-server", "uppercase_server", "http", False), 62 ("duplicate-server", "duplicate_server", "http", False), 63 ("missing-expiration", "missing_expiration", "http", False), 64 ("noncanonical-expiration", "noncanonical_expiration", "http", False), 65 ("expired", "expired", "http", False), 66 ("created-at-not-past", "created_at_not_past", "http", False), 67 ("lifetime-too-long", "lifetime_too_long", "http", False), 68 ("unknown-tag", "unknown_tag", "http", False), 69 ("event-id-mutation", "event_id", "http", False), 70 ("signature-mutation", "signature", "http", False), 71 ("relay-publication", "none", "relay", False), 72 ) 73 PERSONA_ALIASES = ("P01", "P02", "P03", "P04", "P05") 74 FLOW_KINDS = { 75 "Update": 1, 76 "PhotoUpdate": 1, 77 "Ask": 1, 78 "Event": 31923, 79 "FoodAvailability": 30402, 80 } 81 PHOTO_PERSONAS = frozenset(("P01", "P03", "P04")) 82 PERSONA_CONTROL_SCHEMA = "tera.ios.local-social.persona-control.v1" 83 PERSONA_ATTEMPT_SCHEMA = "tera.ios.local-social.persona-attempt-evidence.v1" 84 PERSONA_RESULT_V2_SCHEMA = "tera.ios.local-social.persona-results.v2" 85 PERSONA_TEST_TARGET = "TeraUITests" 86 PERSONA_TEST_IDENTIFIER = "TeraUITests/testLocalSocialDeterministicPersonas" 87 PERSONA_TEST_ACTION = "test" 88 PERSONA_TEST_CONFIGURATION = "Debug" 89 PERSONA_XCRESULT_NODE_IDENTIFIER = ( 90 "TeraRemoteQualificationUITests/testLocalSocialDeterministicPersonas()" 91 ) 92 PERSONA_XCRESULT_NODE_URL = ( 93 "test://com.apple.xcode/Tera/TeraUITests/" 94 "TeraRemoteQualificationUITests/testLocalSocialDeterministicPersonas" 95 ) 96 MAX_XCRESULT_JSON_BYTES = 1024 * 1024 97 MAX_PERSONA_ATTACHMENT_BYTES = 64 * 1024 98 MAX_PERSONA_ATTACHMENTS_BYTES = 15 * MAX_PERSONA_ATTACHMENT_BYTES 99 MAX_RESULT_BUNDLE_ENTRIES = 65_536 100 MAX_RESULT_BUNDLE_FILE_BYTES = 1024 * 1024 * 1024 101 MAX_RESULT_BUNDLE_BYTES = 8 * 1024 * 1024 * 1024 102 MAX_RESULT_BUNDLE_RELATIVE_PATH_BYTES = 1024 103 RESULT_BUNDLE_DIGEST_DOMAIN = b"radroots.ios.persona_result_bundle.v1\0" 104 VERIFIER_PYTHON = (3, 14, 7) 105 VERIFIER_JSONSCHEMA = "4.26.0" 106 PERSONA_ATTACHMENT_NAMES = tuple( 107 f"radroots-local-social-P{persona:02d}-A{attempt:02d}.json" 108 for persona in range(1, 6) 109 for attempt in range(1, 4) 110 ) 111 PERSONA_XCRESULT_ATTACHMENT_NAME = re.compile( 112 r"^radroots-local-social-(P0[1-5]-A0[1-3])_0_" 113 r"[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}\.json$" 114 ) 115 FORBIDDEN_EVIDENCE_KEYS = frozenset( 116 ("private_key", "secret", "seed", "signed_event", "event_content", "raw_event") 117 ) 118 119 120 def strict_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: 121 value: dict[str, Any] = {} 122 for key, item in pairs: 123 if key in value: 124 raise ValueError("duplicate JSON member") 125 value[key] = item 126 return value 127 128 129 def read_json(path: Path, maximum: int = MAX_JSON_BYTES) -> tuple[bytes, Any]: 130 with path.open("rb") as stream: 131 raw = stream.read(maximum + 1) 132 if not raw or len(raw) > maximum: 133 raise ValueError("JSON input is empty or exceeds its byte bound") 134 value = json.loads(raw, object_pairs_hook=strict_object) 135 return raw, value 136 137 138 def read_json_bounded(path: Path, maximum: int) -> tuple[bytes, Any]: 139 return read_json(path, maximum) 140 141 142 def exact_keys(value: Any, keys: set[str], name: str) -> dict[str, Any]: 143 if not isinstance(value, dict) or set(value) != keys: 144 raise ValueError(f"{name} has an invalid field inventory") 145 return value 146 147 148 def validate_persona_suite(value: Any) -> dict[str, Any]: 149 root = exact_keys( 150 value, 151 {"schema", "schema_version", "locale", "media_fixture_sha256", "personas"}, 152 "persona suite", 153 ) 154 if ( 155 root["schema"] != "tera.ios.local-social.personas.v1" 156 or root["schema_version"] != 1 157 or root["locale"] != "en_US" 158 or not lowercase_hex(root["media_fixture_sha256"], 64) 159 or not isinstance(root["personas"], list) 160 or len(root["personas"]) != 5 161 ): 162 raise ValueError("persona suite header is invalid") 163 expected = ( 164 ("P01", "age_18_27", "experienced_direct", ("Update", "PhotoUpdate", "Ask")), 165 ( 166 "P02", 167 "age_18_27", 168 "novice_progressive_disclosure", 169 ("Event", "FoodAvailability", "Update"), 170 ), 171 ( 172 "P03", 173 "age_18_27", 174 "nontechnical_validation_recovery", 175 ("PhotoUpdate", "Ask", "Event"), 176 ), 177 ( 178 "P04", 179 "adult_other", 180 "novice_accessibility_keyboard", 181 ("FoodAvailability", "Update", "PhotoUpdate"), 182 ), 183 ( 184 "P05", 185 "adult_other", 186 "general_transport_retry_relaunch", 187 ("Ask", "Event", "FoodAvailability"), 188 ), 189 ) 190 attempts: list[dict[str, Any]] = [] 191 for persona_index, (persona_value, expected_value) in enumerate( 192 zip(root["personas"], expected, strict=True), 1 193 ): 194 persona = exact_keys( 195 persona_value, 196 {"alias", "synthetic_age_band", "interaction_profile", "attempts"}, 197 "persona", 198 ) 199 alias, age_band, profile, flows = expected_value 200 if ( 201 persona["alias"] != alias 202 or persona["synthetic_age_band"] != age_band 203 or persona["interaction_profile"] != profile 204 or not isinstance(persona["attempts"], list) 205 or len(persona["attempts"]) != 3 206 ): 207 raise ValueError("persona matrix is not exact") 208 for attempt_index, (attempt_value, flow) in enumerate( 209 zip(persona["attempts"], flows, strict=True), 1 210 ): 211 attempt = exact_keys( 212 attempt_value, 213 {"id", "order", "flow", "marker", "expected_failure"}, 214 "attempt", 215 ) 216 expected_id = f"{alias}-A{attempt_index:02d}" 217 expected_order = (persona_index - 1) * 3 + attempt_index 218 if ( 219 attempt["id"] != expected_id 220 or attempt["order"] != expected_order 221 or attempt["flow"] != flow 222 or not isinstance(attempt["marker"], str) 223 or not 1 <= len(attempt["marker"].encode("ascii")) <= 24 224 or attempt["marker"] 225 != f"rr-{alias.lower()}-a{attempt_index:02d}-{flow_marker(flow)}" 226 or attempt["expected_failure"] 227 not in {"none", "validation_recovery", "transport_retry_relaunch"} 228 ): 229 raise ValueError("persona attempt is not exact") 230 attempts.append(attempt) 231 if ( 232 [item["expected_failure"] for item in attempts].count("validation_recovery") 233 != 1 234 ): 235 raise ValueError("persona suite must contain one validation-recovery vector") 236 if ( 237 [item["expected_failure"] for item in attempts].count( 238 "transport_retry_relaunch" 239 ) 240 != 1 241 ): 242 raise ValueError("persona suite must contain one transport-retry vector") 243 if any( 244 sum(item["flow"] == flow for item in attempts) != 3 for flow in FLOW_KINDS 245 ): 246 raise ValueError("each Add flow must have exactly three attempts") 247 return root 248 249 250 def validate_bud11_mutation_corpus(value: Any) -> dict[str, Any]: 251 root = exact_keys(value, {"schema", "schema_version", "vectors"}, "BUD-11 corpus") 252 if ( 253 root["schema"] != BUD11_MUTATION_SCHEMA 254 or root["schema_version"] != 1 255 or not isinstance(root["vectors"], list) 256 ): 257 raise ValueError("BUD-11 corpus header is invalid") 258 observed = [] 259 for value in root["vectors"]: 260 vector = exact_keys( 261 value, 262 {"id", "mutation", "surface", "expected_accepted"}, 263 "BUD-11 vector", 264 ) 265 if ( 266 not isinstance(vector["id"], str) 267 or not isinstance(vector["mutation"], str) 268 or vector["surface"] not in {"http", "relay"} 269 or type(vector["expected_accepted"]) is not bool 270 ): 271 raise ValueError("BUD-11 vector is invalid") 272 observed.append( 273 ( 274 vector["id"], 275 vector["mutation"], 276 vector["surface"], 277 vector["expected_accepted"], 278 ) 279 ) 280 if tuple(observed) != BUD11_MUTATIONS: 281 raise ValueError("BUD-11 corpus inventory is invalid") 282 return root 283 284 285 def load_bud11_mutation_corpus(path: Path) -> tuple[bytes, dict[str, Any]]: 286 raw, value = read_json(path) 287 corpus = validate_bud11_mutation_corpus(value) 288 canonical = ( 289 "{\n" 290 f' "schema": {json.dumps(value.get("schema"))},\n' 291 f' "schema_version": {json.dumps(value.get("schema_version"))},\n' 292 ' "vectors": [\n' 293 + ",\n".join( 294 " " + json.dumps(vector, ensure_ascii=False) 295 for vector in value.get("vectors", []) 296 ) 297 + "\n ]\n}\n" 298 ).encode("utf-8") 299 if raw != canonical: 300 raise ValueError("BUD-11 corpus is noncanonical") 301 return raw, corpus 302 303 304 def flow_marker(flow: str) -> str: 305 return { 306 "Update": "update", 307 "PhotoUpdate": "photo", 308 "Ask": "ask", 309 "Event": "event", 310 "FoodAvailability": "food", 311 }[flow] 312 313 314 def load_persona_suite(path: Path) -> tuple[bytes, dict[str, Any]]: 315 raw, value = read_json(path) 316 return raw, validate_persona_suite(value) 317 318 319 def validate_schema_file(path: Path, expected_id: str) -> bytes: 320 raw, _ = load_schema_file(path, expected_id) 321 return raw 322 323 324 def load_schema_file(path: Path, expected_id: str) -> tuple[bytes, dict[str, Any]]: 325 raw, value = read_json(path) 326 if not isinstance(value, dict): 327 raise ValueError("schema boundary is invalid") 328 root = exact_keys(value, set(value), "schema") 329 if ( 330 root.get("$schema") != "https://json-schema.org/draft/2020-12/schema" 331 or root.get("$id") != expected_id 332 or root.get("type") != "object" 333 or root.get("additionalProperties") is not False 334 ): 335 raise ValueError("schema boundary is invalid") 336 if schema_contains_external_reference(root): 337 raise ValueError("schema contains an external reference") 338 try: 339 Draft202012Validator.check_schema(root) 340 except SchemaError as error: 341 raise ValueError("schema fails Draft 2020-12 meta-validation") from error 342 return raw, root 343 344 345 def schema_contains_external_reference(value: Any) -> bool: 346 if isinstance(value, dict): 347 for key, item in value.items(): 348 if key in {"$ref", "$dynamicRef"} and ( 349 not isinstance(item, str) or not item.startswith("#/") 350 ): 351 return True 352 if schema_contains_external_reference(item): 353 return True 354 elif isinstance(value, list): 355 return any(schema_contains_external_reference(item) for item in value) 356 return False 357 358 359 def validate_schema_instance( 360 schema: dict[str, Any], value: Any, name: str 361 ) -> None: 362 try: 363 Draft202012Validator(schema).validate(value) 364 except ValidationError as error: 365 raise ValueError(f"{name} disagrees with its JSON schema") from error 366 367 368 def verify_toolchain_identity() -> None: 369 if sys.version_info[:3] != VERIFIER_PYTHON: 370 raise RuntimeError("persona verifier Python identity is unavailable") 371 try: 372 schema_version = importlib.metadata.version("jsonschema") 373 except importlib.metadata.PackageNotFoundError as error: 374 raise RuntimeError( 375 "persona verifier schema dependency is unavailable" 376 ) from error 377 if schema_version != VERIFIER_JSONSCHEMA: 378 raise RuntimeError("persona verifier schema dependency is unavailable") 379 380 381 def persona_attempts(suite: dict[str, Any]) -> dict[str, dict[str, Any]]: 382 return { 383 attempt["id"]: {**attempt, "persona": persona["alias"]} 384 for persona in suite["personas"] 385 for attempt in persona["attempts"] 386 } 387 388 389 def read_control(path: Path) -> dict[str, Any] | None: 390 if not path.is_file(): 391 return None 392 try: 393 _, value = read_json(path, 1024) 394 control = exact_keys( 395 value, 396 {"schema", "active_persona", "blossom_enabled"}, 397 "persona control", 398 ) 399 except (OSError, UnicodeError, ValueError, json.JSONDecodeError): 400 return None 401 if ( 402 control["schema"] != PERSONA_CONTROL_SCHEMA 403 or control["active_persona"] not in PERSONA_ALIASES 404 or type(control["blossom_enabled"]) is not bool 405 ): 406 return None 407 return control 408 409 410 class FixtureState: 411 def __init__( 412 self, 413 evidence: Path, 414 control: Path, 415 blossom_port: int, 416 suite: dict[str, Any] | None = None, 417 ) -> None: 418 self._evidence = evidence 419 self.control = control 420 self.blossom_port = blossom_port 421 self._suite = suite 422 self._attempts = persona_attempts(suite) if suite is not None else {} 423 self._lock = threading.Lock() 424 self._events: dict[str, dict[str, Any]] = {} 425 self._blobs: dict[str, tuple[bytes, str]] = {} 426 self._upload_attempts = 0 427 self._accepted_uploads = 0 428 self._retrievals = 0 429 self._subscriptions = 0 430 self._subscriptions_by_persona = {alias: 0 for alias in PERSONA_ALIASES} 431 self._accepted_attempts: dict[str, dict[str, Any]] = {} 432 self._identity_by_persona: dict[str, str] = {} 433 self._persona_by_identity: dict[str, str] = {} 434 self._accepted_uploads_by_persona = {alias: 0 for alias in PERSONA_ALIASES} 435 self._uploaded_digests_by_persona = { 436 alias: set() for alias in PERSONA_ALIASES 437 } 438 self._retrievals_by_persona = {alias: set() for alias in PERSONA_ALIASES} 439 self._unknown_attempts = 0 440 self._duplicate_attempts = 0 441 self._expected_failure_rejections = 0 442 self._transport_rejected_attempts: set[str] = set() 443 self._events_accepted_during_expected_failures = 0 444 self._accepted_connections = 0 445 self._rejected_connections = 0 446 self._non_loopback_attempts = 0 447 self._production_network_contacts = 0 448 self._unintended_publications = 0 449 self._write_evidence() 450 451 def observe_connection(self, host: str) -> bool: 452 try: 453 permitted = ipaddress.ip_address(host).is_loopback 454 except ValueError: 455 permitted = False 456 with self._lock: 457 if permitted: 458 self._accepted_connections += 1 459 else: 460 self._rejected_connections += 1 461 self._non_loopback_attempts += 1 462 self._production_network_contacts += 1 463 self._write_evidence_locked() 464 return permitted 465 466 def publish(self, event: dict[str, Any]) -> bool | None: 467 if not valid_nostr_event(event) or not verify_nostr_signature(event): 468 return False 469 if event["kind"] == BUD11_EVENT_KIND: 470 if self._suite is not None: 471 with self._lock: 472 self._unintended_publications += 1 473 self._write_evidence_locked() 474 return False 475 if self._suite is not None: 476 return self._publish_persona_event(event) 477 event_id = event["id"] 478 with self._lock: 479 if event_id not in self._events and len(self._events) >= MAX_EVENTS: 480 return False 481 self._events[event_id] = event 482 self._write_evidence_locked() 483 return True 484 485 def _publish_persona_event(self, event: dict[str, Any]) -> bool | None: 486 attempt = classify_attempt(event, self._attempts) 487 control = read_control(self.control) 488 with self._lock: 489 if attempt is None or control is None: 490 self._unknown_attempts += 1 491 self._unintended_publications += 1 492 self._write_evidence_locked() 493 return False 494 attempt_id = attempt["id"] 495 persona = attempt["persona"] 496 if control["active_persona"] != persona: 497 self._unknown_attempts += 1 498 self._unintended_publications += 1 499 self._write_evidence_locked() 500 return False 501 if event["kind"] != FLOW_KINDS[attempt["flow"]]: 502 self._unintended_publications += 1 503 self._write_evidence_locked() 504 return False 505 public_key = event["pubkey"] 506 existing_identity = self._identity_by_persona.get(persona) 507 existing_persona = self._persona_by_identity.get(public_key) 508 if ( 509 (existing_identity is not None and existing_identity != public_key) 510 or (existing_persona is not None and existing_persona != persona) 511 ): 512 self._unintended_publications += 1 513 self._write_evidence_locked() 514 return False 515 self._identity_by_persona[persona] = public_key 516 self._persona_by_identity[public_key] = persona 517 if ( 518 attempt["expected_failure"] == "transport_retry_relaunch" 519 and attempt_id not in self._transport_rejected_attempts 520 ): 521 self._transport_rejected_attempts.add(attempt_id) 522 self._expected_failure_rejections += 1 523 self._write_evidence_locked() 524 return None 525 if attempt_id in self._accepted_attempts: 526 self._duplicate_attempts += 1 527 self._write_evidence_locked() 528 return False 529 if len(self._events) >= MAX_EVENTS: 530 return False 531 self._events[event["id"]] = event 532 self._accepted_attempts[attempt_id] = { 533 "id": attempt_id, 534 "flow": attempt["flow"], 535 "event_kind": event["kind"], 536 "accepted": True, 537 "expected_failure_rejections": int( 538 attempt_id in self._transport_rejected_attempts 539 ), 540 } 541 self._write_evidence_locked() 542 return True 543 544 def query(self, filters: list[dict[str, Any]]) -> list[dict[str, Any]]: 545 control = read_control(self.control) 546 with self._lock: 547 self._subscriptions += 1 548 if control is not None: 549 self._subscriptions_by_persona[control["active_persona"]] += 1 550 events = list(self._events.values()) 551 self._write_evidence_locked() 552 selected = [ 553 event for event in events if any(matches(event, item) for item in filters) 554 ] 555 selected.sort(key=lambda item: (item.get("created_at", 0), item.get("id", ""))) 556 limits = [ 557 item["limit"] 558 for item in filters 559 if isinstance(item, dict) and type(item.get("limit")) is int 560 ] 561 count = min(max(max(limits, default=len(selected)), 0), MAX_EVENTS) 562 return selected[-count:] if count else [] 563 564 def upload( 565 self, 566 body: bytes, 567 media_type: str, 568 expected_hash: str, 569 authorization: str, 570 ) -> tuple[bool, dict[str, Any]]: 571 digest = hashlib.sha256(body).hexdigest() 572 control = read_control(self.control) 573 with self._lock: 574 self._upload_attempts += 1 575 persona = control["active_persona"] if control is not None else None 576 if self._suite is None: 577 allowed = self.control.is_file() and valid_blossom_authorization( 578 authorization, expected_hash, BUD11_SERVER_DOMAIN 579 ) 580 else: 581 allowed = ( 582 control is not None 583 and control["blossom_enabled"] 584 and persona in PHOTO_PERSONAS 585 and valid_blossom_authorization( 586 authorization, expected_hash, BUD11_SERVER_DOMAIN 587 ) 588 ) 589 capacity = digest in self._blobs or len(self._blobs) < MAX_BLOBS 590 if allowed and capacity and digest == expected_hash: 591 self._blobs[digest] = (body, media_type) 592 self._accepted_uploads += 1 593 if persona is not None: 594 self._accepted_uploads_by_persona[persona] += 1 595 self._uploaded_digests_by_persona[persona].add(digest) 596 self._write_evidence_locked() 597 descriptor = { 598 "url": f"http://127.0.0.1:{self.blossom_port}/{digest}.png", 599 "sha256": digest, 600 "size": len(body), 601 "type": media_type, 602 "uploaded": int(time.time()), 603 } 604 return allowed and capacity and digest == expected_hash, descriptor 605 606 def retrieve(self, digest: str) -> tuple[bytes, str] | None: 607 control = read_control(self.control) 608 with self._lock: 609 value = self._blobs.get(digest) 610 if value is not None: 611 if self._suite is None: 612 self._retrievals += 1 613 elif control is not None: 614 persona = control["active_persona"] 615 if digest in self._uploaded_digests_by_persona[persona]: 616 before = len(self._retrievals_by_persona[persona]) 617 self._retrievals_by_persona[persona].add(digest) 618 self._retrievals += ( 619 len(self._retrievals_by_persona[persona]) - before 620 ) 621 self._write_evidence_locked() 622 return value 623 624 def _write_evidence(self) -> None: 625 with self._lock: 626 self._write_evidence_locked() 627 628 def _write_evidence_locked(self) -> None: 629 if self._suite is None: 630 payload = { 631 "schema": "tera-ios-local-social-fixture-evidence-v1", 632 "schema_version": 1, 633 "accepted_events": len(self._events), 634 "event_kinds": sorted( 635 event["kind"] 636 for event in self._events.values() 637 if isinstance(event.get("kind"), int) 638 ), 639 "subscriptions": self._subscriptions, 640 "upload_attempts": self._upload_attempts, 641 "accepted_uploads": self._accepted_uploads, 642 "retrievals": self._retrievals, 643 "accepted_connections": self._accepted_connections, 644 "rejected_connections": self._rejected_connections, 645 "non_loopback_attempts": self._non_loopback_attempts, 646 "production_network_contacts": self._production_network_contacts, 647 } 648 else: 649 payload = self._persona_evidence() 650 temporary = self._evidence.with_suffix(".tmp") 651 temporary.write_text( 652 json.dumps(payload, sort_keys=True) + "\n", encoding="utf-8" 653 ) 654 os.replace(temporary, self._evidence) 655 656 def _persona_evidence(self) -> dict[str, Any]: 657 personas = [] 658 for persona in self._suite["personas"]: 659 alias = persona["alias"] 660 public_key = self._identity_by_persona.get(alias) 661 personas.append( 662 { 663 "alias": alias, 664 "identity_sha256": identity_digest(public_key), 665 "subscriptions": self._subscriptions_by_persona[alias], 666 "accepted_uploads": self._accepted_uploads_by_persona[alias], 667 "retrievals": len(self._retrievals_by_persona[alias]), 668 "attempts": [ 669 self._accepted_attempts[attempt["id"]] 670 for attempt in persona["attempts"] 671 if attempt["id"] in self._accepted_attempts 672 ], 673 } 674 ) 675 kind_counts = { 676 str(kind): sum(event["kind"] == kind for event in self._events.values()) 677 for kind in (1, 31923, 30402) 678 } 679 flow_counts = { 680 flow: sum(item["flow"] == flow for item in self._accepted_attempts.values()) 681 for flow in FLOW_KINDS 682 } 683 return { 684 "schema": "tera.ios.local-social.persona-evidence.v1", 685 "schema_version": 1, 686 "personas": personas, 687 "flow_counts": flow_counts, 688 "accepted_events": len(self._events), 689 "event_kind_counts": kind_counts, 690 "upload_attempts": self._upload_attempts, 691 "accepted_uploads": self._accepted_uploads, 692 "retrievals": self._retrievals, 693 "distinct_identities": len(self._persona_by_identity), 694 "unknown_attempts": self._unknown_attempts, 695 "duplicate_attempts": self._duplicate_attempts, 696 "expected_failure_rejections": self._expected_failure_rejections, 697 "events_accepted_during_expected_failures": ( 698 self._events_accepted_during_expected_failures 699 ), 700 "accepted_connections": self._accepted_connections, 701 "rejected_connections": self._rejected_connections, 702 "non_loopback_attempts": self._non_loopback_attempts, 703 "production_network_contacts": self._production_network_contacts, 704 "unintended_publications": self._unintended_publications, 705 "final_candidate_data_loss": 0, 706 } 707 708 709 def matches(event: dict[str, Any], item: dict[str, Any]) -> bool: 710 if not isinstance(item, dict): 711 return False 712 if isinstance(item.get("ids"), list) and event.get("id") not in item["ids"]: 713 return False 714 if ( 715 isinstance(item.get("authors"), list) 716 and event.get("pubkey") not in item["authors"] 717 ): 718 return False 719 if isinstance(item.get("kinds"), list) and event.get("kind") not in item["kinds"]: 720 return False 721 created_at = event.get("created_at") 722 if not isinstance(created_at, int): 723 return False 724 if isinstance(item.get("since"), int) and created_at < item["since"]: 725 return False 726 if isinstance(item.get("until"), int) and created_at > item["until"]: 727 return False 728 tags = event.get("tags") 729 if not isinstance(tags, list): 730 return False 731 for key, expected in item.items(): 732 if not key.startswith("#") or not isinstance(expected, list): 733 continue 734 name = key[1:] 735 if not any( 736 isinstance(tag, list) 737 and len(tag) >= 2 738 and tag[0] == name 739 and tag[1] in expected 740 for tag in tags 741 ): 742 return False 743 return True 744 745 746 def valid_nostr_event(event: dict[str, Any]) -> bool: 747 if not isinstance(event, dict) or set(event) != { 748 "id", 749 "pubkey", 750 "created_at", 751 "kind", 752 "tags", 753 "content", 754 "sig", 755 }: 756 return False 757 if not lowercase_hex(event["id"], 64) or not lowercase_hex(event["pubkey"], 64): 758 return False 759 if not lowercase_hex(event["sig"], 128): 760 return False 761 if type(event["created_at"]) is not int or event["created_at"] < 0: 762 return False 763 if type(event["kind"]) is not int or not 0 <= event["kind"] <= 0xFFFF_FFFF: 764 return False 765 if not isinstance(event["content"], str) or not isinstance(event["tags"], list): 766 return False 767 if not all( 768 isinstance(tag, list) and all(isinstance(value, str) for value in tag) 769 for tag in event["tags"] 770 ): 771 return False 772 preimage = json.dumps( 773 [ 774 0, 775 event["pubkey"], 776 event["created_at"], 777 event["kind"], 778 event["tags"], 779 event["content"], 780 ], 781 ensure_ascii=False, 782 separators=(",", ":"), 783 ).encode("utf-8") 784 return hashlib.sha256(preimage).hexdigest() == event["id"] 785 786 787 def lowercase_hex(value: Any, length: int) -> bool: 788 return ( 789 isinstance(value, str) 790 and len(value) == length 791 and all(character in "0123456789abcdef" for character in value) 792 ) 793 794 795 def classify_attempt( 796 event: dict[str, Any], attempts: dict[str, dict[str, Any]] 797 ) -> dict[str, Any] | None: 798 values = [event.get("content")] 799 for tag in event.get("tags", []): 800 values.extend(tag) 801 matches = [ 802 attempt 803 for attempt in attempts.values() 804 if ( 805 photo_attempt_matches(event, attempt) 806 if attempt.get("flow") == "PhotoUpdate" 807 else attempt["marker"] in values 808 ) 809 ] 810 return matches[0] if len(matches) == 1 else None 811 812 813 def photo_attempt_matches(event: dict[str, Any], attempt: dict[str, Any]) -> bool: 814 if attempt.get("flow") != "PhotoUpdate": 815 return False 816 content = event.get("content") 817 if not isinstance(content, str): 818 return False 819 lines = content.split("\n") 820 if len(lines) != 2 or lines[0] != attempt.get("marker"): 821 return False 822 url = lines[1] 823 match = re.fullmatch( 824 r"http://127\.0\.0\.1:([1-9][0-9]{0,4})/([0-9a-f]{64})\.png", url 825 ) 826 if match is None or int(match.group(1)) > 65535: 827 return False 828 digest = match.group(2) 829 imeta = [ 830 tag 831 for tag in event.get("tags", []) 832 if isinstance(tag, list) and tag[:1] == ["imeta"] 833 ] 834 return len(imeta) == 1 and all( 835 field in imeta[0] 836 for field in (f"url {url}", f"x {digest}", "m image/png") 837 ) 838 839 840 def identity_digest(public_key: str | None) -> str: 841 if public_key is None: 842 return "0" * 64 843 return hashlib.sha256( 844 b"radroots.ios.local-social.persona-identity.v1\0" 845 + bytes.fromhex(public_key) 846 ).hexdigest() 847 848 849 SECP256K1_FIELD = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F 850 SECP256K1_ORDER = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 851 SECP256K1_GENERATOR = ( 852 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798, 853 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8, 854 ) 855 856 857 def point_add( 858 left: tuple[int, int] | None, right: tuple[int, int] | None 859 ) -> tuple[int, int] | None: 860 if left is None: 861 return right 862 if right is None: 863 return left 864 if left[0] == right[0] and left[1] != right[1]: 865 return None 866 if left == right: 867 if left[1] == 0: 868 return None 869 slope = (3 * left[0] * left[0]) * pow(2 * left[1], -1, SECP256K1_FIELD) 870 else: 871 slope = (right[1] - left[1]) * pow( 872 right[0] - left[0], -1, SECP256K1_FIELD 873 ) 874 slope %= SECP256K1_FIELD 875 x = (slope * slope - left[0] - right[0]) % SECP256K1_FIELD 876 y = (slope * (left[0] - x) - left[1]) % SECP256K1_FIELD 877 return x, y 878 879 880 def point_multiply(value: int, point: tuple[int, int]) -> tuple[int, int] | None: 881 result = None 882 current: tuple[int, int] | None = point 883 while value: 884 if value & 1: 885 result = point_add(result, current) 886 current = point_add(current, current) 887 value >>= 1 888 return result 889 890 891 def tagged_hash(tag: str, payload: bytes) -> bytes: 892 tag_hash = hashlib.sha256(tag.encode("ascii")).digest() 893 return hashlib.sha256(tag_hash + tag_hash + payload).digest() 894 895 896 def verify_bip340(public_key: bytes, message: bytes, signature: bytes) -> bool: 897 if len(public_key) != 32 or len(message) != 32 or len(signature) != 64: 898 return False 899 x = int.from_bytes(public_key, "big") 900 r = int.from_bytes(signature[:32], "big") 901 s = int.from_bytes(signature[32:], "big") 902 if x >= SECP256K1_FIELD or r >= SECP256K1_FIELD or s >= SECP256K1_ORDER: 903 return False 904 y_squared = (pow(x, 3, SECP256K1_FIELD) + 7) % SECP256K1_FIELD 905 y = pow(y_squared, (SECP256K1_FIELD + 1) // 4, SECP256K1_FIELD) 906 if pow(y, 2, SECP256K1_FIELD) != y_squared: 907 return False 908 if y & 1: 909 y = SECP256K1_FIELD - y 910 challenge = int.from_bytes( 911 tagged_hash("BIP0340/challenge", signature[:32] + public_key + message), 912 "big", 913 ) % SECP256K1_ORDER 914 negative = (x, (-y) % SECP256K1_FIELD) 915 candidate = point_add( 916 point_multiply(s, SECP256K1_GENERATOR), 917 point_multiply(challenge, negative), 918 ) 919 return candidate is not None and candidate[1] % 2 == 0 and candidate[0] == r 920 921 922 def verify_nostr_signature(event: dict[str, Any]) -> bool: 923 try: 924 return verify_bip340( 925 bytes.fromhex(event["pubkey"]), 926 bytes.fromhex(event["id"]), 927 bytes.fromhex(event["sig"]), 928 ) 929 except (KeyError, TypeError, ValueError): 930 return False 931 932 933 def valid_bud11_server_domain(value: Any) -> bool: 934 if ( 935 not isinstance(value, str) 936 or not value 937 or len(value) > 253 938 or not value.isascii() 939 or value.lower() != value 940 ): 941 return False 942 try: 943 address = ipaddress.ip_address(value) 944 except ValueError: 945 labels = value.split(".") 946 return not all(label.isdigit() for label in labels) and all( 947 1 <= len(label) <= 63 948 and re.fullmatch(r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?", label) 949 is not None 950 for label in labels 951 ) 952 return isinstance(address, ipaddress.IPv4Address) and str(address) == value 953 954 955 def valid_bud11_content(value: Any) -> bool: 956 return ( 957 isinstance(value, str) 958 and value 959 and len(value.encode("utf-8")) <= BUD11_CONTENT_MAX_BYTES 960 and value.strip() == value 961 and not any( 962 unicodedata.category(character) == "Cc" 963 and character not in "\t\n\r" 964 for character in value 965 ) 966 ) 967 968 969 def canonical_unsigned_decimal(value: Any) -> int | None: 970 if ( 971 not isinstance(value, str) 972 or not value 973 or len(value) > 20 974 or any(character not in "0123456789" for character in value) 975 or (len(value) > 1 and value.startswith("0")) 976 ): 977 return None 978 parsed = int(value) 979 return parsed if parsed <= 0xFFFF_FFFF_FFFF_FFFF else None 980 981 982 def valid_blossom_authorization( 983 authorization: str, 984 expected_hash: str, 985 expected_server: str, 986 now_unix_s: int | None = None, 987 ) -> bool: 988 if not lowercase_hex(expected_hash, 64) or not valid_bud11_server_domain( 989 expected_server 990 ): 991 return False 992 if not authorization.startswith("Nostr "): 993 return False 994 payload = authorization.removeprefix("Nostr ") 995 if ( 996 not payload 997 or len(payload) > BUD11_AUTHORIZATION_ENCODED_MAX_BYTES 998 or any(character.isspace() for character in payload) 999 or "=" in payload 1000 or re.fullmatch(r"[A-Za-z0-9_-]+", payload) is None 1001 ): 1002 return False 1003 try: 1004 raw = base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)) 1005 if len(raw) > BUD11_AUTHORIZATION_MAX_BYTES: 1006 return False 1007 if base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") != payload: 1008 return False 1009 event = json.loads(raw, object_pairs_hook=strict_object) 1010 except (ValueError, json.JSONDecodeError): 1011 return False 1012 if not valid_nostr_event(event) or not verify_nostr_signature(event): 1013 return False 1014 if event["kind"] != BUD11_EVENT_KIND or not valid_bud11_content(event["content"]): 1015 return False 1016 if len(event["tags"]) != 4 or event["tags"][0] != ["t", "upload"]: 1017 return False 1018 expiration_tag = event["tags"][1] 1019 if ( 1020 len(expiration_tag) != 2 1021 or expiration_tag[0] != "expiration" 1022 or event["tags"][2] != ["x", expected_hash] 1023 or event["tags"][3] != ["server", expected_server] 1024 ): 1025 return False 1026 expiration = canonical_unsigned_decimal(expiration_tag[1]) 1027 now = int(time.time()) if now_unix_s is None else now_unix_s 1028 return ( 1029 type(now) is int 1030 and now >= 0 1031 and expiration is not None 1032 and event["created_at"] < now < expiration 1033 and now - event["created_at"] <= BUD11_MAX_CREATED_AGE_SECONDS 1034 and 0 < expiration - event["created_at"] <= BUD11_MAX_LIFETIME_SECONDS 1035 ) 1036 1037 1038 class ObservableLoopbackServerMixin: 1039 _fixture_state: FixtureState 1040 1041 def verify_request( 1042 self, request: socket.socket, client_address: tuple[str, int] 1043 ) -> bool: 1044 del request 1045 return self._fixture_state.observe_connection(client_address[0]) 1046 1047 1048 class ReusableThreadingServer( 1049 ObservableLoopbackServerMixin, socketserver.ThreadingTCPServer 1050 ): 1051 allow_reuse_address = True 1052 daemon_threads = True 1053 1054 def __init__( 1055 self, 1056 server_address: tuple[str, int], 1057 handler: type[socketserver.BaseRequestHandler], 1058 state: FixtureState, 1059 ) -> None: 1060 self._fixture_state = state 1061 super().__init__(server_address, handler) 1062 1063 1064 class ObservableLoopbackHTTPServer( 1065 ObservableLoopbackServerMixin, http.server.ThreadingHTTPServer 1066 ): 1067 def __init__( 1068 self, 1069 server_address: tuple[str, int], 1070 handler: type[http.server.BaseHTTPRequestHandler], 1071 state: FixtureState, 1072 ) -> None: 1073 self._fixture_state = state 1074 super().__init__(server_address, handler) 1075 1076 1077 class LoopbackConnectionFactory: 1078 @staticmethod 1079 def relay(port: int, state: FixtureState) -> ReusableThreadingServer: 1080 return ReusableThreadingServer(("127.0.0.1", port), RelayHandler, state) 1081 1082 @staticmethod 1083 def blossom(port: int, state: FixtureState) -> ObservableLoopbackHTTPServer: 1084 return ObservableLoopbackHTTPServer( 1085 ("127.0.0.1", port), BlossomHandler, state 1086 ) 1087 1088 1089 class RelayHandler(socketserver.BaseRequestHandler): 1090 state: FixtureState 1091 1092 def handle(self) -> None: 1093 self.request.settimeout(15) 1094 headers = read_until(self.request, b"\r\n\r\n", 16 * 1024) 1095 lines = headers.decode("ascii").split("\r\n") 1096 if not lines or lines[0] != "GET / HTTP/1.1": 1097 return 1098 fields = {} 1099 for line in lines[1:]: 1100 if ":" in line: 1101 key, value = line.split(":", 1) 1102 fields[key.lower()] = value.strip() 1103 key = fields.get("sec-websocket-key") 1104 if not key or fields.get("upgrade", "").lower() != "websocket": 1105 return 1106 accept = base64.b64encode( 1107 hashlib.sha1( 1108 (key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode() 1109 ).digest() 1110 ).decode() 1111 self.request.sendall( 1112 ( 1113 "HTTP/1.1 101 Switching Protocols\r\n" 1114 "Upgrade: websocket\r\n" 1115 "Connection: Upgrade\r\n" 1116 f"Sec-WebSocket-Accept: {accept}\r\n\r\n" 1117 ).encode("ascii") 1118 ) 1119 while True: 1120 frame = read_frame(self.request) 1121 if frame is None: 1122 return 1123 opcode, payload = frame 1124 if opcode == 0x8: 1125 return 1126 if opcode == 0x9: 1127 send_frame(self.request, 0xA, payload) 1128 continue 1129 if opcode != 0x1: 1130 continue 1131 try: 1132 message = json.loads(payload) 1133 except (UnicodeDecodeError, json.JSONDecodeError): 1134 continue 1135 if not isinstance(message, list) or not message: 1136 continue 1137 if ( 1138 message[0] == "EVENT" 1139 and len(message) == 2 1140 and isinstance(message[1], dict) 1141 ): 1142 event_id = message[1].get("id", "") 1143 accepted = self.state.publish(message[1]) 1144 if accepted is None: 1145 return 1146 send_json( 1147 self.request, 1148 ["OK", event_id, accepted, "" if accepted else "invalid"], 1149 ) 1150 elif ( 1151 message[0] == "REQ" 1152 and len(message) >= 3 1153 and isinstance(message[1], str) 1154 ): 1155 subscription = message[1] 1156 filters = [value for value in message[2:] if isinstance(value, dict)] 1157 for event in self.state.query(filters): 1158 send_json(self.request, ["EVENT", subscription, event]) 1159 send_json(self.request, ["EOSE", subscription]) 1160 1161 1162 def read_until(stream: socket.socket, marker: bytes, maximum: int) -> bytes: 1163 value = bytearray() 1164 while marker not in value: 1165 chunk = stream.recv(4096) 1166 if not chunk: 1167 raise ConnectionError("socket closed") 1168 value.extend(chunk) 1169 if len(value) > maximum: 1170 raise ValueError("request too large") 1171 return bytes(value) 1172 1173 1174 def read_exact(stream: socket.socket, length: int) -> bytes: 1175 value = bytearray() 1176 while len(value) < length: 1177 chunk = stream.recv(length - len(value)) 1178 if not chunk: 1179 raise ConnectionError("socket closed") 1180 value.extend(chunk) 1181 return bytes(value) 1182 1183 1184 def read_frame(stream: socket.socket) -> tuple[int, bytes] | None: 1185 try: 1186 first, second = read_exact(stream, 2) 1187 except (ConnectionError, OSError, TimeoutError): 1188 return None 1189 if first & 0x80 == 0 or second & 0x80 == 0: 1190 return None 1191 length = second & 0x7F 1192 if length == 126: 1193 length = struct.unpack("!H", read_exact(stream, 2))[0] 1194 elif length == 127: 1195 length = struct.unpack("!Q", read_exact(stream, 8))[0] 1196 if length > MAX_WEBSOCKET_MESSAGE: 1197 return None 1198 mask = read_exact(stream, 4) 1199 payload = bytearray(read_exact(stream, length)) 1200 for index in range(length): 1201 payload[index] ^= mask[index % 4] 1202 return first & 0x0F, bytes(payload) 1203 1204 1205 def send_frame(stream: socket.socket, opcode: int, payload: bytes) -> None: 1206 length = len(payload) 1207 header = bytearray([0x80 | opcode]) 1208 if length < 126: 1209 header.append(length) 1210 elif length <= 0xFFFF: 1211 header.append(126) 1212 header.extend(struct.pack("!H", length)) 1213 else: 1214 header.append(127) 1215 header.extend(struct.pack("!Q", length)) 1216 stream.sendall(header + payload) 1217 1218 1219 def send_json(stream: socket.socket, value: Any) -> None: 1220 send_frame(stream, 0x1, json.dumps(value, separators=(",", ":")).encode()) 1221 1222 1223 class BlossomHandler(http.server.BaseHTTPRequestHandler): 1224 state: FixtureState 1225 protocol_version = "HTTP/1.1" 1226 1227 def do_PUT(self) -> None: # noqa: N802 1228 if self.path != "/upload": 1229 self.send_error(404) 1230 return 1231 try: 1232 length = int(self.headers.get("Content-Length", "-1")) 1233 except ValueError: 1234 length = -1 1235 authorization = self.headers.get("Authorization", "") 1236 expected_hash = self.headers.get("X-SHA-256", "") 1237 media_type = self.headers.get("Content-Type", "") 1238 if ( 1239 length < 1 1240 or length > MAX_HTTP_BODY 1241 or not lowercase_hex(expected_hash, 64) 1242 or not authorization.startswith("Nostr ") 1243 or media_type != "image/png" 1244 ): 1245 self.send_error(400) 1246 return 1247 body = self.rfile.read(length) 1248 accepted, descriptor = self.state.upload( 1249 body, media_type, expected_hash, authorization 1250 ) 1251 if not accepted: 1252 self.respond(503, b'{"error":"fixture_retry_required"}', "application/json") 1253 return 1254 self.respond( 1255 200, 1256 json.dumps(descriptor, separators=(",", ":")).encode(), 1257 "application/json", 1258 ) 1259 1260 def do_GET(self) -> None: # noqa: N802 1261 component = self.path.removeprefix("/") 1262 if component.endswith(".png") and lowercase_hex(component[:-4], 64): 1263 value = self.state.retrieve(component[:-4]) 1264 if value is not None: 1265 self.respond(200, value[0], value[1]) 1266 return 1267 self.respond(404, b'{"error":"not_found"}', "application/json") 1268 1269 def respond(self, status: int, body: bytes, content_type: str) -> None: 1270 self.send_response(status) 1271 self.send_header("Content-Type", content_type) 1272 self.send_header("Content-Length", str(len(body))) 1273 self.send_header("Connection", "close") 1274 self.end_headers() 1275 self.wfile.write(body) 1276 1277 def log_message(self, format: str, *args: Any) -> None: 1278 return 1279 1280 1281 def serve(arguments: argparse.Namespace) -> int: 1282 evidence = Path(arguments.evidence).resolve() 1283 ready = Path(arguments.ready).resolve() 1284 control = Path(arguments.control).resolve() 1285 for path in (evidence, ready, control): 1286 path.parent.mkdir(parents=True, exist_ok=True) 1287 control.unlink(missing_ok=True) 1288 ready.unlink(missing_ok=True) 1289 suite = None 1290 if arguments.persona_fixture is not None: 1291 _, suite = load_persona_suite(Path(arguments.persona_fixture).resolve()) 1292 state = FixtureState(evidence, control, arguments.blossom_port, suite) 1293 RelayHandler.state = state 1294 BlossomHandler.state = state 1295 relay = LoopbackConnectionFactory.relay(arguments.relay_port, state) 1296 blossom = LoopbackConnectionFactory.blossom(arguments.blossom_port, state) 1297 threads = [ 1298 threading.Thread(target=relay.serve_forever, daemon=True), 1299 threading.Thread(target=blossom.serve_forever, daemon=True), 1300 ] 1301 for thread in threads: 1302 thread.start() 1303 ready.write_text( 1304 json.dumps( 1305 { 1306 "schema": "tera-ios-local-social-fixture-ready-v1", 1307 "relay": f"ws://127.0.0.1:{arguments.relay_port}", 1308 "blossom": f"http://127.0.0.1:{arguments.blossom_port}", 1309 }, 1310 sort_keys=True, 1311 ) 1312 + "\n", 1313 encoding="utf-8", 1314 ) 1315 stopped = threading.Event() 1316 1317 def stop(_signum: int, _frame: Any) -> None: 1318 stopped.set() 1319 1320 signal.signal(signal.SIGTERM, stop) 1321 signal.signal(signal.SIGINT, stop) 1322 stopped.wait() 1323 relay.shutdown() 1324 blossom.shutdown() 1325 relay.server_close() 1326 blossom.server_close() 1327 for thread in threads: 1328 thread.join(timeout=5) 1329 return 0 1330 1331 1332 def verify(arguments: argparse.Namespace) -> int: 1333 _, payload = read_json(Path(arguments.evidence).resolve()) 1334 if ( 1335 payload.get("schema") != "tera-ios-local-social-fixture-evidence-v1" 1336 or payload.get("accepted_events", 0) < 5 1337 or payload.get("subscriptions", 0) < 1 1338 or payload.get("upload_attempts", 0) < 1 1339 or payload.get("accepted_uploads", 0) < 1 1340 or payload.get("retrievals", 0) < 1 1341 or payload.get("non_loopback_attempts") != 0 1342 or payload.get("production_network_contacts") != 0 1343 ): 1344 raise SystemExit("local-social fixture evidence is incomplete") 1345 print("local-social fixture evidence verified") 1346 return 0 1347 1348 1349 def verify_accessibility(arguments: argparse.Namespace) -> int: 1350 _, payload = read_json(Path(arguments.evidence).resolve()) 1351 if ( 1352 payload.get("schema") != "tera-ios-local-social-fixture-evidence-v1" 1353 or payload.get("accepted_events") != 0 1354 or payload.get("upload_attempts") != 0 1355 or payload.get("accepted_uploads") != 0 1356 or payload.get("retrievals") != 0 1357 or payload.get("subscriptions", 0) < 1 1358 ): 1359 raise SystemExit("local-social accessibility fixture evidence is invalid") 1360 print("local-social accessibility fixture evidence verified") 1361 return 0 1362 1363 1364 def verify_persona_fixture(arguments: argparse.Namespace) -> int: 1365 raw, suite = load_persona_suite(Path(arguments.fixture).resolve()) 1366 fixture_schema, fixture_schema_value = load_schema_file( 1367 Path(arguments.fixture_schema).resolve(), 1368 "https://radroots.org/schemas/ios/local-social-personas.v1.schema.json", 1369 ) 1370 result_schema = validate_schema_file( 1371 Path(arguments.result_schema).resolve(), 1372 "https://radroots.org/schemas/ios/local-social-persona-results.v1.schema.json", 1373 ) 1374 attempt_schema = validate_schema_file( 1375 Path(arguments.attempt_schema).resolve(), 1376 "https://radroots.org/schemas/ios/local-social-persona-attempt-evidence.v1.schema.json", 1377 ) 1378 result_v2_schema = validate_schema_file( 1379 Path(arguments.result_v2_schema).resolve(), 1380 "https://radroots.org/schemas/ios/local-social-persona-results.v2.schema.json", 1381 ) 1382 validate_schema_instance(fixture_schema_value, suite, "persona fixture") 1383 print( 1384 "local-social persona fixtures verified: " 1385 f"fixture={hashlib.sha256(raw).hexdigest()} " 1386 f"fixture_schema={hashlib.sha256(fixture_schema).hexdigest()} " 1387 f"result_schema={hashlib.sha256(result_schema).hexdigest()} " 1388 f"attempt_schema={hashlib.sha256(attempt_schema).hexdigest()} " 1389 f"result_v2_schema={hashlib.sha256(result_v2_schema).hexdigest()}" 1390 ) 1391 return 0 1392 1393 1394 def verify_bud11_corpus(arguments: argparse.Namespace) -> int: 1395 raw, corpus = load_bud11_mutation_corpus(Path(arguments.corpus).resolve()) 1396 schema, schema_value = load_schema_file( 1397 Path(arguments.schema).resolve(), 1398 "https://radroots.org/schemas/ios/bud11-upload-authorization-mutations.v1.schema.json", 1399 ) 1400 validate_schema_instance(schema_value, corpus, "BUD-11 corpus") 1401 print( 1402 "BUD-11 mutation corpus verified: " 1403 f"corpus={hashlib.sha256(raw).hexdigest()} " 1404 f"schema={hashlib.sha256(schema).hexdigest()}" 1405 ) 1406 return 0 1407 1408 1409 def bounded_directory_inventory( 1410 path: Path, 1411 maximum_entries: int = MAX_RESULT_BUNDLE_ENTRIES, 1412 maximum_relative_path_bytes: int = MAX_RESULT_BUNDLE_RELATIVE_PATH_BYTES, 1413 ) -> list[tuple[int, bytes, Path]]: 1414 if path.is_symlink() or not path.is_dir(): 1415 raise ValueError("result bundle is not a regular directory") 1416 pending = [(path, Path())] 1417 inventory: list[tuple[int, bytes, Path]] = [] 1418 while pending: 1419 directory, relative_directory = pending.pop() 1420 with os.scandir(directory) as entries: 1421 for entry in entries: 1422 relative_path = relative_directory / entry.name 1423 try: 1424 relative = relative_path.as_posix().encode("utf-8") 1425 except UnicodeError as error: 1426 raise ValueError("result bundle path is not UTF-8") from error 1427 if not relative or len(relative) > maximum_relative_path_bytes: 1428 raise ValueError("result bundle path exceeds its byte bound") 1429 if len(inventory) >= maximum_entries: 1430 raise ValueError("result bundle exceeds its entry bound") 1431 if entry.is_symlink(): 1432 raise ValueError("result bundle contains a symbolic link") 1433 item = Path(entry.path) 1434 if entry.is_dir(follow_symlinks=False): 1435 inventory.append((0, relative, item)) 1436 pending.append((item, relative_path)) 1437 elif entry.is_file(follow_symlinks=False): 1438 inventory.append((1, relative, item)) 1439 else: 1440 raise ValueError("result bundle contains an unsupported entry") 1441 return sorted(inventory, key=lambda item: item[1]) 1442 1443 1444 def directory_digest( 1445 path: Path, 1446 maximum_entries: int = MAX_RESULT_BUNDLE_ENTRIES, 1447 maximum_file_bytes: int = MAX_RESULT_BUNDLE_FILE_BYTES, 1448 maximum_total_bytes: int = MAX_RESULT_BUNDLE_BYTES, 1449 ) -> str: 1450 inventory = bounded_directory_inventory(path, maximum_entries) 1451 digest = hashlib.sha256() 1452 digest.update(RESULT_BUNDLE_DIGEST_DOMAIN) 1453 digest.update(len(inventory).to_bytes(4, "big")) 1454 total_bytes = 0 1455 for kind, relative, item in inventory: 1456 digest.update(bytes((kind,))) 1457 digest.update(len(relative).to_bytes(8, "big")) 1458 digest.update(relative) 1459 if kind == 0: 1460 continue 1461 descriptor = os.open( 1462 item, 1463 os.O_RDONLY | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0), 1464 ) 1465 try: 1466 before = os.fstat(descriptor) 1467 if not stat.S_ISREG(before.st_mode) or before.st_size < 0: 1468 raise ValueError("result bundle contains an unsupported entry") 1469 size = before.st_size 1470 if size > maximum_file_bytes or total_bytes > maximum_total_bytes - size: 1471 raise ValueError("result bundle exceeds its byte bound") 1472 digest.update(size.to_bytes(8, "big")) 1473 remaining = size 1474 with os.fdopen(descriptor, "rb", closefd=False) as stream: 1475 while remaining: 1476 chunk = stream.read(min(64 * 1024, remaining)) 1477 if not chunk: 1478 raise ValueError("result bundle file changed while hashing") 1479 digest.update(chunk) 1480 remaining -= len(chunk) 1481 if stream.read(1): 1482 raise ValueError("result bundle file changed while hashing") 1483 after = os.fstat(descriptor) 1484 if ( 1485 before.st_dev != after.st_dev 1486 or before.st_ino != after.st_ino 1487 or before.st_size != after.st_size 1488 or before.st_mode != after.st_mode 1489 or before.st_mtime_ns != after.st_mtime_ns 1490 or before.st_ctime_ns != after.st_ctime_ns 1491 ): 1492 raise ValueError("result bundle file changed while hashing") 1493 total_bytes += size 1494 finally: 1495 os.close(descriptor) 1496 return digest.hexdigest() 1497 1498 1499 def simulator_metadata(udid: str, result_bundle: Path) -> dict[str, str]: 1500 devices = run_json_command_bounded( 1501 ["xcrun", "simctl", "list", "devices", "--json"], 1502 MAX_XCRESULT_JSON_BYTES, 1503 ) 1504 matches = [ 1505 (runtime, device) 1506 for runtime, values in devices.get("devices", {}).items() 1507 for device in values 1508 if device.get("udid") == udid 1509 ] 1510 if len(matches) != 1: 1511 raise ValueError("simulator identity is unavailable") 1512 runtime, simulator = matches[0] 1513 runtime_version = runtime.rsplit("iOS-", 1)[-1].replace("-", ".") 1514 summary = run_json_command_bounded( 1515 [ 1516 "xcrun", 1517 "xcresulttool", 1518 "get", 1519 "test-results", 1520 "summary", 1521 "--path", 1522 str(result_bundle), 1523 ], 1524 MAX_XCRESULT_JSON_BYTES, 1525 ) 1526 result_devices = [ 1527 row.get("device") 1528 for row in summary.get("devicesAndConfigurations", []) 1529 if isinstance(row, dict) 1530 and isinstance(row.get("device"), dict) 1531 and isinstance(row["device"].get("deviceId"), str) 1532 and row["device"]["deviceId"].upper() == udid.upper() 1533 ] 1534 if len(result_devices) != 1: 1535 raise ValueError("result bundle simulator identity is unavailable") 1536 result_device = result_devices[0] 1537 version = result_device.get("osVersion") 1538 architecture = result_device.get("architecture") 1539 if ( 1540 summary.get("result") != "Passed" 1541 or summary.get("failedTests") != 0 1542 or summary.get("passedTests") != 1 1543 or summary.get("skippedTests") != 0 1544 or simulator.get("isAvailable") is not True 1545 or result_device.get("platform") != "iOS Simulator" 1546 or version != runtime_version 1547 or not isinstance(version, str) 1548 or re.fullmatch(r"[1-9][0-9]*(\.[0-9]+){1,2}", version) is None 1549 or int(version.split(".", 1)[0]) < 18 1550 or architecture != "arm64" 1551 ): 1552 raise ValueError("simulator does not satisfy the development platform contract") 1553 canonical_version = version if version.count(".") == 2 else f"{version}.0" 1554 return { 1555 "udid": udid.upper(), 1556 "os": f"iOS {canonical_version}", 1557 "architecture": architecture, 1558 } 1559 1560 1561 def validate_persona_evidence(value: Any, suite: dict[str, Any]) -> dict[str, Any]: 1562 keys = { 1563 "schema", "schema_version", "personas", "flow_counts", "accepted_events", 1564 "event_kind_counts", "upload_attempts", "accepted_uploads", "retrievals", 1565 "distinct_identities", "unknown_attempts", "duplicate_attempts", 1566 "expected_failure_rejections", "events_accepted_during_expected_failures", 1567 "accepted_connections", "rejected_connections", "non_loopback_attempts", 1568 "production_network_contacts", 1569 "unintended_publications", 1570 "final_candidate_data_loss", 1571 } 1572 evidence = exact_keys(value, keys, "persona evidence") 1573 if ( 1574 evidence["schema"] != "tera.ios.local-social.persona-evidence.v1" 1575 or evidence["schema_version"] != 1 1576 ): 1577 raise ValueError("persona evidence header is invalid") 1578 expected_scalars = { 1579 "accepted_events": 15, 1580 "accepted_uploads": 3, 1581 "retrievals": 3, 1582 "distinct_identities": 5, 1583 "unknown_attempts": 0, 1584 "duplicate_attempts": 0, 1585 "expected_failure_rejections": 1, 1586 "events_accepted_during_expected_failures": 0, 1587 "non_loopback_attempts": 0, 1588 "production_network_contacts": 0, 1589 "unintended_publications": 0, 1590 "final_candidate_data_loss": 0, 1591 } 1592 if any(evidence.get(key) != expected for key, expected in expected_scalars.items()): 1593 raise ValueError("persona evidence totals are invalid") 1594 if ( 1595 type(evidence["accepted_connections"]) is not int 1596 or not 1 <= evidence["accepted_connections"] <= 61_440 1597 or type(evidence["rejected_connections"]) is not int 1598 or not 0 <= evidence["rejected_connections"] <= 61_440 1599 ): 1600 raise ValueError("persona connection evidence is invalid") 1601 if evidence["flow_counts"] != {flow: 3 for flow in FLOW_KINDS}: 1602 raise ValueError("persona flow counts are invalid") 1603 if evidence["event_kind_counts"] != {"1": 9, "31923": 3, "30402": 3}: 1604 raise ValueError("persona event kind counts are invalid") 1605 if not isinstance(evidence["personas"], list) or len(evidence["personas"]) != 5: 1606 raise ValueError("persona evidence inventory is invalid") 1607 identity_digests = set() 1608 for persona, expected in zip(evidence["personas"], suite["personas"], strict=True): 1609 exact_keys( 1610 persona, 1611 { 1612 "alias", 1613 "identity_sha256", 1614 "subscriptions", 1615 "accepted_uploads", 1616 "retrievals", 1617 "attempts", 1618 }, 1619 "persona evidence row", 1620 ) 1621 if ( 1622 persona["alias"] != expected["alias"] 1623 or not lowercase_hex(persona["identity_sha256"], 64) 1624 or persona["identity_sha256"] == "0" * 64 1625 or type(persona["subscriptions"]) is not int 1626 or not 1 <= persona["subscriptions"] <= 4096 1627 or persona["accepted_uploads"] != int(persona["alias"] in PHOTO_PERSONAS) 1628 or persona["retrievals"] != int(persona["alias"] in PHOTO_PERSONAS) 1629 or not isinstance(persona["attempts"], list) 1630 or len(persona["attempts"]) != 3 1631 ): 1632 raise ValueError("persona evidence row is invalid") 1633 identity_digests.add(persona["identity_sha256"]) 1634 for attempt, expected_attempt in zip( 1635 persona["attempts"], expected["attempts"], strict=True 1636 ): 1637 exact_keys( 1638 attempt, 1639 {"id", "flow", "event_kind", "accepted", "expected_failure_rejections"}, 1640 "attempt evidence row", 1641 ) 1642 if ( 1643 attempt["id"] != expected_attempt["id"] 1644 or attempt["flow"] != expected_attempt["flow"] 1645 or attempt["event_kind"] != FLOW_KINDS[expected_attempt["flow"]] 1646 or attempt["accepted"] is not True 1647 or attempt["expected_failure_rejections"] 1648 != int( 1649 expected_attempt["expected_failure"] 1650 == "transport_retry_relaunch" 1651 ) 1652 ): 1653 raise ValueError("attempt evidence row is invalid") 1654 if len(identity_digests) != 5: 1655 raise ValueError("persona identities are not distinct") 1656 return evidence 1657 1658 1659 def valid_ios_runtime(value: Any) -> bool: 1660 if not isinstance(value, str) or not re.fullmatch( 1661 r"iOS ([1-9][0-9]*)(\.[0-9]+){1,2}", value 1662 ): 1663 return False 1664 return int(value.split()[1].split(".", 1)[0]) >= 18 1665 1666 1667 def canonical_evidence_bytes(value: Any) -> bytes: 1668 return json.dumps( 1669 value, ensure_ascii=True, separators=(",", ":"), sort_keys=True 1670 ).encode("utf-8") 1671 1672 1673 def evidence_contains_forbidden_key(value: Any) -> bool: 1674 if isinstance(value, dict): 1675 return any( 1676 key.lower() in FORBIDDEN_EVIDENCE_KEYS 1677 or evidence_contains_forbidden_key(item) 1678 for key, item in value.items() 1679 ) 1680 if isinstance(value, list): 1681 return any(evidence_contains_forbidden_key(item) for item in value) 1682 return False 1683 1684 1685 def persona_invocation() -> dict[str, str]: 1686 return { 1687 "target": PERSONA_TEST_TARGET, 1688 "identifier": PERSONA_TEST_IDENTIFIER, 1689 "action": PERSONA_TEST_ACTION, 1690 "configuration": PERSONA_TEST_CONFIGURATION, 1691 } 1692 1693 1694 def validate_persona_attempt_evidence( 1695 value: Any, 1696 suite: dict[str, Any], 1697 *, 1698 require_measured_network: bool, 1699 ) -> dict[str, Any]: 1700 keys = { 1701 "schema", 1702 "schema_version", 1703 "test_invocation", 1704 "source", 1705 "app_build_sha256", 1706 "simulator", 1707 "run_id", 1708 "persona_run_id", 1709 "persona_alias", 1710 "attempt_id", 1711 "attempt_order", 1712 "flow", 1713 "expected_failure", 1714 "public_identity_sha256", 1715 "endpoint_policy_sha256", 1716 "ui_observation", 1717 "network_observation", 1718 "accessibility", 1719 "artifact_digests", 1720 } 1721 attempt = exact_keys(value, keys, "persona attempt evidence") 1722 if evidence_contains_forbidden_key(attempt): 1723 raise ValueError("persona attempt evidence contains a forbidden field") 1724 if ( 1725 attempt["schema"] != PERSONA_ATTEMPT_SCHEMA 1726 or attempt["schema_version"] != 1 1727 or attempt["test_invocation"] != persona_invocation() 1728 or not lowercase_hex(attempt["app_build_sha256"], 64) 1729 or not lowercase_hex(attempt["public_identity_sha256"], 64) 1730 or attempt["public_identity_sha256"] == "0" * 64 1731 or not lowercase_hex(attempt["endpoint_policy_sha256"], 64) 1732 or not re.fullmatch( 1733 r"[a-z0-9][a-z0-9-]{6,62}[a-z0-9]", attempt["run_id"] 1734 ) 1735 ): 1736 raise ValueError("persona attempt evidence header is invalid") 1737 source = exact_keys(attempt["source"], {"commit", "tree"}, "attempt source") 1738 if not lowercase_hex(source["commit"], 40) or not lowercase_hex( 1739 source["tree"], 40 1740 ): 1741 raise ValueError("persona attempt source identity is invalid") 1742 simulator = exact_keys( 1743 attempt["simulator"], {"udid", "os", "architecture"}, "attempt simulator" 1744 ) 1745 if ( 1746 not isinstance(simulator["udid"], str) 1747 or re.fullmatch(r"[A-F0-9-]{36}", simulator["udid"]) is None 1748 or not valid_ios_runtime(simulator["os"]) 1749 or simulator["architecture"] != "arm64" 1750 ): 1751 raise ValueError("persona attempt simulator is invalid") 1752 expected_attempts = { 1753 candidate["id"]: (persona, candidate) 1754 for persona in suite["personas"] 1755 for candidate in persona["attempts"] 1756 } 1757 expected = expected_attempts.get(attempt["attempt_id"]) 1758 if expected is None: 1759 raise ValueError("persona attempt identity is unknown") 1760 expected_persona, expected_attempt = expected 1761 alias = expected_persona["alias"] 1762 if ( 1763 attempt["persona_alias"] != alias 1764 or attempt["attempt_order"] != expected_attempt["order"] 1765 or attempt["flow"] != expected_attempt["flow"] 1766 or attempt["expected_failure"] != expected_attempt["expected_failure"] 1767 or re.fullmatch( 1768 rf"persona-{alias.lower()}-[0-9a-f]{{24}}", attempt["persona_run_id"] 1769 ) 1770 is None 1771 ): 1772 raise ValueError("persona attempt binding is invalid") 1773 ui = exact_keys( 1774 attempt["ui_observation"], 1775 { 1776 "validation_attempted", 1777 "validation_rejected", 1778 "retry_attempts", 1779 "relaunches", 1780 "retention_verified", 1781 "today_projection_verified", 1782 }, 1783 "attempt UI observation", 1784 ) 1785 validation = expected_attempt["expected_failure"] == "validation_recovery" 1786 retry = expected_attempt["expected_failure"] == "transport_retry_relaunch" 1787 if ( 1788 ui["validation_attempted"] is not validation 1789 or ui["validation_rejected"] is not validation 1790 or ui["retry_attempts"] != int(retry) 1791 or ui["relaunches"] != int(retry) 1792 or ui["retention_verified"] is not True 1793 or ui["today_projection_verified"] is not True 1794 ): 1795 raise ValueError("persona attempt UI observation is invalid") 1796 network = attempt["network_observation"] 1797 if network == {"state": "pending_step_258"}: 1798 if require_measured_network: 1799 raise ValueError("persona attempt network evidence is not measured") 1800 else: 1801 network = exact_keys( 1802 network, 1803 { 1804 "state", 1805 "accepted_connections", 1806 "rejected_connections", 1807 "non_loopback_attempts", 1808 "subscriptions", 1809 "accepted_events", 1810 "accepted_uploads", 1811 "retrievals", 1812 "unintended_publications", 1813 "events_accepted_during_expected_failure", 1814 "final_candidate_data_loss", 1815 }, 1816 "attempt network observation", 1817 ) 1818 if network["state"] != "measured" or any( 1819 type(network[key]) is not int or not 0 <= network[key] <= 4096 1820 for key in network 1821 if key != "state" 1822 ): 1823 raise ValueError("persona attempt network observation is invalid") 1824 expected_media = int(attempt["flow"] == "PhotoUpdate") 1825 if ( 1826 network["accepted_events"] != 1 1827 or network["accepted_uploads"] != expected_media 1828 or network["retrievals"] != expected_media 1829 or network["non_loopback_attempts"] != 0 1830 or network["unintended_publications"] != 0 1831 or network["events_accepted_during_expected_failure"] != 0 1832 or network["final_candidate_data_loss"] != 0 1833 ): 1834 raise ValueError("persona attempt measured result is invalid") 1835 accessibility = exact_keys( 1836 attempt["accessibility"], 1837 { 1838 "locale", 1839 "content_size", 1840 "reduce_motion", 1841 "progressive_disclosure", 1842 "labels_values_traits", 1843 "keyboard_focus", 1844 "visible_actions", 1845 "voiceover_user_observed", 1846 }, 1847 "attempt accessibility observation", 1848 ) 1849 if ( 1850 accessibility["locale"] != "en_US" 1851 or accessibility["content_size"] 1852 != "accessibility-extra-extra-extra-large" 1853 or accessibility["reduce_motion"] is not True 1854 or any( 1855 type(accessibility[key]) is not bool 1856 for key in ( 1857 "progressive_disclosure", 1858 "labels_values_traits", 1859 "keyboard_focus", 1860 "visible_actions", 1861 "voiceover_user_observed", 1862 ) 1863 ) 1864 or accessibility["visible_actions"] is not True 1865 or accessibility["voiceover_user_observed"] is not False 1866 or accessibility["progressive_disclosure"] 1867 is not (expected_persona["interaction_profile"] == "novice_progressive_disclosure") 1868 or accessibility["keyboard_focus"] 1869 is not (expected_persona["interaction_profile"] == "novice_accessibility_keyboard") 1870 ): 1871 raise ValueError("persona attempt accessibility evidence is invalid") 1872 artifacts = attempt["artifact_digests"] 1873 if not isinstance(artifacts, list) or len(artifacts) > 4: 1874 raise ValueError("persona attempt artifact inventory is invalid") 1875 roles: set[str] = set() 1876 for artifact in artifacts: 1877 artifact = exact_keys(artifact, {"role", "sha256"}, "attempt artifact") 1878 if ( 1879 artifact["role"] 1880 not in {"media_input", "uploaded_blob", "retrieved_blob", "ui_snapshot"} 1881 or artifact["role"] in roles 1882 or not lowercase_hex(artifact["sha256"], 64) 1883 ): 1884 raise ValueError("persona attempt artifact is invalid") 1885 roles.add(artifact["role"]) 1886 return attempt 1887 1888 1889 def exact_persona_test_node(value: Any) -> dict[str, Any]: 1890 if not isinstance(value, dict) or set(value) != { 1891 "devices", 1892 "testNodes", 1893 "testPlanConfigurations", 1894 }: 1895 raise ValueError("xcresult test inventory is invalid") 1896 matches: list[dict[str, Any]] = [] 1897 1898 def visit(node: Any) -> None: 1899 if not isinstance(node, dict): 1900 raise ValueError("xcresult test node is invalid") 1901 if node.get("nodeType") == "Test Case" and ( 1902 node.get("nodeIdentifier") == PERSONA_XCRESULT_NODE_IDENTIFIER 1903 or node.get("nodeIdentifierURL") == PERSONA_XCRESULT_NODE_URL 1904 ): 1905 matches.append(node) 1906 children = node.get("children", []) 1907 if not isinstance(children, list): 1908 raise ValueError("xcresult child inventory is invalid") 1909 for child in children: 1910 visit(child) 1911 1912 nodes = value["testNodes"] 1913 if not isinstance(nodes, list): 1914 raise ValueError("xcresult test inventory is invalid") 1915 for node in nodes: 1916 visit(node) 1917 if len(matches) != 1: 1918 raise ValueError("xcresult exact persona test is unavailable") 1919 match = matches[0] 1920 if ( 1921 match.get("nodeIdentifier") != PERSONA_XCRESULT_NODE_IDENTIFIER 1922 or match.get("nodeIdentifierURL") != PERSONA_XCRESULT_NODE_URL 1923 or match.get("name") != "testLocalSocialDeterministicPersonas()" 1924 or match.get("nodeType") != "Test Case" 1925 or match.get("result") != "Passed" 1926 ): 1927 raise ValueError("xcresult exact persona test did not pass") 1928 return match 1929 1930 1931 def run_json_command_bounded(command: list[str], maximum: int) -> Any: 1932 process = subprocess.Popen(command, stdout=subprocess.PIPE) 1933 assert process.stdout is not None 1934 raw = process.stdout.read(maximum + 1) 1935 process.stdout.close() 1936 if len(raw) > maximum: 1937 process.kill() 1938 process.wait() 1939 raise ValueError("command JSON output exceeds its byte bound") 1940 return_code = process.wait() 1941 if return_code != 0: 1942 raise subprocess.CalledProcessError(return_code, command) 1943 if not raw: 1944 raise ValueError("command JSON output exceeds its byte bound") 1945 return json.loads(raw, object_pairs_hook=strict_object) 1946 1947 1948 def exported_attachment_inventory(path: Path) -> set[str]: 1949 inventory = bounded_directory_inventory( 1950 path, 1951 len(PERSONA_ATTACHMENT_NAMES) + 1, 1952 255, 1953 ) 1954 if any(kind != 1 or b"/" in relative for kind, relative, _ in inventory): 1955 raise ValueError("xcresult attachment export inventory is invalid") 1956 return {relative.decode("utf-8") for _, relative, _ in inventory} 1957 1958 1959 def xcresult_attachment_attempt_id(name: object) -> str | None: 1960 if not isinstance(name, str): 1961 return None 1962 matched = PERSONA_XCRESULT_ATTACHMENT_NAME.fullmatch(name) 1963 return matched.group(1) if matched is not None else None 1964 1965 1966 def load_exported_persona_attachments( 1967 export_directory: Path, 1968 suite: dict[str, Any], 1969 *, 1970 require_measured_network: bool, 1971 ) -> list[tuple[bytes, dict[str, Any]]]: 1972 inventory = exported_attachment_inventory(export_directory) 1973 manifest_raw, manifest_value = read_json_bounded( 1974 export_directory / "manifest.json", MAX_XCRESULT_JSON_BYTES 1975 ) 1976 del manifest_raw 1977 if not isinstance(manifest_value, list) or len(manifest_value) != 1: 1978 raise ValueError("xcresult attachment manifest is invalid") 1979 group = exact_keys( 1980 manifest_value[0], 1981 {"testIdentifier", "testIdentifierURL", "attachments"}, 1982 "xcresult attachment group", 1983 ) 1984 if ( 1985 group["testIdentifier"] != PERSONA_XCRESULT_NODE_IDENTIFIER 1986 or group["testIdentifierURL"] != PERSONA_XCRESULT_NODE_URL 1987 or not isinstance(group["attachments"], list) 1988 or len(group["attachments"]) != 15 1989 ): 1990 raise ValueError("xcresult attachment test binding is invalid") 1991 attachments_by_name: dict[str, tuple[bytes, dict[str, Any]]] = {} 1992 exported_names: set[str] = set() 1993 total_bytes = 0 1994 allowed_manifest_keys = { 1995 "exportedFileName", 1996 "suggestedHumanReadableName", 1997 "isAssociatedWithFailure", 1998 "configurationName", 1999 "deviceName", 2000 "deviceId", 2001 "timestamp", 2002 "repetitionNumber", 2003 "arguments", 2004 } 2005 for row_value in group["attachments"]: 2006 if not isinstance(row_value, dict) or not { 2007 "exportedFileName", 2008 "suggestedHumanReadableName", 2009 "isAssociatedWithFailure", 2010 "configurationName", 2011 "deviceName", 2012 "deviceId", 2013 }.issubset(row_value) or not set(row_value).issubset(allowed_manifest_keys): 2014 raise ValueError("xcresult attachment row is invalid") 2015 exported = row_value["exportedFileName"] 2016 name = row_value["suggestedHumanReadableName"] 2017 attempt_id = xcresult_attachment_attempt_id(name) 2018 if attempt_id is None: 2019 raise ValueError("xcresult attempt attachment identity is invalid") 2020 canonical_name = f"radroots-local-social-{attempt_id}.json" 2021 if ( 2022 canonical_name not in PERSONA_ATTACHMENT_NAMES 2023 or canonical_name in attachments_by_name 2024 or not isinstance(exported, str) 2025 or exported in exported_names 2026 or not 1 <= len(exported.encode("utf-8")) <= 255 2027 or Path(exported).name != exported 2028 or row_value["isAssociatedWithFailure"] is not False 2029 or row_value["configurationName"] != "Test Scheme Action" 2030 or not isinstance(row_value["deviceName"], str) 2031 or not row_value["deviceName"] 2032 or not isinstance(row_value["deviceId"], str) 2033 ): 2034 raise ValueError("xcresult attempt attachment identity is invalid") 2035 exported_names.add(exported) 2036 path = export_directory / exported 2037 if path.is_symlink() or not path.is_file(): 2038 raise ValueError("xcresult attempt attachment is not a regular file") 2039 raw, value = read_json_bounded(path, MAX_PERSONA_ATTACHMENT_BYTES) 2040 if raw != canonical_evidence_bytes(value): 2041 raise ValueError("persona attempt attachment is noncanonical") 2042 attempt = validate_persona_attempt_evidence( 2043 value, suite, require_measured_network=require_measured_network 2044 ) 2045 if attempt_id != attempt["attempt_id"]: 2046 raise ValueError("xcresult attachment name does not bind its attempt") 2047 total_bytes += len(raw) 2048 if total_bytes > MAX_PERSONA_ATTACHMENTS_BYTES: 2049 raise ValueError("xcresult attempt attachments exceed their aggregate bound") 2050 attachments_by_name[canonical_name] = (raw, attempt) 2051 if tuple(sorted(attachments_by_name)) != tuple(sorted(PERSONA_ATTACHMENT_NAMES)): 2052 raise ValueError("xcresult persona attachment inventory is incomplete") 2053 if inventory != {"manifest.json", *exported_names}: 2054 raise ValueError("xcresult attachment export inventory is invalid") 2055 return [attachments_by_name[name] for name in PERSONA_ATTACHMENT_NAMES] 2056 2057 2058 def extract_persona_attempt_attachments( 2059 result_bundle: Path, 2060 suite: dict[str, Any], 2061 *, 2062 require_measured_network: bool, 2063 ) -> list[tuple[bytes, dict[str, Any]]]: 2064 tests = run_json_command_bounded( 2065 [ 2066 "xcrun", 2067 "xcresulttool", 2068 "get", 2069 "test-results", 2070 "tests", 2071 "--path", 2072 str(result_bundle), 2073 ], 2074 MAX_XCRESULT_JSON_BYTES, 2075 ) 2076 exact_persona_test_node(tests) 2077 with tempfile.TemporaryDirectory() as directory: 2078 export_directory = Path(directory) 2079 subprocess.run( 2080 [ 2081 "xcrun", 2082 "xcresulttool", 2083 "export", 2084 "attachments", 2085 "--test-id", 2086 PERSONA_XCRESULT_NODE_URL, 2087 "--path", 2088 str(result_bundle), 2089 "--output-path", 2090 str(export_directory), 2091 ], 2092 check=True, 2093 ) 2094 return load_exported_persona_attachments( 2095 export_directory, 2096 suite, 2097 require_measured_network=require_measured_network, 2098 ) 2099 2100 2101 def reconstruct_persona_result_v2( 2102 suite: dict[str, Any], 2103 attachments: list[tuple[bytes, dict[str, Any]]], 2104 *, 2105 fixture_sha256: str, 2106 fixture_schema_sha256: str, 2107 attempt_schema_sha256: str, 2108 result_schema_sha256: str, 2109 result_bundle_sha256: str, 2110 forward_repairs: list[str], 2111 ) -> dict[str, Any]: 2112 if len(attachments) != 15: 2113 raise ValueError("persona result requires exactly 15 attempt attachments") 2114 attempts = [ 2115 validate_persona_attempt_evidence(value, suite, require_measured_network=True) 2116 for _, value in attachments 2117 ] 2118 expected_ids = [ 2119 candidate["id"] 2120 for persona in suite["personas"] 2121 for candidate in persona["attempts"] 2122 ] 2123 if [attempt["attempt_id"] for attempt in attempts] != expected_ids: 2124 raise ValueError("persona result attempt inventory is not exact") 2125 first = attempts[0] 2126 shared_fields = ( 2127 "test_invocation", 2128 "source", 2129 "app_build_sha256", 2130 "simulator", 2131 "run_id", 2132 "endpoint_policy_sha256", 2133 ) 2134 if any( 2135 attempt[field] != first[field] 2136 for attempt in attempts[1:] 2137 for field in shared_fields 2138 ): 2139 raise ValueError("persona attempt evidence spans multiple run identities") 2140 for digest in ( 2141 fixture_sha256, 2142 fixture_schema_sha256, 2143 attempt_schema_sha256, 2144 result_schema_sha256, 2145 result_bundle_sha256, 2146 ): 2147 if not lowercase_hex(digest, 64): 2148 raise ValueError("persona result digest identity is invalid") 2149 if ( 2150 not isinstance(forward_repairs, list) 2151 or len(forward_repairs) > 16 2152 or len(set(forward_repairs)) != len(forward_repairs) 2153 or any(not lowercase_hex(revision, 40) for revision in forward_repairs) 2154 ): 2155 raise ValueError("persona result forward-repair inventory is invalid") 2156 identity_by_persona: dict[str, str] = {} 2157 persona_rows = [] 2158 for persona in suite["personas"]: 2159 alias = persona["alias"] 2160 rows = [attempt for attempt in attempts if attempt["persona_alias"] == alias] 2161 identities = {row["public_identity_sha256"] for row in rows} 2162 if len(rows) != 3 or len(identities) != 1: 2163 raise ValueError("persona result identity reuse is invalid") 2164 identity = identities.pop() 2165 identity_by_persona[alias] = identity 2166 subscriptions = sum( 2167 row["network_observation"]["subscriptions"] for row in rows 2168 ) 2169 if subscriptions < 1: 2170 raise ValueError("persona result is missing a subscription") 2171 persona_rows.append( 2172 { 2173 "alias": alias, 2174 "public_identity_sha256": identity, 2175 "subscriptions": subscriptions, 2176 "attempt_ids": [row["attempt_id"] for row in rows], 2177 } 2178 ) 2179 if len(set(identity_by_persona.values())) != 5: 2180 raise ValueError("persona result identities are not distinct") 2181 network_rows = [attempt["network_observation"] for attempt in attempts] 2182 accepted_events = sum(row["accepted_events"] for row in network_rows) 2183 flow_counts = { 2184 flow: sum(attempt["flow"] == flow for attempt in attempts) 2185 for flow in FLOW_KINDS 2186 } 2187 event_kind_counts = { 2188 str(kind): sum( 2189 FLOW_KINDS[attempt["flow"]] == kind 2190 and attempt["network_observation"]["accepted_events"] == 1 2191 for attempt in attempts 2192 ) 2193 for kind in (1, 31923, 30402) 2194 } 2195 result = { 2196 "schema": PERSONA_RESULT_V2_SCHEMA, 2197 "schema_version": 2, 2198 "run_id": first["run_id"], 2199 "test_invocation": first["test_invocation"], 2200 "source": first["source"], 2201 "app_build_sha256": first["app_build_sha256"], 2202 "endpoint_policy_sha256": first["endpoint_policy_sha256"], 2203 "fixture_sha256": fixture_sha256, 2204 "fixture_schema_sha256": fixture_schema_sha256, 2205 "attempt_schema_sha256": attempt_schema_sha256, 2206 "result_schema_sha256": result_schema_sha256, 2207 "simulator": first["simulator"], 2208 "result_bundle_sha256": result_bundle_sha256, 2209 "attachments": [ 2210 { 2211 "attempt_id": attempt["attempt_id"], 2212 "sha256": hashlib.sha256(raw).hexdigest(), 2213 } 2214 for raw, attempt in attachments 2215 ], 2216 "personas": persona_rows, 2217 "flow_counts": flow_counts, 2218 "accepted_events": accepted_events, 2219 "event_kind_counts": event_kind_counts, 2220 "accepted_uploads": sum(row["accepted_uploads"] for row in network_rows), 2221 "retrievals": sum(row["retrievals"] for row in network_rows), 2222 "distinct_identities": len(set(identity_by_persona.values())), 2223 "unknown_attempts": len( 2224 set(expected_ids) - {row["attempt_id"] for row in attempts} 2225 ), 2226 "duplicate_attempts": len(attempts) - len({row["attempt_id"] for row in attempts}), 2227 "expected_failure_rejections": sum( 2228 attempt["ui_observation"]["validation_rejected"] 2229 or attempt["ui_observation"]["retry_attempts"] > 0 2230 for attempt in attempts 2231 ), 2232 "events_accepted_during_expected_failures": sum( 2233 row["events_accepted_during_expected_failure"] for row in network_rows 2234 ), 2235 "accepted_connections": sum(row["accepted_connections"] for row in network_rows), 2236 "rejected_connections": sum(row["rejected_connections"] for row in network_rows), 2237 "non_loopback_attempts": sum(row["non_loopback_attempts"] for row in network_rows), 2238 "unintended_publications": sum( 2239 row["unintended_publications"] for row in network_rows 2240 ), 2241 "final_candidate_data_loss": sum( 2242 row["final_candidate_data_loss"] for row in network_rows 2243 ), 2244 "accessibility": { 2245 "locale": "en_US", 2246 "content_size": "accessibility-extra-extra-extra-large", 2247 "reduce_motion": True, 2248 "semantic_attempts": sum( 2249 attempt["accessibility"]["labels_values_traits"] for attempt in attempts 2250 ), 2251 "keyboard_focus_attempts": sum( 2252 attempt["accessibility"]["keyboard_focus"] for attempt in attempts 2253 ), 2254 "voiceover_user_observed": False, 2255 }, 2256 "forward_repairs": forward_repairs, 2257 "complete_matrix_rerun": True, 2258 } 2259 if ( 2260 result["flow_counts"] != {flow: 3 for flow in FLOW_KINDS} 2261 or result["accepted_events"] != 15 2262 or result["event_kind_counts"] != {"1": 9, "31923": 3, "30402": 3} 2263 or result["accepted_uploads"] != 3 2264 or result["retrievals"] != 3 2265 or result["distinct_identities"] != 5 2266 or result["unknown_attempts"] != 0 2267 or result["duplicate_attempts"] != 0 2268 or result["expected_failure_rejections"] != 2 2269 or result["events_accepted_during_expected_failures"] != 0 2270 or result["accepted_connections"] < 1 2271 or result["non_loopback_attempts"] != 0 2272 or result["unintended_publications"] != 0 2273 or result["final_candidate_data_loss"] != 0 2274 or result["accessibility"]["semantic_attempts"] < 3 2275 or result["accessibility"]["keyboard_focus_attempts"] != 3 2276 ): 2277 raise ValueError("persona result reconstructed outcome is invalid") 2278 return result 2279 2280 2281 def validate_persona_result( 2282 value: Any, 2283 suite: dict[str, Any], 2284 fixture_sha256: str, 2285 fixture_schema_sha256: str, 2286 result_schema_sha256: str, 2287 ) -> dict[str, Any]: 2288 keys = { 2289 "schema", 2290 "schema_version", 2291 "run_id", 2292 "source_commit", 2293 "source_tree", 2294 "fixture_sha256", 2295 "fixture_schema_sha256", 2296 "result_schema_sha256", 2297 "simulator", 2298 "result_bundle_sha256", 2299 "evidence_sha256", 2300 "personas", 2301 "flow_counts", 2302 "accepted_events", 2303 "event_kind_counts", 2304 "accepted_uploads", 2305 "retrievals", 2306 "distinct_identities", 2307 "unknown_attempts", 2308 "duplicate_attempts", 2309 "expected_failure_rejections", 2310 "events_accepted_during_expected_failures", 2311 "production_network_contacts", 2312 "unintended_publications", 2313 "final_candidate_data_loss", 2314 "accessibility", 2315 "forward_repairs", 2316 "complete_matrix_rerun", 2317 } 2318 result = exact_keys(value, keys, "persona result") 2319 if ( 2320 result["schema"] != "tera.ios.local-social.persona-results.v1" 2321 or result["schema_version"] != 1 2322 or not re.fullmatch(r"[a-z0-9][a-z0-9-]{6,62}[a-z0-9]", result["run_id"]) 2323 or not lowercase_hex(result["source_commit"], 40) 2324 or not lowercase_hex(result["source_tree"], 40) 2325 ): 2326 raise ValueError("persona result header is invalid") 2327 expected_digests = { 2328 "fixture_sha256": fixture_sha256, 2329 "fixture_schema_sha256": fixture_schema_sha256, 2330 "result_schema_sha256": result_schema_sha256, 2331 } 2332 if any(result[key] != digest for key, digest in expected_digests.items()): 2333 raise ValueError("persona result contract digest is invalid") 2334 if not lowercase_hex(result["result_bundle_sha256"], 64) or not lowercase_hex( 2335 result["evidence_sha256"], 64 2336 ): 2337 raise ValueError("persona result evidence digest is invalid") 2338 simulator = exact_keys( 2339 result["simulator"], {"udid", "os", "architecture"}, "simulator" 2340 ) 2341 if ( 2342 not isinstance(simulator["udid"], str) 2343 or re.fullmatch(r"[A-F0-9-]{36}", simulator["udid"]) is None 2344 or not valid_ios_runtime(simulator["os"]) 2345 or simulator["architecture"] != "arm64" 2346 ): 2347 raise ValueError("persona result simulator is invalid") 2348 accessibility = exact_keys( 2349 result["accessibility"], 2350 { 2351 "locale", 2352 "content_size", 2353 "reduce_motion", 2354 "semantic_audit", 2355 "voiceover_user_observed", 2356 }, 2357 "accessibility result", 2358 ) 2359 if accessibility != { 2360 "locale": "en_US", 2361 "content_size": "accessibility-extra-extra-extra-large", 2362 "reduce_motion": True, 2363 "semantic_audit": "passed", 2364 "voiceover_user_observed": False, 2365 }: 2366 raise ValueError("persona accessibility result is invalid") 2367 repairs = result["forward_repairs"] 2368 if ( 2369 not isinstance(repairs, list) 2370 or len(repairs) > 16 2371 or len(set(repairs)) != len(repairs) 2372 or any(not lowercase_hex(commit, 40) for commit in repairs) 2373 or result["complete_matrix_rerun"] is not True 2374 ): 2375 raise ValueError("persona rerun evidence is invalid") 2376 expected_scalars = { 2377 "accepted_events": 15, 2378 "accepted_uploads": 3, 2379 "retrievals": 3, 2380 "distinct_identities": 5, 2381 "unknown_attempts": 0, 2382 "duplicate_attempts": 0, 2383 "expected_failure_rejections": 1, 2384 "events_accepted_during_expected_failures": 0, 2385 "production_network_contacts": 0, 2386 "unintended_publications": 0, 2387 "final_candidate_data_loss": 0, 2388 } 2389 if any(result.get(key) != expected for key, expected in expected_scalars.items()): 2390 raise ValueError("persona result totals are invalid") 2391 if result["flow_counts"] != {flow: 3 for flow in FLOW_KINDS} or result[ 2392 "event_kind_counts" 2393 ] != {"1": 9, "31923": 3, "30402": 3}: 2394 raise ValueError("persona result event inventory is invalid") 2395 if not isinstance(result["personas"], list) or len(result["personas"]) != 5: 2396 raise ValueError("persona result inventory is invalid") 2397 identities = set() 2398 for persona, expected in zip(result["personas"], suite["personas"], strict=True): 2399 exact_keys( 2400 persona, 2401 {"alias", "identity_sha256", "subscriptions", "attempts"}, 2402 "persona result row", 2403 ) 2404 if ( 2405 persona["alias"] != expected["alias"] 2406 or not lowercase_hex(persona["identity_sha256"], 64) 2407 or persona["identity_sha256"] == "0" * 64 2408 or type(persona["subscriptions"]) is not int 2409 or not 1 <= persona["subscriptions"] <= 4096 2410 or not isinstance(persona["attempts"], list) 2411 or len(persona["attempts"]) != 3 2412 ): 2413 raise ValueError("persona result row is invalid") 2414 identities.add(persona["identity_sha256"]) 2415 for attempt, expected_attempt in zip( 2416 persona["attempts"], expected["attempts"], strict=True 2417 ): 2418 exact_keys( 2419 attempt, 2420 { 2421 "id", 2422 "flow", 2423 "event_kind", 2424 "accepted", 2425 "expected_failure_rejections", 2426 }, 2427 "attempt result row", 2428 ) 2429 if ( 2430 attempt["id"] != expected_attempt["id"] 2431 or attempt["flow"] != expected_attempt["flow"] 2432 or attempt["event_kind"] != FLOW_KINDS[expected_attempt["flow"]] 2433 or attempt["accepted"] is not True 2434 or attempt["expected_failure_rejections"] 2435 != int( 2436 expected_attempt["expected_failure"] 2437 == "transport_retry_relaunch" 2438 ) 2439 ): 2440 raise ValueError("attempt result row is invalid") 2441 if len(identities) != 5: 2442 raise ValueError("persona result identities are not distinct") 2443 return result 2444 2445 2446 def verify_persona(arguments: argparse.Namespace) -> int: 2447 fixture_raw, suite = load_persona_suite(Path(arguments.fixture).resolve()) 2448 fixture_schema_raw, fixture_schema = load_schema_file( 2449 Path(arguments.fixture_schema).resolve(), 2450 "https://radroots.org/schemas/ios/local-social-personas.v1.schema.json", 2451 ) 2452 attempt_schema_raw, attempt_schema = load_schema_file( 2453 Path(arguments.attempt_schema).resolve(), 2454 "https://radroots.org/schemas/ios/local-social-persona-attempt-evidence.v1.schema.json", 2455 ) 2456 result_schema_raw, result_schema = load_schema_file( 2457 Path(arguments.result_v2_schema).resolve(), 2458 "https://radroots.org/schemas/ios/local-social-persona-results.v2.schema.json", 2459 ) 2460 validate_schema_instance(fixture_schema, suite, "persona fixture") 2461 evidence_path = Path(arguments.evidence).resolve() 2462 _, evidence_value = read_json(evidence_path) 2463 evidence = validate_persona_evidence(evidence_value, suite) 2464 result_bundle = Path(arguments.result_bundle).resolve() 2465 if not result_bundle.is_dir(): 2466 raise ValueError("XCUITest result bundle is unavailable") 2467 if not lowercase_hex(arguments.source_commit, 40) or not lowercase_hex( 2468 arguments.source_tree, 40 2469 ): 2470 raise ValueError("source identity is invalid") 2471 attachments = extract_persona_attempt_attachments( 2472 result_bundle, suite, require_measured_network=True 2473 ) 2474 for _, attempt in attachments: 2475 validate_schema_instance(attempt_schema, attempt, "persona attempt evidence") 2476 simulator = simulator_metadata(arguments.simulator_id, result_bundle) 2477 result = reconstruct_persona_result_v2( 2478 suite, 2479 attachments, 2480 fixture_sha256=hashlib.sha256(fixture_raw).hexdigest(), 2481 fixture_schema_sha256=hashlib.sha256(fixture_schema_raw).hexdigest(), 2482 attempt_schema_sha256=hashlib.sha256(attempt_schema_raw).hexdigest(), 2483 result_schema_sha256=hashlib.sha256(result_schema_raw).hexdigest(), 2484 result_bundle_sha256=directory_digest(result_bundle), 2485 forward_repairs=arguments.forward_repair_commit, 2486 ) 2487 validate_schema_instance(result_schema, result, "persona v2 result") 2488 if ( 2489 result["run_id"] != arguments.run_id 2490 or result["source"] 2491 != {"commit": arguments.source_commit, "tree": arguments.source_tree} 2492 or result["simulator"] != simulator 2493 or result["accepted_events"] != evidence["accepted_events"] 2494 or result["accepted_uploads"] != evidence["accepted_uploads"] 2495 or result["retrievals"] != evidence["retrievals"] 2496 or result["non_loopback_attempts"] != evidence["non_loopback_attempts"] 2497 or result["unintended_publications"] != evidence["unintended_publications"] 2498 or result["final_candidate_data_loss"] != evidence["final_candidate_data_loss"] 2499 or result["accepted_connections"] > evidence["accepted_connections"] 2500 or result["rejected_connections"] > evidence["rejected_connections"] 2501 ): 2502 raise ValueError("persona aggregate does not match fixture evidence") 2503 output = Path(arguments.output).resolve() 2504 output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") 2505 raw, reloaded = read_json(output) 2506 if raw != (json.dumps(reloaded, indent=2) + "\n").encode("utf-8") or reloaded != result: 2507 raise ValueError("persona v2 result is noncanonical") 2508 print( 2509 "local-social measured persona result verified: " 2510 f"{hashlib.sha256(raw).hexdigest()}" 2511 ) 2512 return 0 2513 2514 2515 def verify_persona_result_file( 2516 path: Path, 2517 suite: dict[str, Any], 2518 fixture_sha256: str, 2519 fixture_schema_sha256: str, 2520 result_schema_sha256: str, 2521 ) -> dict[str, Any]: 2522 raw, value = read_json(path) 2523 canonical = (json.dumps(value, indent=2) + "\n").encode("utf-8") 2524 if raw != canonical: 2525 raise ValueError("persona result is noncanonical") 2526 return validate_persona_result( 2527 value, 2528 suite, 2529 fixture_sha256, 2530 fixture_schema_sha256, 2531 result_schema_sha256, 2532 ) 2533 2534 2535 def verify_persona_result(arguments: argparse.Namespace) -> int: 2536 fixture_raw, suite = load_persona_suite(Path(arguments.fixture).resolve()) 2537 fixture_schema_raw, fixture_schema = load_schema_file( 2538 Path(arguments.fixture_schema).resolve(), 2539 "https://radroots.org/schemas/ios/local-social-personas.v1.schema.json", 2540 ) 2541 result_schema_raw, result_schema = load_schema_file( 2542 Path(arguments.result_schema).resolve(), 2543 "https://radroots.org/schemas/ios/local-social-persona-results.v1.schema.json", 2544 ) 2545 validate_schema_instance(fixture_schema, suite, "persona fixture") 2546 result = verify_persona_result_file( 2547 Path(arguments.result).resolve(), 2548 suite, 2549 hashlib.sha256(fixture_raw).hexdigest(), 2550 hashlib.sha256(fixture_schema_raw).hexdigest(), 2551 hashlib.sha256(result_schema_raw).hexdigest(), 2552 ) 2553 validate_schema_instance(result_schema, result, "persona v1 result") 2554 print("local-social persona result contract verified") 2555 return 0 2556 2557 2558 def parser() -> argparse.ArgumentParser: 2559 root = argparse.ArgumentParser() 2560 commands = root.add_subparsers(dest="command", required=True) 2561 serve_command = commands.add_parser("serve") 2562 serve_command.add_argument("--relay-port", type=int, required=True) 2563 serve_command.add_argument("--blossom-port", type=int, required=True) 2564 serve_command.add_argument("--evidence", required=True) 2565 serve_command.add_argument("--ready", required=True) 2566 serve_command.add_argument("--control", required=True) 2567 serve_command.add_argument("--persona-fixture") 2568 verify_command = commands.add_parser("verify") 2569 verify_command.add_argument("--evidence", required=True) 2570 accessibility_command = commands.add_parser("verify-accessibility") 2571 accessibility_command.add_argument("--evidence", required=True) 2572 fixture_command = commands.add_parser("verify-persona-fixture") 2573 fixture_command.add_argument("--fixture", required=True) 2574 fixture_command.add_argument("--fixture-schema", required=True) 2575 fixture_command.add_argument("--result-schema", required=True) 2576 fixture_command.add_argument("--attempt-schema", required=True) 2577 fixture_command.add_argument("--result-v2-schema", required=True) 2578 bud11_command = commands.add_parser("verify-bud11-corpus") 2579 bud11_command.add_argument("--corpus", required=True) 2580 bud11_command.add_argument("--schema", required=True) 2581 persona_command = commands.add_parser("verify-persona") 2582 persona_command.add_argument("--fixture", required=True) 2583 persona_command.add_argument("--fixture-schema", required=True) 2584 persona_command.add_argument("--result-schema", required=True) 2585 persona_command.add_argument("--attempt-schema", required=True) 2586 persona_command.add_argument("--result-v2-schema", required=True) 2587 persona_command.add_argument("--evidence", required=True) 2588 persona_command.add_argument("--result-bundle", required=True) 2589 persona_command.add_argument("--output", required=True) 2590 persona_command.add_argument("--source-commit", required=True) 2591 persona_command.add_argument("--source-tree", required=True) 2592 persona_command.add_argument("--run-id", required=True) 2593 persona_command.add_argument("--simulator-id", required=True) 2594 persona_command.add_argument("--forward-repair-commit", action="append", default=[]) 2595 result_command = commands.add_parser("verify-persona-result") 2596 result_command.add_argument("--fixture", required=True) 2597 result_command.add_argument("--fixture-schema", required=True) 2598 result_command.add_argument("--result-schema", required=True) 2599 result_command.add_argument("--result", required=True) 2600 return root 2601 2602 2603 def main() -> int: 2604 verify_toolchain_identity() 2605 arguments = parser().parse_args() 2606 if arguments.command == "serve": 2607 return serve(arguments) 2608 if arguments.command == "verify": 2609 return verify(arguments) 2610 if arguments.command == "verify-accessibility": 2611 return verify_accessibility(arguments) 2612 if arguments.command == "verify-persona-fixture": 2613 return verify_persona_fixture(arguments) 2614 if arguments.command == "verify-bud11-corpus": 2615 return verify_bud11_corpus(arguments) 2616 if arguments.command == "verify-persona-result": 2617 return verify_persona_result(arguments) 2618 return verify_persona(arguments) 2619 2620 2621 if __name__ == "__main__": 2622 raise SystemExit(main())