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 2df1e931c8d5c6653ecf13a458028a44ce7cee62
parent bf75432888da21d7fee24438684463b483bb442b
Author: triesap <tyson@radroots.org>
Date:   Mon, 31 Aug 2026 21:53:52 +0000

test(ios): bind exported persona evidence

- emit canonical attempt JSON without escaped slashes
- parse the exact Xcode repetition and UUID name envelope
- bind exported names back to canonical attempt identities
- cover malformed names and bounded attachment reads

Diffstat:
MRadrootsUITests/RadrootsRemoteQualificationUITests.swift | 2+-
Mscripts/local-social-fixture.py | 23+++++++++++++++++++----
Mscripts/test_local_social_fixture.py | 31++++++++++++++++++++++++++-----
3 files changed, 46 insertions(+), 10 deletions(-)

diff --git a/RadrootsUITests/RadrootsRemoteQualificationUITests.swift b/RadrootsUITests/RadrootsRemoteQualificationUITests.swift @@ -1555,7 +1555,7 @@ final class RadrootsRemoteQualificationUITests: XCTestCase { artifactDigests: [] ) let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] let data = try encoder.encode(evidence) XCTAssertLessThanOrEqual(data.count, 64 * 1024) let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.json") diff --git a/scripts/local-social-fixture.py b/scripts/local-social-fixture.py @@ -108,6 +108,10 @@ PERSONA_ATTACHMENT_NAMES = tuple( for persona in range(1, 6) for attempt in range(1, 4) ) +PERSONA_XCRESULT_ATTACHMENT_NAME = re.compile( + r"^radroots-local-social-(P0[1-5]-A0[1-3])_0_" + r"[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}\.json$" +) FORBIDDEN_EVIDENCE_KEYS = frozenset( ("private_key", "secret", "seed", "signed_event", "event_content", "raw_event") ) @@ -1951,6 +1955,13 @@ def exported_attachment_inventory(path: Path) -> set[str]: return {relative.decode("utf-8") for _, relative, _ in inventory} +def xcresult_attachment_attempt_id(name: object) -> str | None: + if not isinstance(name, str): + return None + matched = PERSONA_XCRESULT_ATTACHMENT_NAME.fullmatch(name) + return matched.group(1) if matched is not None else None + + def load_exported_persona_attachments( export_directory: Path, suite: dict[str, Any], @@ -2002,9 +2013,13 @@ def load_exported_persona_attachments( raise ValueError("xcresult attachment row is invalid") exported = row_value["exportedFileName"] name = row_value["suggestedHumanReadableName"] + attempt_id = xcresult_attachment_attempt_id(name) + if attempt_id is None: + raise ValueError("xcresult attempt attachment identity is invalid") + canonical_name = f"radroots-local-social-{attempt_id}.json" if ( - name not in PERSONA_ATTACHMENT_NAMES - or name in attachments_by_name + canonical_name not in PERSONA_ATTACHMENT_NAMES + or canonical_name in attachments_by_name or not isinstance(exported, str) or exported in exported_names or not 1 <= len(exported.encode("utf-8")) <= 255 @@ -2026,12 +2041,12 @@ def load_exported_persona_attachments( attempt = validate_persona_attempt_evidence( value, suite, require_measured_network=require_measured_network ) - if name != f"radroots-local-social-{attempt['attempt_id']}.json": + if attempt_id != attempt["attempt_id"]: raise ValueError("xcresult attachment name does not bind its attempt") total_bytes += len(raw) if total_bytes > MAX_PERSONA_ATTACHMENTS_BYTES: raise ValueError("xcresult attempt attachments exceed their aggregate bound") - attachments_by_name[name] = (raw, attempt) + attachments_by_name[canonical_name] = (raw, attempt) if tuple(sorted(attachments_by_name)) != tuple(sorted(PERSONA_ATTACHMENT_NAMES)): raise ValueError("xcresult persona attachment inventory is incomplete") if inventory != {"manifest.json", *exported_names}: diff --git a/scripts/test_local_social_fixture.py b/scripts/test_local_social_fixture.py @@ -852,15 +852,18 @@ class LocalSocialFixtureTests(unittest.TestCase): with tempfile.TemporaryDirectory() as directory: root = Path(directory) rows = [] - for name, (raw, _) in zip( - fixture.PERSONA_ATTACHMENT_NAMES, attachments, strict=True + for index, (name, (raw, _)) in enumerate( + zip(fixture.PERSONA_ATTACHMENT_NAMES, attachments, strict=True) ): exported = "exported-" + name + suggested = ( + f"{name[:-5]}_0_00000000-0000-0000-0000-{index:012X}.json" + ) (root / exported).write_bytes(raw) rows.append( { "exportedFileName": exported, - "suggestedHumanReadableName": name, + "suggestedHumanReadableName": suggested, "isAssociatedWithFailure": False, "configurationName": "Test Scheme Action", "deviceName": "iPhone 17 Pro", @@ -902,11 +905,25 @@ class LocalSocialFixtureTests(unittest.TestCase): root, suite, require_measured_network=True ) + rows[0]["suggestedHumanReadableName"] = ( + "radroots-local-social-P01-A01.json" + ) + (root / "manifest.json").write_text( + json.dumps(manifest), encoding="utf-8" + ) + with self.assertRaises(ValueError): + fixture.load_exported_persona_attachments( + root, suite, require_measured_network=True + ) + def test_xcresult_attachment_read_rejects_maximum_plus_one(self) -> None: suite, attachments = self.persona_attempt_attachments() with tempfile.TemporaryDirectory() as directory: root = Path(directory) - name = fixture.PERSONA_ATTACHMENT_NAMES[0] + name = ( + "radroots-local-social-P01-A01_0_" + "00000000-0000-0000-0000-000000000000.json" + ) exported = "oversized.json" (root / exported).write_bytes( b"{" + b" " * fixture.MAX_PERSONA_ATTACHMENT_BYTES + b"}" @@ -921,9 +938,13 @@ class LocalSocialFixtureTests(unittest.TestCase): "deviceId": "11111111-2222-3333-4444-555555555555", } ] - for index, other_name in enumerate( + for index, canonical_name in enumerate( fixture.PERSONA_ATTACHMENT_NAMES[1:], 1 ): + other_name = ( + f"{canonical_name[:-5]}_0_" + f"00000000-0000-0000-0000-{index:012X}.json" + ) other_exported = f"exported-{index}.json" (root / other_exported).write_bytes(attachments[index][0]) rows.append(