field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

commit 72e742b2b7fdd6b36056e1545420dc080a055ce6
parent 02a6aabad7f01ffe6cfa8b1976521d94bbabec39
Author: triesap <tyson@radroots.org>
Date:   Mon, 31 Aug 2026 19:04:13 +0000

ios: enforce canonical BUD-11 fixture admission

- validate the complete signed upload authorization at the HTTP boundary
- reject authorization events at the loopback relay boundary
- add a strict secret-free one-field mutation corpus and schema
- exercise pure and live HTTP admission through the package contract

Diffstat:
MREADME.md | 9+++++++++
Mscripts/local-social-fixture.py | 224+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mscripts/test_local_social_fixture.py | 279+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mscripts/verify-package-contract.sh | 6++++++
Atest-fixtures/bud11-upload-authorization-mutations.v1.json | 32++++++++++++++++++++++++++++++++
Atest-fixtures/bud11-upload-authorization-mutations.v1.schema.json | 28++++++++++++++++++++++++++++
6 files changed, 567 insertions(+), 11 deletions(-)

diff --git a/README.md b/README.md @@ -80,6 +80,15 @@ mode permitted to use automated user presence or test secret policy. Public and physical qualification retain normal Apple user presence, remain optional, and are not claimed by the deterministic simulator lane. +The loopback Blossom fixture admits uploads only with the exact signed BUD-11 +HTTP authorization produced by the installed Rust runtime: kind `24242`, +bounded non-empty human content, one upload action, one exact SHA-256, one +lowercase domain-only server scope, and one canonical expiration whose +lifetime is at most 300 seconds. Event ID and signature verification precede +admission, and the relay explicitly rejects authorization events. The shared +mutation corpus contains only field-mutation instructions; it persists no +private material or signed authorization event. + Passing `accessibility` as the final launcher argument runs Apple's accessibility audit over every progressively disclosed Add composition at the largest accessibility text size with Reduce Motion enabled. The corresponding diff --git a/scripts/local-social-fixture.py b/scripts/local-social-fixture.py @@ -7,6 +7,7 @@ import argparse import base64 import hashlib import http.server +import ipaddress import json import os import re @@ -17,6 +18,7 @@ import subprocess import struct import threading import time +import unicodedata from pathlib import Path from typing import Any @@ -25,6 +27,42 @@ MAX_WEBSOCKET_MESSAGE = 2 * 1024 * 1024 MAX_EVENTS = 256 MAX_BLOBS = 16 MAX_JSON_BYTES = 64 * 1024 +BUD11_EVENT_KIND = 24_242 +BUD11_CONTENT_MAX_BYTES = 4_096 +BUD11_AUTHORIZATION_MAX_BYTES = 16 * 1024 +BUD11_AUTHORIZATION_ENCODED_MAX_BYTES = 21_846 +BUD11_MAX_LIFETIME_SECONDS = 300 +BUD11_MAX_CREATED_AGE_SECONDS = 300 +BUD11_SERVER_DOMAIN = "127.0.0.1" +BUD11_MUTATION_SCHEMA = "radroots.ios.local-social.bud11-mutations.v1" +BUD11_MUTATIONS = ( + ("canonical", "none", "http", True), + ("wrong-scheme", "authorization_scheme", "http", False), + ("padded-base64", "padded_base64", "http", False), + ("wrong-kind", "wrong_kind", "http", False), + ("empty-content", "empty_content", "http", False), + ("oversized-content", "oversized_content", "http", False), + ("leading-content-space", "leading_content_space", "http", False), + ("control-content", "control_content", "http", False), + ("missing-action", "missing_action", "http", False), + ("wrong-action", "wrong_action", "http", False), + ("duplicate-action", "duplicate_action", "http", False), + ("wrong-hash", "wrong_hash", "http", False), + ("duplicate-hash", "duplicate_hash", "http", False), + ("missing-server", "missing_server", "http", False), + ("wrong-server", "wrong_server", "http", False), + ("uppercase-server", "uppercase_server", "http", False), + ("duplicate-server", "duplicate_server", "http", False), + ("missing-expiration", "missing_expiration", "http", False), + ("noncanonical-expiration", "noncanonical_expiration", "http", False), + ("expired", "expired", "http", False), + ("created-at-not-past", "created_at_not_past", "http", False), + ("lifetime-too-long", "lifetime_too_long", "http", False), + ("unknown-tag", "unknown_tag", "http", False), + ("event-id-mutation", "event_id", "http", False), + ("signature-mutation", "signature", "http", False), + ("relay-publication", "none", "relay", False), +) PERSONA_ALIASES = ("P01", "P02", "P03", "P04", "P05") FLOW_KINDS = { "Update": 1, @@ -162,6 +200,60 @@ def validate_persona_suite(value: Any) -> dict[str, Any]: return root +def validate_bud11_mutation_corpus(value: Any) -> dict[str, Any]: + root = exact_keys(value, {"schema", "schema_version", "vectors"}, "BUD-11 corpus") + if ( + root["schema"] != BUD11_MUTATION_SCHEMA + or root["schema_version"] != 1 + or not isinstance(root["vectors"], list) + ): + raise ValueError("BUD-11 corpus header is invalid") + observed = [] + for value in root["vectors"]: + vector = exact_keys( + value, + {"id", "mutation", "surface", "expected_accepted"}, + "BUD-11 vector", + ) + if ( + not isinstance(vector["id"], str) + or not isinstance(vector["mutation"], str) + or vector["surface"] not in {"http", "relay"} + or type(vector["expected_accepted"]) is not bool + ): + raise ValueError("BUD-11 vector is invalid") + observed.append( + ( + vector["id"], + vector["mutation"], + vector["surface"], + vector["expected_accepted"], + ) + ) + if tuple(observed) != BUD11_MUTATIONS: + raise ValueError("BUD-11 corpus inventory is invalid") + return root + + +def load_bud11_mutation_corpus(path: Path) -> tuple[bytes, dict[str, Any]]: + raw, value = read_json(path) + corpus = validate_bud11_mutation_corpus(value) + canonical = ( + "{\n" + f' "schema": {json.dumps(value.get("schema"))},\n' + f' "schema_version": {json.dumps(value.get("schema_version"))},\n' + ' "vectors": [\n' + + ",\n".join( + " " + json.dumps(vector, ensure_ascii=False) + for vector in value.get("vectors", []) + ) + + "\n ]\n}\n" + ).encode("utf-8") + if raw != canonical: + raise ValueError("BUD-11 corpus is noncanonical") + return raw, corpus + + def flow_marker(flow: str) -> str: return { "Update": "update", @@ -260,6 +352,12 @@ class FixtureState: def publish(self, event: dict[str, Any]) -> bool | None: if not valid_nostr_event(event) or not verify_nostr_signature(event): return False + if event["kind"] == BUD11_EVENT_KIND: + if self._suite is not None: + with self._lock: + self._unintended_publications += 1 + self._write_evidence_locked() + return False if self._suite is not None: return self._publish_persona_event(event) event_id = event["id"] @@ -363,14 +461,16 @@ class FixtureState: persona = control["active_persona"] if control is not None else None if self._suite is None: allowed = self.control.is_file() and valid_blossom_authorization( - authorization, expected_hash + authorization, expected_hash, BUD11_SERVER_DOMAIN ) else: allowed = ( control is not None and control["blossom_enabled"] and persona in PHOTO_PERSONAS - and valid_blossom_authorization(authorization, expected_hash) + and valid_blossom_authorization( + authorization, expected_hash, BUD11_SERVER_DOMAIN + ) ) capacity = digest in self._blobs or len(self._blobs) < MAX_BLOBS if allowed and capacity and digest == expected_hash: @@ -523,7 +623,15 @@ def matches(event: dict[str, Any], item: dict[str, Any]) -> bool: def valid_nostr_event(event: dict[str, Any]) -> bool: - if set(event) != {"id", "pubkey", "created_at", "kind", "tags", "content", "sig"}: + if not isinstance(event, dict) or set(event) != { + "id", + "pubkey", + "created_at", + "kind", + "tags", + "content", + "sig", + }: return False if not lowercase_hex(event["id"], 64) or not lowercase_hex(event["pubkey"], 64): return False @@ -701,19 +809,79 @@ def verify_nostr_signature(event: dict[str, Any]) -> bool: return False -def valid_blossom_authorization(authorization: str, expected_hash: str) -> bool: +def valid_bud11_server_domain(value: Any) -> bool: + if ( + not isinstance(value, str) + or not value + or len(value) > 253 + or not value.isascii() + or value.lower() != value + ): + return False + try: + address = ipaddress.ip_address(value) + except ValueError: + labels = value.split(".") + return not all(label.isdigit() for label in labels) and all( + 1 <= len(label) <= 63 + and re.fullmatch(r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?", label) + is not None + for label in labels + ) + return isinstance(address, ipaddress.IPv4Address) and str(address) == value + + +def valid_bud11_content(value: Any) -> bool: + return ( + isinstance(value, str) + and value + and len(value.encode("utf-8")) <= BUD11_CONTENT_MAX_BYTES + and value.strip() == value + and not any( + unicodedata.category(character) == "Cc" + and character not in "\t\n\r" + for character in value + ) + ) + + +def canonical_unsigned_decimal(value: Any) -> int | None: + if ( + not isinstance(value, str) + or not value + or len(value) > 20 + or any(character not in "0123456789" for character in value) + or (len(value) > 1 and value.startswith("0")) + ): + return None + parsed = int(value) + return parsed if parsed <= 0xFFFF_FFFF_FFFF_FFFF else None + + +def valid_blossom_authorization( + authorization: str, + expected_hash: str, + expected_server: str, + now_unix_s: int | None = None, +) -> bool: + if not lowercase_hex(expected_hash, 64) or not valid_bud11_server_domain( + expected_server + ): + return False if not authorization.startswith("Nostr "): return False payload = authorization.removeprefix("Nostr ") if ( not payload + or len(payload) > BUD11_AUTHORIZATION_ENCODED_MAX_BYTES or any(character.isspace() for character in payload) or "=" in payload + or re.fullmatch(r"[A-Za-z0-9_-]+", payload) is None ): return False try: raw = base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)) - if len(raw) > 16 * 1024: + if len(raw) > BUD11_AUTHORIZATION_MAX_BYTES: return False if base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") != payload: return False @@ -722,12 +890,27 @@ def valid_blossom_authorization(authorization: str, expected_hash: str) -> bool: return False if not valid_nostr_event(event) or not verify_nostr_signature(event): return False - return any( - isinstance(tag, list) - and len(tag) >= 2 - and tag[0] == "x" - and tag[1] == expected_hash - for tag in event["tags"] + if event["kind"] != BUD11_EVENT_KIND or not valid_bud11_content(event["content"]): + return False + if len(event["tags"]) != 4 or event["tags"][0] != ["t", "upload"]: + return False + expiration_tag = event["tags"][1] + if ( + len(expiration_tag) != 2 + or expiration_tag[0] != "expiration" + or event["tags"][2] != ["x", expected_hash] + or event["tags"][3] != ["server", expected_server] + ): + return False + expiration = canonical_unsigned_decimal(expiration_tag[1]) + now = int(time.time()) if now_unix_s is None else now_unix_s + return ( + type(now) is int + and now >= 0 + and expiration is not None + and event["created_at"] < now < expiration + and now - event["created_at"] <= BUD11_MAX_CREATED_AGE_SECONDS + and 0 < expiration - event["created_at"] <= BUD11_MAX_LIFETIME_SECONDS ) @@ -1034,6 +1217,20 @@ def verify_persona_fixture(arguments: argparse.Namespace) -> int: return 0 +def verify_bud11_corpus(arguments: argparse.Namespace) -> int: + raw, _ = load_bud11_mutation_corpus(Path(arguments.corpus).resolve()) + schema = validate_schema_file( + Path(arguments.schema).resolve(), + "https://radroots.org/schemas/ios/bud11-upload-authorization-mutations.v1.schema.json", + ) + print( + "BUD-11 mutation corpus verified: " + f"corpus={hashlib.sha256(raw).hexdigest()} " + f"schema={hashlib.sha256(schema).hexdigest()}" + ) + return 0 + + def directory_digest(path: Path) -> str: digest = hashlib.sha256() for item in sorted( @@ -1524,6 +1721,9 @@ def parser() -> argparse.ArgumentParser: fixture_command.add_argument("--fixture", required=True) fixture_command.add_argument("--fixture-schema", required=True) fixture_command.add_argument("--result-schema", required=True) + bud11_command = commands.add_parser("verify-bud11-corpus") + bud11_command.add_argument("--corpus", required=True) + bud11_command.add_argument("--schema", required=True) persona_command = commands.add_parser("verify-persona") persona_command.add_argument("--fixture", required=True) persona_command.add_argument("--fixture-schema", required=True) @@ -1554,6 +1754,8 @@ def main() -> int: return verify_accessibility(arguments) if arguments.command == "verify-persona-fixture": return verify_persona_fixture(arguments) + if arguments.command == "verify-bud11-corpus": + return verify_bud11_corpus(arguments) if arguments.command == "verify-persona-result": return verify_persona_result(arguments) return verify_persona(arguments) diff --git a/scripts/test_local_social_fixture.py b/scripts/test_local_social_fixture.py @@ -1,9 +1,13 @@ +import base64 import copy import hashlib +import http.client import importlib.util import json import re +import secrets import tempfile +import time import unittest from pathlib import Path from unittest import mock @@ -16,6 +20,140 @@ fixture = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(fixture) +def sign_bud11_event(event: dict) -> dict: + secret = secrets.randbelow(fixture.SECP256K1_ORDER - 1) + 1 + public_point = fixture.point_multiply(secret, fixture.SECP256K1_GENERATOR) + assert public_point is not None + if public_point[1] & 1: + secret = fixture.SECP256K1_ORDER - secret + public_key = public_point[0].to_bytes(32, "big") + signed = copy.deepcopy(event) + signed["pubkey"] = public_key.hex() + preimage = json.dumps( + [ + 0, + signed["pubkey"], + signed["created_at"], + signed["kind"], + signed["tags"], + signed["content"], + ], + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + message = hashlib.sha256(preimage).digest() + signed["id"] = message.hex() + auxiliary = secrets.token_bytes(32) + masked_secret = bytes( + left ^ right + for left, right in zip( + secret.to_bytes(32, "big"), + fixture.tagged_hash("BIP0340/aux", auxiliary), + strict=True, + ) + ) + nonce = int.from_bytes( + fixture.tagged_hash("BIP0340/nonce", masked_secret + public_key + message), + "big", + ) % fixture.SECP256K1_ORDER + assert nonce != 0 + nonce_point = fixture.point_multiply(nonce, fixture.SECP256K1_GENERATOR) + assert nonce_point is not None + if nonce_point[1] & 1: + nonce = fixture.SECP256K1_ORDER - nonce + challenge = int.from_bytes( + fixture.tagged_hash( + "BIP0340/challenge", + nonce_point[0].to_bytes(32, "big") + public_key + message, + ), + "big", + ) % fixture.SECP256K1_ORDER + signature = nonce_point[0].to_bytes(32, "big") + ( + (nonce + challenge * secret) % fixture.SECP256K1_ORDER + ).to_bytes(32, "big") + signed["sig"] = signature.hex() + assert fixture.valid_nostr_event(signed) + assert fixture.verify_nostr_signature(signed) + return signed + + +def bud11_event(now_unix_s: int, digest: str) -> dict: + return { + "created_at": now_unix_s - 5, + "kind": fixture.BUD11_EVENT_KIND, + "tags": [ + ["t", "upload"], + ["expiration", str(now_unix_s + 295)], + ["x", digest], + ["server", fixture.BUD11_SERVER_DOMAIN], + ], + "content": "Upload exact Radroots image", + } + + +def mutate_bud11_event(event: dict, mutation: str, now_unix_s: int) -> tuple[dict, str]: + changed = copy.deepcopy(event) + if mutation == "none": + pass + elif mutation == "wrong_kind": + changed["kind"] = 1 + elif mutation == "empty_content": + changed["content"] = "" + elif mutation == "oversized_content": + changed["content"] = "x" * (fixture.BUD11_CONTENT_MAX_BYTES + 1) + elif mutation == "leading_content_space": + changed["content"] = " Upload exact Radroots image" + elif mutation == "control_content": + changed["content"] = "Upload\0image" + elif mutation == "missing_action": + changed["tags"].pop(0) + elif mutation == "wrong_action": + changed["tags"][0][1] = "delete" + elif mutation == "duplicate_action": + changed["tags"].insert(1, ["t", "upload"]) + elif mutation == "wrong_hash": + changed["tags"][2][1] = "f" * 64 + elif mutation == "duplicate_hash": + changed["tags"].insert(3, copy.deepcopy(changed["tags"][2])) + elif mutation == "missing_server": + changed["tags"].pop(3) + elif mutation == "wrong_server": + changed["tags"][3][1] = "media.example" + elif mutation == "uppercase_server": + changed["tags"][3][1] = "Media.Example" + elif mutation == "duplicate_server": + changed["tags"].append(copy.deepcopy(changed["tags"][3])) + elif mutation == "missing_expiration": + changed["tags"].pop(1) + elif mutation == "noncanonical_expiration": + changed["tags"][1][1] = "0" + changed["tags"][1][1] + elif mutation == "expired": + changed["tags"][1][1] = str(now_unix_s) + elif mutation == "created_at_not_past": + changed["created_at"] = now_unix_s + 60 + changed["tags"][1][1] = str(now_unix_s + 360) + elif mutation == "lifetime_too_long": + changed["tags"][1][1] = str(changed["created_at"] + 301) + elif mutation == "unknown_tag": + changed["tags"].append(["client", "radroots"]) + elif mutation in {"event_id", "signature", "authorization_scheme", "padded_base64"}: + pass + else: + raise AssertionError(f"unsupported mutation: {mutation}") + signed = sign_bud11_event(changed) + if mutation == "event_id": + signed["id"] = ("0" if signed["id"][0] != "0" else "1") + signed["id"][1:] + elif mutation == "signature": + signed["sig"] = ("0" if signed["sig"][0] != "0" else "1") + signed["sig"][1:] + raw = json.dumps(signed, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + header = "Nostr " + base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + if mutation == "authorization_scheme": + header = "Bearer " + header.removeprefix("Nostr ") + elif mutation == "padded_base64": + header += "=" + return signed, header + + class LocalSocialFixtureTests(unittest.TestCase): def persona_result(self) -> tuple[dict, dict]: _, suite = fixture.load_persona_suite( @@ -101,6 +239,147 @@ class LocalSocialFixtureTests(unittest.TestCase): fixture.verify_bip340(public_key, message, signature[:-1] + b"\x00") ) + def test_bud11_corpus_is_exact_bounded_and_contains_no_sensitive_evidence(self) -> None: + path = Path("test-fixtures/bud11-upload-authorization-mutations.v1.json") + raw, corpus = fixture.load_bud11_mutation_corpus(path) + self.assertLessEqual(len(raw), fixture.MAX_JSON_BYTES) + self.assertEqual(len(corpus["vectors"]), len(fixture.BUD11_MUTATIONS)) + self.assertNotRegex( + raw.decode("utf-8"), + r'"(?:private_key|secret|seed|authorization|event|sig|pubkey)"\s*:', + ) + + def test_bud11_corpus_rejects_unknown_duplicate_and_missing_vectors(self) -> None: + path = Path("test-fixtures/bud11-upload-authorization-mutations.v1.json") + value = json.loads(path.read_text(encoding="utf-8")) + value["unknown"] = True + with self.assertRaisesRegex(ValueError, "field inventory"): + fixture.validate_bud11_mutation_corpus(value) + value = json.loads(path.read_text(encoding="utf-8")) + value["vectors"].pop() + with self.assertRaisesRegex(ValueError, "inventory"): + fixture.validate_bud11_mutation_corpus(value) + with tempfile.TemporaryDirectory() as directory: + duplicate = Path(directory, "duplicate.json") + duplicate.write_text( + '{"schema":"x","schema":"x","schema_version":1,"vectors":[]}\n', + encoding="utf-8", + ) + with self.assertRaisesRegex(ValueError, "duplicate JSON member"): + fixture.load_bud11_mutation_corpus(duplicate) + + def test_bud11_scalar_bounds_and_server_domain_grammar_are_fail_closed(self) -> None: + self.assertEqual(fixture.canonical_unsigned_decimal("0"), 0) + self.assertEqual( + fixture.canonical_unsigned_decimal(str(0xFFFF_FFFF_FFFF_FFFF)), + 0xFFFF_FFFF_FFFF_FFFF, + ) + for value in ["", "00", "+1", "١", "18446744073709551616", "1" * 10_000]: + self.assertIsNone(fixture.canonical_unsigned_decimal(value)) + for value in ["127.0.0.1", "media.example", "a-b.example"]: + self.assertTrue(fixture.valid_bud11_server_domain(value), value) + for value in [ + "127.000.0.1", + "123", + "Media.Example", + "https://media.example", + "media.example:443", + "-media.example", + "media-.example", + ]: + self.assertFalse(fixture.valid_bud11_server_domain(value), value) + oversized = "Nostr " + "a" * ( + fixture.BUD11_AUTHORIZATION_ENCODED_MAX_BYTES + 1 + ) + self.assertFalse( + fixture.valid_blossom_authorization( + oversized, + "a" * 64, + fixture.BUD11_SERVER_DOMAIN, + 1, + ) + ) + + def test_bud11_mutation_corpus_matches_strict_admission_and_relay_denial(self) -> None: + _, corpus = fixture.load_bud11_mutation_corpus( + Path("test-fixtures/bud11-upload-authorization-mutations.v1.json") + ) + now = int(time.time()) + digest = hashlib.sha256(b"canonical-bud11-photo").hexdigest() + for vector in corpus["vectors"]: + with self.subTest(vector=vector["id"]): + event, header = mutate_bud11_event( + bud11_event(now, digest), vector["mutation"], now + ) + if vector["surface"] == "http": + accepted = fixture.valid_blossom_authorization( + header, + digest, + fixture.BUD11_SERVER_DOMAIN, + now, + ) + else: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + state = fixture.FixtureState( + root / "evidence.json", root / "control", 21100 + ) + accepted = bool(state.publish(event)) + self.assertEqual(accepted, vector["expected_accepted"]) + + def test_local_blossom_http_runtime_enforces_the_shared_bud11_corpus(self) -> None: + _, corpus = fixture.load_bud11_mutation_corpus( + Path("test-fixtures/bud11-upload-authorization-mutations.v1.json") + ) + body = b"canonical-bud11-photo" + digest = hashlib.sha256(body).hexdigest() + now = int(time.time()) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + control = root / "control" + control.touch() + state = fixture.FixtureState(root / "evidence.json", control, 0) + fixture.BlossomHandler.state = state + server = fixture.http.server.ThreadingHTTPServer( + ("127.0.0.1", 0), fixture.BlossomHandler + ) + state.blossom_port = server.server_address[1] + thread = fixture.threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + for vector in corpus["vectors"]: + if vector["surface"] != "http": + continue + with self.subTest(vector=vector["id"]): + _, header = mutate_bud11_event( + bud11_event(now, digest), vector["mutation"], now + ) + connection = http.client.HTTPConnection( + "127.0.0.1", state.blossom_port, timeout=5 + ) + connection.request( + "PUT", + f"/{digest}.png", + body=body, + headers={ + "Authorization": header, + "Content-Type": "image/png", + "Content-Length": str(len(body)), + }, + ) + response = connection.getresponse() + response.read() + connection.close() + self.assertEqual( + response.status == 200, vector["expected_accepted"] + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + evidence = json.loads((root / "evidence.json").read_text(encoding="utf-8")) + self.assertEqual(evidence["accepted_uploads"], 1) + def test_fixture_rejects_duplicate_and_unknown_fields(self) -> None: source = Path("test-fixtures/local-social-personas.v1.json") payload = json.loads(source.read_text(encoding="utf-8")) diff --git a/scripts/verify-package-contract.sh b/scripts/verify-package-contract.sh @@ -96,6 +96,7 @@ grep -Fq 'xcrun simctl ui "$simulator_id" content_size "$previous_content_size"' "$repo_root/scripts/xcode.sh" grep -Fq 'verify-accessibility' "$repo_root/scripts/local-social-fixture.py" grep -Fq 'verify-persona-fixture' "$repo_root/scripts/local-social-fixture.py" +grep -Fq 'verify-bud11-corpus' "$repo_root/scripts/local-social-fixture.py" grep -Fq 'verify-persona' "$repo_root/scripts/local-social-fixture.py" grep -Fq 'verify-persona-result' "$repo_root/scripts/local-social-fixture.py" grep -Fq 'add.tap()' "$repo_root/RadrootsUITests/RadrootsRemoteQualificationUITests.swift" @@ -104,12 +105,17 @@ if rg -n 'coordinate\(' "$repo_root/RadrootsUITests" --glob '*.swift'; then exit 1 fi for fixture in \ + bud11-upload-authorization-mutations.v1.json \ + bud11-upload-authorization-mutations.v1.schema.json \ local-social-personas.v1.json \ local-social-personas.v1.schema.json \ local-social-persona-results.v1.schema.json do test -f "$repo_root/test-fixtures/$fixture" done +python3 "$repo_root/scripts/local-social-fixture.py" verify-bud11-corpus \ + --corpus "$repo_root/test-fixtures/bud11-upload-authorization-mutations.v1.json" \ + --schema "$repo_root/test-fixtures/bud11-upload-authorization-mutations.v1.schema.json" python3 "$repo_root/scripts/local-social-fixture.py" verify-persona-fixture \ --fixture "$repo_root/test-fixtures/local-social-personas.v1.json" \ --fixture-schema "$repo_root/test-fixtures/local-social-personas.v1.schema.json" \ diff --git a/test-fixtures/bud11-upload-authorization-mutations.v1.json b/test-fixtures/bud11-upload-authorization-mutations.v1.json @@ -0,0 +1,32 @@ +{ + "schema": "radroots.ios.local-social.bud11-mutations.v1", + "schema_version": 1, + "vectors": [ + {"id": "canonical", "mutation": "none", "surface": "http", "expected_accepted": true}, + {"id": "wrong-scheme", "mutation": "authorization_scheme", "surface": "http", "expected_accepted": false}, + {"id": "padded-base64", "mutation": "padded_base64", "surface": "http", "expected_accepted": false}, + {"id": "wrong-kind", "mutation": "wrong_kind", "surface": "http", "expected_accepted": false}, + {"id": "empty-content", "mutation": "empty_content", "surface": "http", "expected_accepted": false}, + {"id": "oversized-content", "mutation": "oversized_content", "surface": "http", "expected_accepted": false}, + {"id": "leading-content-space", "mutation": "leading_content_space", "surface": "http", "expected_accepted": false}, + {"id": "control-content", "mutation": "control_content", "surface": "http", "expected_accepted": false}, + {"id": "missing-action", "mutation": "missing_action", "surface": "http", "expected_accepted": false}, + {"id": "wrong-action", "mutation": "wrong_action", "surface": "http", "expected_accepted": false}, + {"id": "duplicate-action", "mutation": "duplicate_action", "surface": "http", "expected_accepted": false}, + {"id": "wrong-hash", "mutation": "wrong_hash", "surface": "http", "expected_accepted": false}, + {"id": "duplicate-hash", "mutation": "duplicate_hash", "surface": "http", "expected_accepted": false}, + {"id": "missing-server", "mutation": "missing_server", "surface": "http", "expected_accepted": false}, + {"id": "wrong-server", "mutation": "wrong_server", "surface": "http", "expected_accepted": false}, + {"id": "uppercase-server", "mutation": "uppercase_server", "surface": "http", "expected_accepted": false}, + {"id": "duplicate-server", "mutation": "duplicate_server", "surface": "http", "expected_accepted": false}, + {"id": "missing-expiration", "mutation": "missing_expiration", "surface": "http", "expected_accepted": false}, + {"id": "noncanonical-expiration", "mutation": "noncanonical_expiration", "surface": "http", "expected_accepted": false}, + {"id": "expired", "mutation": "expired", "surface": "http", "expected_accepted": false}, + {"id": "created-at-not-past", "mutation": "created_at_not_past", "surface": "http", "expected_accepted": false}, + {"id": "lifetime-too-long", "mutation": "lifetime_too_long", "surface": "http", "expected_accepted": false}, + {"id": "unknown-tag", "mutation": "unknown_tag", "surface": "http", "expected_accepted": false}, + {"id": "event-id-mutation", "mutation": "event_id", "surface": "http", "expected_accepted": false}, + {"id": "signature-mutation", "mutation": "signature", "surface": "http", "expected_accepted": false}, + {"id": "relay-publication", "mutation": "none", "surface": "relay", "expected_accepted": false} + ] +} diff --git a/test-fixtures/bud11-upload-authorization-mutations.v1.schema.json b/test-fixtures/bud11-upload-authorization-mutations.v1.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://radroots.org/schemas/ios/bud11-upload-authorization-mutations.v1.schema.json", + "title": "Radroots iOS local-social BUD-11 mutation corpus v1", + "type": "object", + "additionalProperties": false, + "required": ["schema", "schema_version", "vectors"], + "properties": { + "schema": {"const": "radroots.ios.local-social.bud11-mutations.v1"}, + "schema_version": {"const": 1}, + "vectors": { + "type": "array", + "minItems": 26, + "maxItems": 26, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "mutation", "surface", "expected_accepted"], + "properties": { + "id": {"type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "maxLength": 64}, + "mutation": {"type": "string", "pattern": "^[a-z0-9]+(?:_[a-z0-9]+)*$", "maxLength": 64}, + "surface": {"enum": ["http", "relay"]}, + "expected_accepted": {"type": "boolean"} + } + } + } + } +}