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 93ca054b46aefe1c64587e8b5466781368a4e07f
parent 2a354cefe78c73605443404814f83b4dd5c7b4e3
Author: triesap <tyson@radroots.org>
Date:   Mon, 31 Aug 2026 20:26:56 +0000

ios: measure persona network evidence

- admit qualification endpoints through closed typed policies
- observe accepted and rejected loopback fixture connections
- derive attempt outcomes from UI execution and fixture deltas
- reconstruct v2 results from exact measured attachments

Diffstat:
MREADME.md | 14++++++++++----
MRadroots/App/RadrootsRemoteQualification.swift | 77+++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
MRadrootsTests/RadrootsRemoteQualificationTests.swift | 36++++++++++++++++++++++++++++++++++++
MRadrootsUITests/Info.plist | 2++
MRadrootsUITests/RadrootsRemoteQualificationUITests.swift | 378++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mscripts/local-social-fixture.py | 195+++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mscripts/test_local_social_fixture.py | 20++++++++++++++++++++
Mscripts/verify-package-contract.sh | 16++++++++++++++++
Mscripts/xcode.sh | 3+++
9 files changed, 610 insertions(+), 131 deletions(-)

diff --git a/README.md b/README.md @@ -80,6 +80,11 @@ 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 app and XCUITest runner admit the simulator endpoints through closed typed +policies. The fixture creates both servers through one observable loopback +connection factory, rejects and counts every non-loopback peer, and records +accepted and rejected socket counters in its bounded evidence snapshot. + 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 @@ -126,10 +131,11 @@ contracts. Each completed attempt now also emits one bounded canonical target and identifier, test action and configuration, source commit and tree, app-build digest, simulator, run, persona, attempt, endpoint policy, and visible UI outcome. The attachment never retains a raw secret, signed authorization -event, or raw event content. Its closed `pending_step_258` network state is not -a passing network claim: the measured v2 aggregate rejects that state until the -observable connection and fixture counters are supplied by the next governed -remediation step. +event, or raw event content. Validation, retry, relaunch, retention, Today, +connection, subscription, event, upload, and retrieval fields come from the +executed UI path and monotonic fixture-snapshot deltas. The v2 result is +reconstructed only from the exact 15 measured attachments and is cross-checked +against the final fixture totals; a non-loopback attempt fails the run. The test uses ordinary visible controls, native-generated signing identities, real app stores, and generated Rust FFI. This is deterministic non-human diff --git a/Radroots/App/RadrootsRemoteQualification.swift b/Radroots/App/RadrootsRemoteQualification.swift @@ -31,6 +31,47 @@ enum RadrootsQualificationNetworkMode: Sendable, Equatable { } } +struct RadrootsQualificationEndpoint: Sendable, Equatable { + enum Role: Sendable { + case relay + case blossom + } + + let rawValue: String + + init(_ raw: String, role: Role, mode: RadrootsQualificationNetworkMode) throws { + guard let value = URLComponents(string: raw), + let scheme = value.scheme?.lowercased(), + let host = value.host?.lowercased(), + !host.isEmpty, + value.user == nil, + value.password == nil, + value.query == nil, + value.fragment == nil + else { + throw RadrootsConfigurationError.invalid("qualification_endpoint") + } + let expectedScheme = switch (role, mode) { + case (.relay, .isolatedLoopback): "ws" + case (.blossom, .isolatedLoopback): "http" + case (.relay, .publicEndpoint): "wss" + case (.blossom, .publicEndpoint): "https" + } + let loopback = host == "127.0.0.1" + let validHost = switch mode { + case .isolatedLoopback: + loopback + && value.port.map { 1 ... 65535 ~= $0 } == true + && (value.path.isEmpty || value.path == "/") + case .publicEndpoint: !loopback && host != "::1" && host != "localhost" + } + guard scheme == expectedScheme, validHost else { + throw RadrootsConfigurationError.invalid("qualification_endpoint_policy") + } + rawValue = raw + } +} + struct RadrootsRemoteQualificationEnvironment: Sendable, Equatable { static let enabledKey = "RADROOTS_IOS_UI_TEST_REMOTE" static let runIDKey = "RADROOTS_IOS_UI_TEST_RUN_ID" @@ -118,13 +159,14 @@ struct RadrootsRemoteQualificationEnvironment: Sendable, Equatable { guard !relays.isEmpty, blossoms.count == 1 else { throw RadrootsConfigurationError.invalid("qualification_blossom_origin") } - if networkMode == .isolatedLoopback { - guard - relays.allSatisfy({ isLoopbackEndpoint($0, schemes: ["ws"]) }), - blossoms.allSatisfy({ isLoopbackEndpoint($0, schemes: ["http"]) }) - else { - throw RadrootsConfigurationError.invalid("qualification_loopback_endpoint") - } + let relayEndpoints = try relays.map { + try RadrootsQualificationEndpoint($0, role: .relay, mode: networkMode) + } + let blossomEndpoints = try blossoms.map { + try RadrootsQualificationEndpoint($0, role: .blossom, mode: networkMode) + } + guard Set(relayEndpoints.map(\.rawValue)).count == relayEndpoints.count else { + throw RadrootsConfigurationError.invalid("qualification_endpoint_duplicate") } let mediaFile = try environment[mediaRelativePathKey].map { raw in guard raw == "qualification/input.png" else { @@ -134,8 +176,8 @@ struct RadrootsRemoteQualificationEnvironment: Sendable, Equatable { } return Self( runID: runID, - relayURLs: relays, - blossomOrigins: blossoms, + relayURLs: relayEndpoints.map(\.rawValue), + blossomOrigins: blossomEndpoints.map(\.rawValue), mediaFile: mediaFile, networkMode: networkMode ) @@ -164,23 +206,6 @@ struct RadrootsRemoteQualificationEnvironment: Sendable, Equatable { .filter { !$0.isEmpty } } - private static func isLoopbackEndpoint(_ raw: String, schemes: Set<String>) -> Bool { - guard - let components = URLComponents(string: raw), - let scheme = components.scheme?.lowercased(), - schemes.contains(scheme), - components.host == "127.0.0.1", - components.port != nil, - components.user == nil, - components.password == nil, - components.query == nil, - components.fragment == nil, - components.path.isEmpty || components.path == "/" - else { - return false - } - return true - } #else static func current(environment _: [String: String] = [:]) throws -> Self? { nil diff --git a/RadrootsTests/RadrootsRemoteQualificationTests.swift b/RadrootsTests/RadrootsRemoteQualificationTests.swift @@ -55,6 +55,42 @@ final class RadrootsRemoteQualificationTests: XCTestCase { ]) { _, new in new } ) ) + for endpoint in [ + "ws://localhost:21000", + "ws://[::1]:21000", + "wss://127.0.0.1:21000", + "ws://127.0.0.1", + "ws://127.0.0.1:0", + "ws://user@127.0.0.1:21000", + "ws://127.0.0.1:21000/path", + "ws://127.0.0.1:21000?query=1", + ] { + XCTAssertThrowsError( + try RadrootsQualificationEndpoint( + endpoint, + role: .relay, + mode: .isolatedLoopback + ) + ) + } + XCTAssertThrowsError( + try RadrootsQualificationEndpoint( + "https://127.0.0.1:21100", + role: .blossom, + mode: .publicEndpoint + ) + ) + XCTAssertThrowsError( + try RadrootsRemoteQualificationEnvironment.current( + environment: base.merging([ + RadrootsRemoteQualificationEnvironment.networkProfileKey: "simulator", + RadrootsRemoteQualificationEnvironment.relayURLsKey: + "ws://127.0.0.1:21000,ws://127.0.0.1:21000", + RadrootsRemoteQualificationEnvironment.blossomOriginsKey: + "http://127.0.0.1:21100", + ]) { _, new in new } + ) + ) XCTAssertThrowsError( try RadrootsRemoteQualificationEnvironment.current( environment: base.merging([ diff --git a/RadrootsUITests/Info.plist b/RadrootsUITests/Info.plist @@ -22,6 +22,8 @@ <string>$(RADROOTS_IOS_UI_TEST_BLOSSOM_ORIGINS)</string> <key>RADROOTS_IOS_UI_TEST_FIXTURE_CONTROL</key> <string>$(RADROOTS_IOS_UI_TEST_FIXTURE_CONTROL)</string> + <key>RADROOTS_IOS_UI_TEST_FIXTURE_EVIDENCE</key> + <string>$(RADROOTS_IOS_UI_TEST_FIXTURE_EVIDENCE)</string> <key>RADROOTS_IOS_UI_TEST_NETWORK_PROFILE</key> <string>$(RADROOTS_IOS_UI_TEST_NETWORK_PROFILE)</string> <key>RADROOTS_IOS_UI_TEST_SOURCE_COMMIT</key> diff --git a/RadrootsUITests/RadrootsRemoteQualificationUITests.swift b/RadrootsUITests/RadrootsRemoteQualificationUITests.swift @@ -179,9 +179,11 @@ final class RadrootsRemoteQualificationUITests: XCTestCase { XCTAssertEqual(suite.personas.map(\.alias), ["P01", "P02", "P03", "P04", "P05"]) var identityDigests = Set<String>() + var observations = [String: PersonaAttemptObservation]() for persona in suite.personas { try activateFixture(persona: persona.alias, at: control) let configuration = environment.forPersona(persona.alias) + var networkBefore = try readFixtureNetwork(configuration) var app = launchPersona(configuration) let publicKey = try readPublicKey(app) let identityDigest = SHA256.hash(data: Data(publicKey.utf8)) @@ -189,17 +191,20 @@ final class RadrootsRemoteQualificationUITests: XCTestCase { XCTAssertTrue(identityDigests.insert(identityDigest).inserted) for attempt in persona.attempts { + let interaction: PersonaInteractionObservation switch attempt.expectedFailure { case "validation_recovery": - try publishWithValidationRecovery(app, attempt: attempt) + interaction = try publishWithValidationRecovery(app, attempt: attempt) case "transport_retry_relaunch": - app = try publishWithTransportRetry( + let retry = try publishWithTransportRetry( app, configuration: configuration, attempt: attempt ) + app = retry.app + interaction = retry.observation case "none": - try publishPersonaAttempt( + interaction = try publishPersonaAttempt( app, attempt: attempt, interactionProfile: persona.interactionProfile @@ -208,6 +213,14 @@ final class RadrootsRemoteQualificationUITests: XCTestCase { throw QualificationError.invalidPersonaFixture } try assertTodayContains(app, markers: [attempt.marker]) + let networkAfter = try readFixtureNetwork(configuration) + observations[attempt.id] = try PersonaAttemptObservation( + interaction: interaction, + network: networkAfter.delta(from: networkBefore), + todayProjectionVerified: true, + retentionVerified: false + ) + networkBefore = networkAfter } let markers = persona.attempts.map(\.marker) @@ -216,11 +229,13 @@ final class RadrootsRemoteQualificationUITests: XCTestCase { try assertTodayContains(app, markers: markers) XCTAssertEqual(try readPublicKey(app), publicKey) for attempt in persona.attempts { + let observation = try XCTUnwrap(observations[attempt.id]) try writePersonaAttemptAttachment( configuration: configuration, - persona: persona, + personaAlias: persona.alias, attempt: attempt, - identityDigest: identityDigest + identityDigest: identityDigest, + observation: observation.confirmingRetention() ) } app.terminate() @@ -875,18 +890,32 @@ final class RadrootsRemoteQualificationUITests: XCTestCase { _ app: XCUIApplication, attempt: PersonaAttempt, interactionProfile: String - ) throws { + ) throws -> PersonaInteractionObservation { let type = attempt.flow.uiLabel try beginDraft(app, type: type) + var progressiveDisclosure = false + var accessibilitySemantics = false if interactionProfile == "novice_progressive_disclosure" { assertProgressiveDisclosure(app, type: type) + progressiveDisclosure = true } if interactionProfile == "novice_accessibility_keyboard" { // The dedicated accessibility test owns the exact full contrast lane. // Persona attempts retain every semantic audit plus keyboard/focus use. try performLocalSocialAccessibilityAudit(app, includeContrast: false) + accessibilitySemantics = true } try completeOpenDraft(app, flow: attempt.flow, marker: attempt.marker) + return PersonaInteractionObservation( + validationAttempted: false, + validationRejected: false, + retryAttempts: 0, + relaunches: 0, + progressiveDisclosure: progressiveDisclosure, + accessibilitySemantics: accessibilitySemantics, + keyboardFocus: accessibilitySemantics, + visibleActions: true + ) } @MainActor @@ -948,7 +977,7 @@ final class RadrootsRemoteQualificationUITests: XCTestCase { private func publishWithValidationRecovery( _ app: XCUIApplication, attempt: PersonaAttempt - ) throws { + ) throws -> PersonaInteractionObservation { guard attempt.flow == .event else { throw QualificationError.invalidPersonaFixture } @@ -963,6 +992,16 @@ final class RadrootsRemoteQualificationUITests: XCTestCase { XCTAssertEqual(content.value as? String, attempt.marker) try enterText(app, identifier: "radroots.add.title", value: attempt.marker) try submitSuccessfully(app) + return PersonaInteractionObservation( + validationAttempted: true, + validationRejected: true, + retryAttempts: 0, + relaunches: 0, + progressiveDisclosure: false, + accessibilitySemantics: false, + keyboardFocus: false, + visibleActions: true + ) } @MainActor @@ -970,7 +1009,7 @@ final class RadrootsRemoteQualificationUITests: XCTestCase { _ app: XCUIApplication, configuration: QualificationConfiguration, attempt: PersonaAttempt - ) throws -> XCUIApplication { + ) throws -> (app: XCUIApplication, observation: PersonaInteractionObservation) { guard attempt.flow == .ask else { throw QualificationError.invalidPersonaFixture } @@ -997,7 +1036,19 @@ final class RadrootsRemoteQualificationUITests: XCTestCase { let done = relaunched.buttons["Done"] XCTAssertTrue(done.waitForExistence(timeout: 10)) done.tap() - return relaunched + return ( + relaunched, + PersonaInteractionObservation( + validationAttempted: false, + validationRejected: false, + retryAttempts: 1, + relaunches: 1, + progressiveDisclosure: false, + accessibilitySemantics: false, + keyboardFocus: false, + visibleActions: true + ) + ) } private func loadPersonaSuite() throws -> PersonaSuite { @@ -1027,6 +1078,23 @@ final class RadrootsRemoteQualificationUITests: XCTestCase { try data.write(to: URL(fileURLWithPath: path), options: [.atomic]) } + private func readFixtureNetwork( + _ configuration: QualificationConfiguration + ) throws -> PersonaFixtureNetworkSnapshot { + let path = try XCTUnwrap(configuration.fixtureEvidence) + let handle = try FileHandle(forReadingFrom: URL(fileURLWithPath: path)) + defer { try? handle.close() } + let data = try handle.read(upToCount: 64 * 1024 + 1) ?? Data() + guard !data.isEmpty, data.count <= 64 * 1024 else { + throw QualificationError.invalidFixtureEvidence + } + let root = try JSONSerialization.jsonObject(with: data) + guard let value = root as? [String: Any] else { + throw QualificationError.invalidFixtureEvidence + } + return try PersonaFixtureNetworkSnapshot(value) + } + @MainActor private func beginDraft(_ app: XCUIApplication, type: String) throws { guard openAdd(app) != nil else { @@ -1450,17 +1518,12 @@ final class RadrootsRemoteQualificationUITests: XCTestCase { private func writePersonaAttemptAttachment( configuration: QualificationConfiguration, - persona: Persona, + personaAlias: String, attempt: PersonaAttempt, - identityDigest: String + identityDigest: String, + observation: PersonaAttemptObservation ) throws { let binding = try XCTUnwrap(configuration.evidenceBinding) - let validation = attempt.expectedFailure == "validation_recovery" - let retry = attempt.expectedFailure == "transport_retry_relaunch" - let progressiveDisclosure = - persona.interactionProfile == "novice_progressive_disclosure" - let accessibilityKeyboard = - persona.interactionProfile == "novice_accessibility_keyboard" let evidence = PersonaAttemptEvidence( schema: "radroots.ios.local-social.persona-attempt-evidence.v1", schemaVersion: 1, @@ -1479,30 +1542,23 @@ final class RadrootsRemoteQualificationUITests: XCTestCase { ), runID: configuration.qualificationRunID, personaRunID: configuration.runID, - personaAlias: persona.alias, + personaAlias: personaAlias, attemptID: attempt.id, attemptOrder: attempt.order, flow: attempt.flow.rawValue, expectedFailure: attempt.expectedFailure, publicIdentitySHA256: identityDigest, endpointPolicySHA256: Self.endpointPolicyDigest(configuration), - uiObservation: .init( - validationAttempted: validation, - validationRejected: validation, - retryAttempts: retry ? 1 : 0, - relaunches: retry ? 1 : 0, - retentionVerified: true, - todayProjectionVerified: true - ), - networkObservation: .init(state: "pending_step_258"), + uiObservation: observation.ui, + networkObservation: observation.network, accessibility: .init( locale: "en_US", contentSize: "accessibility-extra-extra-extra-large", reduceMotion: true, - progressiveDisclosure: progressiveDisclosure, - labelsValuesTraits: accessibilityKeyboard, - keyboardFocus: accessibilityKeyboard, - visibleActions: true, + progressiveDisclosure: observation.interaction.progressiveDisclosure, + labelsValuesTraits: observation.interaction.accessibilitySemantics, + keyboardFocus: observation.interaction.keyboardFocus, + visibleActions: observation.interaction.visibleActions, voiceoverUserObserved: false ), artifactDigests: [] @@ -1536,12 +1592,80 @@ final class RadrootsRemoteQualificationUITests: XCTestCase { } } +private struct QualificationEndpointPolicy { + private enum Role { + case relay + case blossom + } + + init(relayURLs: [String], blossomOrigins: [String], profile: String) throws { + guard !relayURLs.isEmpty, + Set(relayURLs).count == relayURLs.count, + blossomOrigins.count == 1 + else { + throw QualificationError.invalidEndpointPolicy + } + if profile == "simulator" { + guard + relayURLs.allSatisfy({ Self.isEndpoint($0, role: .relay, loopbackOnly: true) }), + blossomOrigins.allSatisfy({ + Self.isEndpoint($0, role: .blossom, loopbackOnly: true) + }) + else { + throw QualificationError.invalidEndpointPolicy + } + } else { + guard + relayURLs.allSatisfy({ Self.isEndpoint($0, role: .relay, loopbackOnly: false) }), + blossomOrigins.allSatisfy({ + Self.isEndpoint($0, role: .blossom, loopbackOnly: false) + }) + else { + throw QualificationError.invalidEndpointPolicy + } + } + } + + private static func isEndpoint( + _ raw: String, + role: Role, + loopbackOnly: Bool + ) -> Bool { + guard let value = URLComponents(string: raw), + value.user == nil, + value.password == nil, + value.query == nil, + value.fragment == nil, + let scheme = value.scheme?.lowercased(), + let host = value.host?.lowercased(), + !host.isEmpty + else { return false } + let expectedScheme = switch (role, loopbackOnly) { + case (.relay, true): "ws" + case (.blossom, true): "http" + case (.relay, false): "wss" + case (.blossom, false): "https" + } + if loopbackOnly { + return scheme == expectedScheme + && host == "127.0.0.1" + && value.port.map { 1 ... 65535 ~= $0 } == true + && (value.path.isEmpty || value.path == "/") + } + return scheme == expectedScheme + && host != "127.0.0.1" + && host != "::1" + && host != "localhost" + } +} + private struct QualificationConfiguration { static let enabledKey = "RADROOTS_IOS_UI_TEST_REMOTE" static let runIDKey = "RADROOTS_IOS_UI_TEST_RUN_ID" static let relayURLsKey = "RADROOTS_IOS_UI_TEST_NOSTR_RELAY_URLS" static let blossomOriginsKey = "RADROOTS_IOS_UI_TEST_BLOSSOM_ORIGINS" static let fixtureControlKey = "RADROOTS_IOS_UI_TEST_FIXTURE_CONTROL" + static let fixtureEvidenceKey = "RADROOTS_IOS_UI_TEST_FIXTURE_EVIDENCE" static let mediaRelativePathKey = "RADROOTS_IOS_UI_TEST_MEDIA_RELATIVE_PATH" static let networkProfileKey = "RADROOTS_IOS_UI_TEST_NETWORK_PROFILE" static let sourceCommitKey = "RADROOTS_IOS_UI_TEST_SOURCE_COMMIT" @@ -1554,6 +1678,7 @@ private struct QualificationConfiguration { let relayURLs: [String] let blossomOrigins: [String] let fixtureControl: String? + let fixtureEvidence: String? let networkProfile: String let evidenceBinding: PersonaEvidenceBinding? @@ -1562,6 +1687,7 @@ private struct QualificationConfiguration { relayURLs: [String], blossomOrigins: [String], fixtureControl: String? = nil, + fixtureEvidence: String? = nil, networkProfile: String = "public", qualificationRunID: String? = nil, evidenceBinding: PersonaEvidenceBinding? = nil @@ -1571,6 +1697,7 @@ private struct QualificationConfiguration { self.relayURLs = relayURLs self.blossomOrigins = blossomOrigins self.fixtureControl = fixtureControl + self.fixtureEvidence = fixtureEvidence self.networkProfile = networkProfile self.evidenceBinding = evidenceBinding } @@ -1597,6 +1724,7 @@ private struct QualificationConfiguration { relayURLs: relayURLs, blossomOrigins: blossomOrigins, fixtureControl: fixtureControl, + fixtureEvidence: fixtureEvidence, networkProfile: networkProfile, qualificationRunID: qualificationRunID, evidenceBinding: evidenceBinding @@ -1619,6 +1747,9 @@ private struct QualificationConfiguration { let fixtureControl = values[fixtureControlKey] ?? bundle.object(forInfoDictionaryKey: fixtureControlKey) as? String + let fixtureEvidence = + values[fixtureEvidenceKey] + ?? bundle.object(forInfoDictionaryKey: fixtureEvidenceKey) as? String let networkProfile = values[networkProfileKey] ?? bundle.object(forInfoDictionaryKey: networkProfileKey) as? String @@ -1638,6 +1769,23 @@ private struct QualificationConfiguration { else { throw QualificationError.missingEnvironment } + _ = try QualificationEndpointPolicy( + relayURLs: separated(relayValue), + blossomOrigins: separated(blossom), + profile: networkProfile + ) + if networkProfile == "simulator" { + guard + let fixtureControl, + let fixtureEvidence, + fixtureControl.utf8.count <= 1024, + fixtureEvidence.utf8.count <= 1024, + fixtureControl.hasPrefix("/"), + fixtureEvidence.hasPrefix("/") + else { + throw QualificationError.missingEnvironment + } + } let evidenceBinding = PersonaEvidenceBinding( sourceCommit: sourceCommit, sourceTree: sourceTree, @@ -1649,6 +1797,7 @@ private struct QualificationConfiguration { relayURLs: separated(relayValue), blossomOrigins: separated(blossom), fixtureControl: fixtureControl.flatMap { $0.isEmpty ? nil : $0 }, + fixtureEvidence: fixtureEvidence.flatMap { $0.isEmpty ? nil : $0 }, networkProfile: networkProfile, evidenceBinding: evidenceBinding ) @@ -1735,8 +1884,173 @@ private struct PersonaAttemptUIObservation: Encodable { } } +private struct PersonaInteractionObservation { + let validationAttempted: Bool + let validationRejected: Bool + let retryAttempts: Int + let relaunches: Int + let progressiveDisclosure: Bool + let accessibilitySemantics: Bool + let keyboardFocus: Bool + let visibleActions: Bool +} + +private struct PersonaAttemptObservation { + let interaction: PersonaInteractionObservation + let network: PersonaAttemptNetworkObservation + let todayProjectionVerified: Bool + let retentionVerified: Bool + + var ui: PersonaAttemptUIObservation { + PersonaAttemptUIObservation( + validationAttempted: interaction.validationAttempted, + validationRejected: interaction.validationRejected, + retryAttempts: interaction.retryAttempts, + relaunches: interaction.relaunches, + retentionVerified: retentionVerified, + todayProjectionVerified: todayProjectionVerified + ) + } + + func confirmingRetention() -> Self { + Self( + interaction: interaction, + network: network, + todayProjectionVerified: todayProjectionVerified, + retentionVerified: true + ) + } +} + private struct PersonaAttemptNetworkObservation: Encodable { let state: String + let acceptedConnections: Int + let rejectedConnections: Int + let nonLoopbackAttempts: Int + let subscriptions: Int + let acceptedEvents: Int + let acceptedUploads: Int + let retrievals: Int + let unintendedPublications: Int + let eventsAcceptedDuringExpectedFailure: Int + let finalCandidateDataLoss: Int + + enum CodingKeys: String, CodingKey { + case state + case acceptedConnections = "accepted_connections" + case rejectedConnections = "rejected_connections" + case nonLoopbackAttempts = "non_loopback_attempts" + case subscriptions + case acceptedEvents = "accepted_events" + case acceptedUploads = "accepted_uploads" + case retrievals + case unintendedPublications = "unintended_publications" + case eventsAcceptedDuringExpectedFailure = "events_accepted_during_expected_failure" + case finalCandidateDataLoss = "final_candidate_data_loss" + } +} + +private struct PersonaFixtureNetworkSnapshot { + private static let keys: Set<String> = [ + "schema", "schema_version", "personas", "flow_counts", "accepted_events", + "event_kind_counts", "upload_attempts", "accepted_uploads", "retrievals", + "distinct_identities", "unknown_attempts", "duplicate_attempts", + "expected_failure_rejections", "events_accepted_during_expected_failures", + "accepted_connections", "rejected_connections", "non_loopback_attempts", + "production_network_contacts", "unintended_publications", "final_candidate_data_loss", + ] + + let acceptedConnections: Int + let rejectedConnections: Int + let nonLoopbackAttempts: Int + let productionNetworkContacts: Int + let subscriptions: Int + let acceptedEvents: Int + let acceptedUploads: Int + let retrievals: Int + let unintendedPublications: Int + let eventsAcceptedDuringExpectedFailures: Int + let finalCandidateDataLoss: Int + + init(_ value: [String: Any]) throws { + guard Set(value.keys) == Self.keys, + value["schema"] as? String == "radroots.ios.local-social.persona-evidence.v1", + value["schema_version"] as? Int == 1, + let acceptedConnections = value["accepted_connections"] as? Int, + let rejectedConnections = value["rejected_connections"] as? Int, + let nonLoopbackAttempts = value["non_loopback_attempts"] as? Int, + let productionNetworkContacts = value["production_network_contacts"] as? Int, + let acceptedEvents = value["accepted_events"] as? Int, + let acceptedUploads = value["accepted_uploads"] as? Int, + let retrievals = value["retrievals"] as? Int, + let unintendedPublications = value["unintended_publications"] as? Int, + let eventsAccepted = value["events_accepted_during_expected_failures"] as? Int, + let finalCandidateDataLoss = value["final_candidate_data_loss"] as? Int, + let personas = value["personas"] as? [[String: Any]], + personas.count == 5, + [ + acceptedConnections, rejectedConnections, nonLoopbackAttempts, + productionNetworkContacts, + acceptedEvents, acceptedUploads, retrievals, unintendedPublications, + eventsAccepted, finalCandidateDataLoss, + ].allSatisfy({ 0 ... 61440 ~= $0 }) + else { + throw QualificationError.invalidFixtureEvidence + } + let subscriptions = try personas.reduce(into: 0) { total, persona in + guard let value = persona["subscriptions"] as? Int, 0 ... 4096 ~= value else { + throw QualificationError.invalidFixtureEvidence + } + total += value + } + self.acceptedConnections = acceptedConnections + self.rejectedConnections = rejectedConnections + self.nonLoopbackAttempts = nonLoopbackAttempts + self.productionNetworkContacts = productionNetworkContacts + self.subscriptions = subscriptions + self.acceptedEvents = acceptedEvents + self.acceptedUploads = acceptedUploads + self.retrievals = retrievals + self.unintendedPublications = unintendedPublications + eventsAcceptedDuringExpectedFailures = eventsAccepted + self.finalCandidateDataLoss = finalCandidateDataLoss + } + + func delta(from prior: Self) throws -> PersonaAttemptNetworkObservation { + let current = [ + acceptedConnections, rejectedConnections, nonLoopbackAttempts, subscriptions, + productionNetworkContacts, + acceptedEvents, acceptedUploads, retrievals, unintendedPublications, + eventsAcceptedDuringExpectedFailures, finalCandidateDataLoss, + ] + let previous = [ + prior.acceptedConnections, prior.rejectedConnections, prior.nonLoopbackAttempts, + prior.subscriptions, prior.productionNetworkContacts, prior.acceptedEvents, + prior.acceptedUploads, prior.retrievals, + prior.unintendedPublications, prior.eventsAcceptedDuringExpectedFailures, + prior.finalCandidateDataLoss, + ] + guard zip(current, previous).allSatisfy({ pair in pair.0 >= pair.1 }) else { + throw QualificationError.invalidFixtureEvidence + } + let values = zip(current, previous).map { pair in pair.0 - pair.1 } + guard values[2] == 0, values[4] == 0 else { + throw QualificationError.invalidEndpointPolicy + } + return PersonaAttemptNetworkObservation( + state: "measured", + acceptedConnections: values[0], + rejectedConnections: values[1], + nonLoopbackAttempts: values[2], + subscriptions: values[3], + acceptedEvents: values[5], + acceptedUploads: values[6], + retrievals: values[7], + unintendedPublications: values[8], + eventsAcceptedDuringExpectedFailure: values[9], + finalCandidateDataLoss: values[10] + ) + } } private struct PersonaAttemptAccessibility: Encodable { @@ -1887,6 +2201,8 @@ private enum PersonaFlow: String, Decodable { private enum QualificationError: Error { case missingEnvironment + case invalidEndpointPolicy + case invalidFixtureEvidence case invalidPublicKey case missingProductSurface case productSubmissionFailed diff --git a/scripts/local-social-fixture.py b/scripts/local-social-fixture.py @@ -379,10 +379,28 @@ class FixtureState: self._expected_failure_rejections = 0 self._transport_rejected_attempts: set[str] = set() self._events_accepted_during_expected_failures = 0 + self._accepted_connections = 0 + self._rejected_connections = 0 + self._non_loopback_attempts = 0 self._production_network_contacts = 0 self._unintended_publications = 0 self._write_evidence() + def observe_connection(self, host: str) -> bool: + try: + permitted = ipaddress.ip_address(host).is_loopback + except ValueError: + permitted = False + with self._lock: + if permitted: + self._accepted_connections += 1 + else: + self._rejected_connections += 1 + self._non_loopback_attempts += 1 + self._production_network_contacts += 1 + self._write_evidence_locked() + return permitted + def publish(self, event: dict[str, Any]) -> bool | None: if not valid_nostr_event(event) or not verify_nostr_signature(event): return False @@ -560,6 +578,10 @@ class FixtureState: "upload_attempts": self._upload_attempts, "accepted_uploads": self._accepted_uploads, "retrievals": self._retrievals, + "accepted_connections": self._accepted_connections, + "rejected_connections": self._rejected_connections, + "non_loopback_attempts": self._non_loopback_attempts, + "production_network_contacts": self._production_network_contacts, } else: payload = self._persona_evidence() @@ -613,6 +635,9 @@ class FixtureState: "events_accepted_during_expected_failures": ( self._events_accepted_during_expected_failures ), + "accepted_connections": self._accepted_connections, + "rejected_connections": self._rejected_connections, + "non_loopback_attempts": self._non_loopback_attempts, "production_network_contacts": self._production_network_contacts, "unintended_publications": self._unintended_publications, "final_candidate_data_loss": 0, @@ -948,10 +973,56 @@ def valid_blossom_authorization( ) -class ReusableThreadingServer(socketserver.ThreadingTCPServer): +class ObservableLoopbackServerMixin: + _fixture_state: FixtureState + + def verify_request( + self, request: socket.socket, client_address: tuple[str, int] + ) -> bool: + del request + return self._fixture_state.observe_connection(client_address[0]) + + +class ReusableThreadingServer( + ObservableLoopbackServerMixin, socketserver.ThreadingTCPServer +): allow_reuse_address = True daemon_threads = True + def __init__( + self, + server_address: tuple[str, int], + handler: type[socketserver.BaseRequestHandler], + state: FixtureState, + ) -> None: + self._fixture_state = state + super().__init__(server_address, handler) + + +class ObservableLoopbackHTTPServer( + ObservableLoopbackServerMixin, http.server.ThreadingHTTPServer +): + def __init__( + self, + server_address: tuple[str, int], + handler: type[http.server.BaseHTTPRequestHandler], + state: FixtureState, + ) -> None: + self._fixture_state = state + super().__init__(server_address, handler) + + +class LoopbackConnectionFactory: + @staticmethod + def relay(port: int, state: FixtureState) -> ReusableThreadingServer: + return ReusableThreadingServer(("127.0.0.1", port), RelayHandler, state) + + @staticmethod + def blossom(port: int, state: FixtureState) -> ObservableLoopbackHTTPServer: + return ObservableLoopbackHTTPServer( + ("127.0.0.1", port), BlossomHandler, state + ) + class RelayHandler(socketserver.BaseRequestHandler): state: FixtureState @@ -1163,10 +1234,8 @@ def serve(arguments: argparse.Namespace) -> int: state = FixtureState(evidence, control, arguments.blossom_port, suite) RelayHandler.state = state BlossomHandler.state = state - relay = ReusableThreadingServer(("127.0.0.1", arguments.relay_port), RelayHandler) - blossom = http.server.ThreadingHTTPServer( - ("127.0.0.1", arguments.blossom_port), BlossomHandler - ) + relay = LoopbackConnectionFactory.relay(arguments.relay_port, state) + blossom = LoopbackConnectionFactory.blossom(arguments.blossom_port, state) threads = [ threading.Thread(target=relay.serve_forever, daemon=True), threading.Thread(target=blossom.serve_forever, daemon=True), @@ -1211,6 +1280,8 @@ def verify(arguments: argparse.Namespace) -> int: or payload.get("upload_attempts", 0) < 1 or payload.get("accepted_uploads", 0) < 1 or payload.get("retrievals", 0) < 1 + or payload.get("non_loopback_attempts") != 0 + or payload.get("production_network_contacts") != 0 ): raise SystemExit("local-social fixture evidence is incomplete") print("local-social fixture evidence verified") @@ -1358,6 +1429,7 @@ def validate_persona_evidence(value: Any, suite: dict[str, Any]) -> dict[str, An "event_kind_counts", "upload_attempts", "accepted_uploads", "retrievals", "distinct_identities", "unknown_attempts", "duplicate_attempts", "expected_failure_rejections", "events_accepted_during_expected_failures", + "accepted_connections", "rejected_connections", "non_loopback_attempts", "production_network_contacts", "unintended_publications", "final_candidate_data_loss", @@ -1377,12 +1449,20 @@ def validate_persona_evidence(value: Any, suite: dict[str, Any]) -> dict[str, An "duplicate_attempts": 0, "expected_failure_rejections": 1, "events_accepted_during_expected_failures": 0, + "non_loopback_attempts": 0, "production_network_contacts": 0, "unintended_publications": 0, "final_candidate_data_loss": 0, } if any(evidence.get(key) != expected for key, expected in expected_scalars.items()): raise ValueError("persona evidence totals are invalid") + if ( + type(evidence["accepted_connections"]) is not int + or not 1 <= evidence["accepted_connections"] <= 61_440 + or type(evidence["rejected_connections"]) is not int + or not 0 <= evidence["rejected_connections"] <= 61_440 + ): + raise ValueError("persona connection evidence is invalid") if evidence["flow_counts"] != {flow: 3 for flow in FLOW_KINDS}: raise ValueError("persona flow counts are invalid") if evidence["event_kind_counts"] != {"1": 9, "31923": 3, "30402": 3}: @@ -2199,12 +2279,16 @@ def verify_persona(arguments: argparse.Namespace) -> int: Path(arguments.fixture_schema).resolve(), "https://radroots.org/schemas/ios/local-social-personas.v1.schema.json", ) + attempt_schema_raw = validate_schema_file( + Path(arguments.attempt_schema).resolve(), + "https://radroots.org/schemas/ios/local-social-persona-attempt-evidence.v1.schema.json", + ) result_schema_raw = validate_schema_file( - Path(arguments.result_schema).resolve(), - "https://radroots.org/schemas/ios/local-social-persona-results.v1.schema.json", + Path(arguments.result_v2_schema).resolve(), + "https://radroots.org/schemas/ios/local-social-persona-results.v2.schema.json", ) evidence_path = Path(arguments.evidence).resolve() - evidence_raw, evidence_value = read_json(evidence_path) + _, evidence_value = read_json(evidence_path) evidence = validate_persona_evidence(evidence_value, suite) result_bundle = Path(arguments.result_bundle).resolve() if not result_bundle.is_dir(): @@ -2213,73 +2297,42 @@ def verify_persona(arguments: argparse.Namespace) -> int: arguments.source_tree, 40 ): raise ValueError("source identity is invalid") - result = { - "schema": "radroots.ios.local-social.persona-results.v1", - "schema_version": 1, - "run_id": arguments.run_id, - "source_commit": arguments.source_commit, - "source_tree": arguments.source_tree, - "fixture_sha256": hashlib.sha256(fixture_raw).hexdigest(), - "fixture_schema_sha256": hashlib.sha256(fixture_schema_raw).hexdigest(), - "result_schema_sha256": hashlib.sha256(result_schema_raw).hexdigest(), - "simulator": simulator_metadata(arguments.simulator_id, result_bundle), - "result_bundle_sha256": directory_digest(result_bundle), - "evidence_sha256": hashlib.sha256(evidence_raw).hexdigest(), - "personas": [ - { - "alias": persona["alias"], - "identity_sha256": persona["identity_sha256"], - "subscriptions": persona["subscriptions"], - "attempts": persona["attempts"], - } - for persona in evidence["personas"] - ], - "flow_counts": evidence["flow_counts"], - "accepted_events": evidence["accepted_events"], - "event_kind_counts": evidence["event_kind_counts"], - "accepted_uploads": evidence["accepted_uploads"], - "retrievals": evidence["retrievals"], - "distinct_identities": evidence["distinct_identities"], - "unknown_attempts": evidence["unknown_attempts"], - "duplicate_attempts": evidence["duplicate_attempts"], - "expected_failure_rejections": evidence["expected_failure_rejections"], - "events_accepted_during_expected_failures": evidence[ - "events_accepted_during_expected_failures" - ], - "production_network_contacts": evidence["production_network_contacts"], - "unintended_publications": evidence["unintended_publications"], - "final_candidate_data_loss": evidence["final_candidate_data_loss"], - "accessibility": { - "locale": "en_US", - "content_size": "accessibility-extra-extra-extra-large", - "reduce_motion": True, - "semantic_audit": "passed", - "voiceover_user_observed": False, - }, - "forward_repairs": arguments.forward_repair_commit, - "complete_matrix_rerun": True, - } - fixture_sha256 = hashlib.sha256(fixture_raw).hexdigest() - fixture_schema_sha256 = hashlib.sha256(fixture_schema_raw).hexdigest() - result_schema_sha256 = hashlib.sha256(result_schema_raw).hexdigest() - validate_persona_result( - result, + attachments = extract_persona_attempt_attachments( + result_bundle, suite, require_measured_network=True + ) + simulator = simulator_metadata(arguments.simulator_id, result_bundle) + result = reconstruct_persona_result_v2( suite, - fixture_sha256, - fixture_schema_sha256, - result_schema_sha256, + attachments, + fixture_sha256=hashlib.sha256(fixture_raw).hexdigest(), + fixture_schema_sha256=hashlib.sha256(fixture_schema_raw).hexdigest(), + attempt_schema_sha256=hashlib.sha256(attempt_schema_raw).hexdigest(), + result_schema_sha256=hashlib.sha256(result_schema_raw).hexdigest(), + result_bundle_sha256=directory_digest(result_bundle), + forward_repairs=arguments.forward_repair_commit, ) + if ( + result["run_id"] != arguments.run_id + or result["source"] + != {"commit": arguments.source_commit, "tree": arguments.source_tree} + or result["simulator"] != simulator + or result["accepted_events"] != evidence["accepted_events"] + or result["accepted_uploads"] != evidence["accepted_uploads"] + or result["retrievals"] != evidence["retrievals"] + or result["non_loopback_attempts"] != evidence["non_loopback_attempts"] + or result["unintended_publications"] != evidence["unintended_publications"] + or result["final_candidate_data_loss"] != evidence["final_candidate_data_loss"] + or result["accepted_connections"] > evidence["accepted_connections"] + or result["rejected_connections"] > evidence["rejected_connections"] + ): + raise ValueError("persona aggregate does not match fixture evidence") output = Path(arguments.output).resolve() output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") - verify_persona_result_file( - output, - suite, - fixture_sha256, - fixture_schema_sha256, - result_schema_sha256, - ) + raw, reloaded = read_json(output) + if raw != (json.dumps(reloaded, indent=2) + "\n").encode("utf-8") or reloaded != result: + raise ValueError("persona v2 result is noncanonical") print( - "local-social persona result verified: " + "local-social measured persona result verified: " f"{hashlib.sha256(output.read_bytes()).hexdigest()}" ) return 0 @@ -2353,6 +2406,8 @@ def parser() -> argparse.ArgumentParser: persona_command.add_argument("--fixture", required=True) persona_command.add_argument("--fixture-schema", required=True) persona_command.add_argument("--result-schema", required=True) + persona_command.add_argument("--attempt-schema", required=True) + persona_command.add_argument("--result-v2-schema", required=True) persona_command.add_argument("--evidence", required=True) persona_command.add_argument("--result-bundle", required=True) persona_command.add_argument("--output", required=True) diff --git a/scripts/test_local_social_fixture.py b/scripts/test_local_social_fixture.py @@ -931,6 +931,26 @@ class LocalSocialFixtureTests(unittest.TestCase): ) self.assertIsNone(fixture.read_control(path)) + def test_observable_loopback_factory_counts_and_rejects_connections(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + state = fixture.FixtureState(root / "evidence.json", root / "control", 0) + server = fixture.LoopbackConnectionFactory.relay(0, state) + try: + self.assertTrue( + server.verify_request(mock.Mock(), ("127.0.0.1", 20_000)) + ) + self.assertFalse( + server.verify_request(mock.Mock(), ("192.0.2.1", 20_001)) + ) + finally: + server.server_close() + _, evidence = fixture.read_json(root / "evidence.json") + self.assertEqual(evidence["accepted_connections"], 1) + self.assertEqual(evidence["rejected_connections"], 1) + self.assertEqual(evidence["non_loopback_attempts"], 1) + self.assertEqual(evidence["production_network_contacts"], 1) + def test_attempt_classification_accepts_only_exact_photo_wire_shape(self) -> None: marker = "rr-p01-a02-photo" digest = "a" * 64 diff --git a/scripts/verify-package-contract.sh b/scripts/verify-package-contract.sh @@ -78,6 +78,8 @@ grep -Fq 'RADROOTS_FIELD_IOS_BLOSSOM_ORIGINS = https:$(SLASH)$(SLASH)blossom.rad "$repo_root/Radroots/Config/Base.xcconfig" grep -Fq '<key>RADROOTS_IOS_UI_TEST_FIXTURE_CONTROL</key>' \ "$repo_root/RadrootsUITests/Info.plist" +grep -Fq '<key>RADROOTS_IOS_UI_TEST_FIXTURE_EVIDENCE</key>' \ + "$repo_root/RadrootsUITests/Info.plist" grep -Fq '<key>RADROOTS_IOS_UI_TEST_NETWORK_PROFILE</key>' \ "$repo_root/RadrootsUITests/Info.plist" for evidence_key in \ @@ -107,6 +109,20 @@ 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 'RADROOTS_IOS_UI_TEST_FIXTURE_EVIDENCE' "$repo_root/scripts/xcode.sh" +grep -Fq 'LoopbackConnectionFactory' "$repo_root/scripts/local-social-fixture.py" +grep -Fq 'attachments = extract_persona_attempt_attachments(' \ + "$repo_root/scripts/local-social-fixture.py" +grep -Fq 'result = reconstruct_persona_result_v2(' \ + "$repo_root/scripts/local-social-fixture.py" +grep -Fq -- '--attempt-schema test-fixtures/local-social-persona-attempt-evidence.v1.schema.json' \ + "$repo_root/scripts/xcode.sh" +grep -Fq -- '--result-v2-schema test-fixtures/local-social-persona-results.v2.schema.json' \ + "$repo_root/scripts/xcode.sh" +if rg -n 'pending_step_258' "$repo_root/RadrootsUITests" --glob '*.swift'; then + echo "error: XCUITest must emit measured network evidence" >&2 + exit 1 +fi grep -Fq 'add.tap()' "$repo_root/RadrootsUITests/RadrootsRemoteQualificationUITests.swift" if rg -n 'coordinate\(' "$repo_root/RadrootsUITests" --glob '*.swift'; then echo "error: UI qualification must not use coordinate taps" >&2 diff --git a/scripts/xcode.sh b/scripts/xcode.sh @@ -299,6 +299,7 @@ case "$operation" in "RADROOTS_IOS_UI_TEST_NOSTR_RELAY_URLS=ws://127.0.0.1:$relay_port" \ "RADROOTS_IOS_UI_TEST_BLOSSOM_ORIGINS=http://127.0.0.1:$blossom_port" \ "RADROOTS_IOS_UI_TEST_FIXTURE_CONTROL=$control" \ + "RADROOTS_IOS_UI_TEST_FIXTURE_EVIDENCE=$evidence" \ "RADROOTS_IOS_UI_TEST_NETWORK_PROFILE=simulator" \ "RADROOTS_IOS_UI_TEST_SOURCE_COMMIT=${source_commit:-}" \ "RADROOTS_IOS_UI_TEST_SOURCE_TREE=${source_tree:-}" \ @@ -317,6 +318,8 @@ case "$operation" in --fixture "$persona_fixture" \ --fixture-schema test-fixtures/local-social-personas.v1.schema.json \ --result-schema test-fixtures/local-social-persona-results.v1.schema.json \ + --attempt-schema test-fixtures/local-social-persona-attempt-evidence.v1.schema.json \ + --result-v2-schema test-fixtures/local-social-persona-results.v2.schema.json \ --evidence "$evidence" \ --result-bundle "$result_bundle" \ --output "$persona_result" \