commit 2939e86b60f41999c2ec2ade2037e3151c232346 parent e7ab5b1c3739a0479cc4a3bb0e2ce00922f0fa80 Author: triesap <tyson@radroots.org> Date: Tue, 1 Sep 2026 02:17:48 +0000 fix(ios): validate clocks and state generations - centralize checked Unix second and millisecond conversion behind an injected clock - fail closed on nonfinite, pre-epoch, zero-required, and overflowing timestamps - reject maximum persisted configuration generations instead of wrapping - cover clock bounds, diagnostics, configuration corruption, and package guards Diffstat:
19 files changed, 366 insertions(+), 55 deletions(-)
diff --git a/Package.swift b/Package.swift @@ -53,6 +53,7 @@ let package = Package( "App/RadrootsRemoteQualification.swift", "App/RadrootsRootShell.swift", "Runtime/RadrootsAddMediaCoordinator.swift", + "Runtime/RadrootsCheckedTime.swift", "Runtime/RadrootsGeneratedRuntimeBackend.swift", "Runtime/RadrootsLifecycleCoordinator.swift", "Runtime/RadrootsRuntimeClient.swift", diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 27B7FB991398E2BE7034C72E /* RadrootsAddStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42C296DF1E35100FE59C04D3 /* RadrootsAddStoreTests.swift */; }; 29A679AE87129E7CBAEA72E9 /* RadrootsRemoteQualificationUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F542C82F0CCB540500C711B /* RadrootsRemoteQualificationUITests.swift */; }; 3BCAFE2B1A96BBE5D741D8FC /* RadrootsSupportingStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41B94CEAFD958CE92D6B9265 /* RadrootsSupportingStoreTests.swift */; }; + 3FA27C59441DFE8F1B276642 /* RadrootsClockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF79F087C35C4B0CFD5CAA68 /* RadrootsClockTests.swift */; }; 51FE0A95B71A5D4EDCEB0748 /* RadrootsStateMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C26D512B6CBD1A9BE4780682 /* RadrootsStateMigrationTests.swift */; }; 53C82BC2E86FCB582E8A8B32 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = FD3E502CA8ADE621038AEB47 /* Localizable.strings */; }; 60B853393C8DF4FDF2C01CAF /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 81E991EAE0A0EAB4853747C6 /* PrivacyInfo.xcprivacy */; }; @@ -66,6 +67,7 @@ C26D512B6CBD1A9BE4780682 /* RadrootsStateMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsStateMigrationTests.swift; sourceTree = "<group>"; }; CAD74320A58874A7F7370505 /* RadrootsUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = RadrootsUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; CF484F988B3D1D3D81880388 /* RadrootsRootShellUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsRootShellUITests.swift; sourceTree = "<group>"; }; + CF79F087C35C4B0CFD5CAA68 /* RadrootsClockTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsClockTests.swift; sourceTree = "<group>"; }; D0451E8A8521D320172FFA35 /* RadrootsRuntimeClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsRuntimeClientTests.swift; sourceTree = "<group>"; }; D91770F919A06DB39186281B /* RadrootsTodayStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsTodayStoreTests.swift; sourceTree = "<group>"; }; D95CB1F4A964D87D2256DA9C /* RadrootsLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsLifecycleTests.swift; sourceTree = "<group>"; }; @@ -97,6 +99,7 @@ isa = PBXGroup; children = ( 42C296DF1E35100FE59C04D3 /* RadrootsAddStoreTests.swift */, + CF79F087C35C4B0CFD5CAA68 /* RadrootsClockTests.swift */, D95CB1F4A964D87D2256DA9C /* RadrootsLifecycleTests.swift */, 8E51B7E9599732A711D60E1B /* RadrootsMediaStoreTests.swift */, 3EA625EE81EC684D43E43CD0 /* RadrootsRemoteQualificationTests.swift */, @@ -359,6 +362,7 @@ buildActionMask = 2147483647; files = ( 27B7FB991398E2BE7034C72E /* RadrootsAddStoreTests.swift in Sources */, + 3FA27C59441DFE8F1B276642 /* RadrootsClockTests.swift in Sources */, CD88BB6222151E0B8242F84A /* RadrootsLifecycleTests.swift in Sources */, AEEF096F40BCBC6D66A3BF32 /* RadrootsMediaStoreTests.swift in Sources */, A8D166BCD8AFCDAF764081BB /* RadrootsRemoteQualificationTests.swift in Sources */, diff --git a/Radroots/Runtime/RadrootsAddMediaCoordinator.swift b/Radroots/Runtime/RadrootsAddMediaCoordinator.swift @@ -75,17 +75,20 @@ actor RadrootsAddMediaCoordinator: RadrootsAddMediaHandling { private let picker: any RadrootsMediaPicker private let preparer: RadrootsAppleMediaPreparer private let transfer: any RadrootsBackgroundTransfer + private let clock: RadrootsClock init( roots: RadrootsAppleFileRoots, picker: any RadrootsMediaPicker, preparer: RadrootsAppleMediaPreparer, - transfer: any RadrootsBackgroundTransfer + transfer: any RadrootsBackgroundTransfer, + clock: RadrootsClock = .system ) { self.roots = roots self.picker = picker self.preparer = preparer self.transfer = transfer + self.clock = clock } static func production( @@ -310,7 +313,7 @@ actor RadrootsAddMediaCoordinator: RadrootsAddMediaHandling { let prepared = try await preparer.prepareImage( RadrootsAppleImagePreparationRequest(source: .file(asset.file)) ) - return RadrootsPreparedMedia( + return try RadrootsPreparedMedia( opaqueReference: "media:\(prepared.sha256)", remoteURL: nil, sha256: prepared.sha256, @@ -319,7 +322,7 @@ actor RadrootsAddMediaCoordinator: RadrootsAddMediaHandling { width: prepared.width, height: prepared.height, alt: "Farm photo", - preparedAtUnixSeconds: UInt64(Date().timeIntervalSince1970) + preparedAtUnixSeconds: clock.unixSeconds() ) } diff --git a/Radroots/Runtime/RadrootsCheckedTime.swift b/Radroots/Runtime/RadrootsCheckedTime.swift @@ -0,0 +1,89 @@ +import Foundation + +enum RadrootsClockError: Error, Sendable, Equatable { + case nonfinite + case beforeUnixEpoch + case overflow + case zeroNotAllowed +} + +struct RadrootsClock: Sendable { + private let now: @Sendable () -> Date + + static let system = Self(now: { Date() }) + + init(now: @escaping @Sendable () -> Date) { + self.now = now + } + + static func fixed(unixSeconds: UInt64) -> Self { + Self(now: { Date(timeIntervalSince1970: TimeInterval(unixSeconds)) }) + } + + func unixSeconds(requirePositive: Bool = false) throws -> UInt64 { + try Self.unixSeconds(from: now(), requirePositive: requirePositive) + } + + func unixMilliseconds(requirePositive: Bool = false) throws -> UInt64 { + try Self.unixMilliseconds(from: now(), requirePositive: requirePositive) + } + + static func unixSeconds( + from date: Date, + requirePositive: Bool = false + ) throws -> UInt64 { + try unsigned(date.timeIntervalSince1970, multiplier: 1, requirePositive: requirePositive) + } + + static func unixMilliseconds( + from date: Date, + requirePositive: Bool = false + ) throws -> UInt64 { + try unsigned(date.timeIntervalSince1970, multiplier: 1000, requirePositive: requirePositive) + } + + static func signedUnixMilliseconds(from date: Date) throws -> Int64 { + let value = try unixMilliseconds(from: date) + guard let signed = Int64(exactly: value) else { + throw RadrootsClockError.overflow + } + return signed + } + + private static func unsigned( + _ seconds: TimeInterval, + multiplier: TimeInterval, + requirePositive: Bool + ) throws -> UInt64 { + guard seconds.isFinite else { + throw RadrootsClockError.nonfinite + } + guard seconds >= 0 else { + throw RadrootsClockError.beforeUnixEpoch + } + let scaled = seconds * multiplier + guard scaled.isFinite, + let value = UInt64(exactly: scaled.rounded(.down)) + else { + throw RadrootsClockError.overflow + } + guard !requirePositive || value > 0 else { + throw RadrootsClockError.zeroNotAllowed + } + return value + } +} + +enum RadrootsStateTransitionError: Error, Sendable, Equatable { + case generationOverflow +} + +enum RadrootsCheckedStateTransition { + static func nextGeneration(after generation: UInt64) throws -> UInt64 { + let (next, overflow) = generation.addingReportingOverflow(1) + guard !overflow else { + throw RadrootsStateTransitionError.generationOverflow + } + return next + } +} diff --git a/Radroots/Runtime/RadrootsGeneratedRuntimeBackend.swift b/Radroots/Runtime/RadrootsGeneratedRuntimeBackend.swift @@ -3,9 +3,11 @@ import RadrootsKitBindings private final class RadrootsGeneratedHostSigner: RadrootsHostSigner, @unchecked Sendable { private let signer: any RadrootsRuntimeSigner + private let clock: RadrootsClock - init(signer: any RadrootsRuntimeSigner) { + init(signer: any RadrootsRuntimeSigner, clock: RadrootsClock = .system) { self.signer = signer + self.clock = clock } func signerStatus() async -> SignerStatusRecord { @@ -16,6 +18,9 @@ private final class RadrootsGeneratedHostSigner: RadrootsHostSigner, @unchecked } func sign(request: HostSigningRequest) async -> HostSigningResult { + guard (try? clock.unixMilliseconds()) != nil else { + return failedClockResult(for: request) + } let purpose = request.purpose.appValue let outcome = await signer.sign( RadrootsRuntimeSigningRequest( @@ -37,6 +42,9 @@ private final class RadrootsGeneratedHostSigner: RadrootsHostSigner, @unchecked ) } #endif + guard let completedAtUnixMilliseconds = try? clock.unixMilliseconds() else { + return failedClockResult(for: request) + } return HostSigningResult( schemaVersion: 1, outcome: outcome.generatedOutcome, @@ -45,7 +53,20 @@ private final class RadrootsGeneratedHostSigner: RadrootsHostSigner, @unchecked publicKey: request.publicKey, purpose: request.purpose, signatureHex: outcome.signatureHex, - completedAtUnixMs: UInt64(Date().timeIntervalSince1970 * 1000) + completedAtUnixMs: completedAtUnixMilliseconds + ) + } + + private func failedClockResult(for request: HostSigningRequest) -> HostSigningResult { + HostSigningResult( + schemaVersion: 1, + outcome: .failed, + operationId: request.operationId, + signerRequestId: request.signerRequestId, + publicKey: request.publicKey, + purpose: request.purpose, + signatureHex: nil, + completedAtUnixMs: 0 ) } } diff --git a/Radroots/Runtime/RadrootsLifecycleCoordinator.swift b/Radroots/Runtime/RadrootsLifecycleCoordinator.swift @@ -139,24 +139,40 @@ struct RadrootsDiagnosticRecord: Codable, Sendable, Equatable { } actor RadrootsDiagnosticsBuffer: RadrootsTelemetry { + private struct Entry: Sendable { + let event: RadrootsTelemetryEvent + let occurredAtUnixMilliseconds: Int64 + } + private let capacity: Int private let policy = RadrootsTelemetryRedactionPolicy.default - private var events: [RadrootsTelemetryEvent] = [] + private var events: [Entry] = [] init(capacity: Int = 128) { self.capacity = min(max(capacity, 16), 256) } func record(_ event: RadrootsTelemetryEvent) { - events.append(policy.redacted(event)) + guard let occurredAtUnixMilliseconds = try? RadrootsClock.signedUnixMilliseconds( + from: event.occurredAt + ) else { + return + } + events.append( + Entry( + event: policy.redacted(event), + occurredAtUnixMilliseconds: occurredAtUnixMilliseconds + ) + ) if events.count > capacity { events.removeFirst(events.count - capacity) } } func records() -> [RadrootsDiagnosticRecord] { - events.map { event in - RadrootsDiagnosticRecord( + events.map { entry in + let event = entry.event + return RadrootsDiagnosticRecord( name: event.name, category: event.category, level: event.level.rawValue, @@ -165,7 +181,7 @@ actor RadrootsDiagnosticsBuffer: RadrootsTelemetry { (field.key, field.value.renderedValue) } ), - occurredAtUnixMilliseconds: Int64(event.occurredAt.timeIntervalSince1970 * 1000) + occurredAtUnixMilliseconds: entry.occurredAtUnixMilliseconds ) } } diff --git a/Radroots/State/RadrootsAddStore.swift b/Radroots/State/RadrootsAddStore.swift @@ -27,8 +27,7 @@ final class RadrootsAddStore: ObservableObject { private let media: (any RadrootsAddMediaHandling)? private let observationDelay: @Sendable (UInt32) async throws -> Void private let identifier: @Sendable () -> String - private let nowUnixSeconds: @Sendable () -> UInt64 - private let nowUnixMilliseconds: @Sendable () -> UInt64 + private let clock: RadrootsClock private var generation: UInt64 = 0 private var operationGeneration: UInt64? private var operationTask: Task<Void, Never>? @@ -46,25 +45,19 @@ final class RadrootsAddStore: ObservableObject { identifier: @escaping @Sendable () -> String = { UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() }, - nowUnixSeconds: @escaping @Sendable () -> UInt64 = { - UInt64(Date().timeIntervalSince1970) - }, - nowUnixMilliseconds: @escaping @Sendable () -> UInt64 = { - UInt64(Date().timeIntervalSince1970 * 1000) - }, + clock: RadrootsClock = .system, observationDelay: @escaping @Sendable (UInt32) async throws -> Void = RadrootsRuntimeObservationBackoff.sleep ) { self.runtimeClient = runtimeClient self.media = media self.identifier = identifier - self.nowUnixSeconds = nowUnixSeconds - self.nowUnixMilliseconds = nowUnixMilliseconds + self.clock = clock self.observationDelay = observationDelay form = Self.newForm( type: initialType, identifier: identifier, - nowUnixSeconds: nowUnixSeconds + clock: clock ) } @@ -178,7 +171,7 @@ final class RadrootsAddStore: ObservableObject { form = Self.newForm( type: type, identifier: identifier, - nowUnixSeconds: nowUnixSeconds + clock: clock ) message = nil } @@ -197,7 +190,7 @@ final class RadrootsAddStore: ObservableObject { form = Self.newForm( type: type ?? form.commandType, identifier: identifier, - nowUnixSeconds: nowUnixSeconds + clock: clock ) message = nil } @@ -476,8 +469,8 @@ final class RadrootsAddStore: ObservableObject { targetAddress: card.sourceAddress, reason: "Removed by author." ), - authoredAtUnixSeconds: self.nowUnixSeconds(), - persistedAtUnixMilliseconds: self.nowUnixMilliseconds() + authoredAtUnixSeconds: self.clock.unixSeconds(), + persistedAtUnixMilliseconds: self.clock.unixMilliseconds() ) self.accept(status) status = try await self.runtimeClient.queueAddIntent( @@ -761,7 +754,7 @@ final class RadrootsAddStore: ObservableObject { private static func newForm( type: RadrootsAddCommandType, identifier: @Sendable () -> String, - nowUnixSeconds: @Sendable () -> UInt64 + clock: RadrootsClock ) -> RadrootsAddForm { var form = RadrootsAddForm.empty(type) if type == .createEvent || type == .createFoodAvailability { @@ -771,7 +764,9 @@ final class RadrootsAddStore: ObservableObject { } } if type == .createEvent { - let now = nowUnixSeconds() + guard let now = try? clock.unixSeconds() else { + return form + } let start = now.addingReportingOverflow(3600).overflow ? now : now + 3600 let end = start.addingReportingOverflow(3600).overflow ? start : start + 3600 form.eventStartUnixSeconds = start diff --git a/Radroots/State/RadrootsConfigurationStore.swift b/Radroots/State/RadrootsConfigurationStore.swift @@ -133,10 +133,16 @@ actor RadrootsConfigurationStore { private let bootstrap: RadrootsConfigurationBootstrap private let fileAccess: RadrootsAppleFileAccess + private let clock: RadrootsClock - init(bootstrap: RadrootsConfigurationBootstrap, roots: RadrootsAppleFileRoots) { + init( + bootstrap: RadrootsConfigurationBootstrap, + roots: RadrootsAppleFileRoots, + clock: RadrootsClock = .system + ) { self.bootstrap = bootstrap fileAccess = RadrootsAppleFileAccess(roots: roots) + self.clock = clock } func load() throws -> RadrootsAppConfiguration { @@ -160,10 +166,10 @@ actor RadrootsConfigurationStore { try persist(selected) } } else { - selected = Self.bootstrapConfiguration( + selected = try Self.bootstrapConfiguration( bootstrap, profile: profile, - generation: (upgraded.generation ?? 0) + 1, + generation: Self.nextGeneration(after: upgraded.generation ?? 0), activationState: .reconfigurationRequired, bootstrapFingerprint: bootstrapFingerprint, previousBlossomConfigFingerprint: upgraded.canonicalBlossomConfigFingerprint @@ -345,10 +351,16 @@ actor RadrootsConfigurationStore { guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else { throw RadrootsConfigurationError.persistenceFailed } + let createdAtUnixMilliseconds: UInt64 + do { + createdAtUnixMilliseconds = try clock.unixMilliseconds(requirePositive: true) + } catch { + throw RadrootsConfigurationError.persistenceFailed + } let value = RadrootsSourceGeneration( schemaVersion: 1, generationHex: bytes.map { String(format: "%02x", $0) }.joined(), - createdAtUnixMilliseconds: max(1, UInt64(Date().timeIntervalSince1970 * 1000)) + createdAtUnixMilliseconds: createdAtUnixMilliseconds ) do { let data = try JSONEncoder.radroots.encode(value) @@ -412,6 +424,14 @@ actor RadrootsConfigurationStore { } } + private static func nextGeneration(after generation: UInt64) throws -> UInt64 { + do { + return try RadrootsCheckedStateTransition.nextGeneration(after: generation) + } catch { + throw RadrootsConfigurationError.corruptStoredConfiguration + } + } + private static func profile(for runtimeMode: String) throws -> RadrootsAppNetworkProfile { switch runtimeMode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { case "production": .publicNetwork diff --git a/Radroots/State/RadrootsSupportingStores.swift b/Radroots/State/RadrootsSupportingStores.swift @@ -15,16 +15,16 @@ final class RadrootsSearchStore: ObservableObject { @Published private(set) var state: RadrootsSupportingLoadState = .idle private let runtimeClient: RadrootsRuntimeClient - private let now: @Sendable () -> UInt64 + private let clock: RadrootsClock private var context: RadrootsLocalNetwork? private var generation: UInt64 = 0 init( runtimeClient: RadrootsRuntimeClient, - now: @escaping @Sendable () -> UInt64 = { UInt64(Date().timeIntervalSince1970) } + clock: RadrootsClock = .system ) { self.runtimeClient = runtimeClient - self.now = now + self.clock = clock } func configure(context: RadrootsLocalNetwork?) { @@ -68,7 +68,7 @@ final class RadrootsSearchStore: ObservableObject { context: context, query: normalized, limit: 50, - asOfUnixSeconds: now() + asOfUnixSeconds: clock.unixSeconds() ) guard requestedGeneration == generation, !Task.isCancelled else { return } results = Self.unique(loaded) @@ -102,7 +102,7 @@ final class RadrootsMeStore: ObservableObject { @Published private(set) var observationState: RadrootsRuntimeObservationState = .inactive private let runtimeClient: RadrootsRuntimeClient - private let now: @Sendable () -> UInt64 + private let clock: RadrootsClock private let observationDelay: @Sendable (UInt32) async throws -> Void private var context: RadrootsLocalNetwork? private var generation: UInt64 = 0 @@ -112,12 +112,12 @@ final class RadrootsMeStore: ObservableObject { init( runtimeClient: RadrootsRuntimeClient, - now: @escaping @Sendable () -> UInt64 = { UInt64(Date().timeIntervalSince1970) }, + clock: RadrootsClock = .system, observationDelay: @escaping @Sendable (UInt32) async throws -> Void = RadrootsRuntimeObservationBackoff.sleep ) { self.runtimeClient = runtimeClient - self.now = now + self.clock = clock self.observationDelay = observationDelay } @@ -159,7 +159,7 @@ final class RadrootsMeStore: ObservableObject { do { let loaded = try await runtimeClient.me( context: context, - asOfUnixSeconds: now() + asOfUnixSeconds: clock.unixSeconds() ) guard requestedGeneration == generation, !Task.isCancelled else { return } snapshot = loaded diff --git a/Radroots/State/RadrootsTodayStore.swift b/Radroots/State/RadrootsTodayStore.swift @@ -20,7 +20,7 @@ final class RadrootsTodayStore: ObservableObject { private let runtimeClient: RadrootsRuntimeClient private let pageSize: UInt16 - private let now: @Sendable () -> UInt64 + private let clock: RadrootsClock private let observationDelay: @Sendable (UInt32) async throws -> Void private var frozenAsOfUnixSeconds: UInt64? private var nextCursor: String? @@ -35,16 +35,14 @@ final class RadrootsTodayStore: ObservableObject { contexts: [RadrootsLocalNetwork] = [], selectedContextID: String? = nil, pageSize: UInt16 = 20, - now: @escaping @Sendable () -> UInt64 = { - UInt64(Date().timeIntervalSince1970) - }, + clock: RadrootsClock = .system, observationDelay: @escaping @Sendable (UInt32) async throws -> Void = RadrootsRuntimeObservationBackoff.sleep ) { self.runtimeClient = runtimeClient self.contexts = Self.unique(contexts) self.pageSize = min(max(pageSize, 1), 100) - self.now = now + self.clock = clock self.observationDelay = observationDelay if let selectedContextID, self.contexts.contains(where: { $0.id == selectedContextID }) @@ -151,7 +149,7 @@ final class RadrootsTodayStore: ObservableObject { do { _ = try await runtimeClient.refreshToday( context: context, - nowUnixSeconds: now(), + nowUnixSeconds: clock.unixSeconds(), update: update ) } catch { @@ -160,7 +158,7 @@ final class RadrootsTodayStore: ObservableObject { } do { - let asOf = now() + let asOf = try clock.unixSeconds() let page = try await runtimeClient.todayPage( request: .first( context: context, diff --git a/Radroots/Views/RadrootsAddView.swift b/Radroots/Views/RadrootsAddView.swift @@ -433,7 +433,12 @@ struct RadrootsAddView: View { .map { Date(timeIntervalSince1970: TimeInterval($0)) } ?? Date().addingTimeInterval(defaultOffset) }, - set: { store.updateForm(keyPath, UInt64(max(1, $0.timeIntervalSince1970))) } + set: { + guard let value = try? RadrootsClock.unixSeconds(from: $0, requirePositive: true) else { + return + } + store.updateForm(keyPath, value) + } ) } diff --git a/RadrootsTests/RadrootsAddStoreTests.swift b/RadrootsTests/RadrootsAddStoreTests.swift @@ -372,8 +372,7 @@ final class RadrootsAddStoreTests: XCTestCase { let store = RadrootsAddStore( runtimeClient: client, identifier: { String(repeating: "f", count: 32) }, - nowUnixSeconds: { 1_800_000_200 }, - nowUnixMilliseconds: { 1_800_000_200_000 } + clock: .fixed(unixSeconds: 1_800_000_200) ) await store.configure(snapshot: backend.snapshot()) await store.start() diff --git a/RadrootsTests/RadrootsClockTests.swift b/RadrootsTests/RadrootsClockTests.swift @@ -0,0 +1,53 @@ +import Foundation +@testable import RadrootsApp +import XCTest + +final class RadrootsClockTests: XCTestCase { + func testUnixClockUsesCheckedFlooringAtBothPrecisions() throws { + let clock = RadrootsClock(now: { Date(timeIntervalSince1970: 1_800_000_000.125) }) + + XCTAssertEqual(try clock.unixSeconds(), 1_800_000_000) + XCTAssertEqual(try clock.unixMilliseconds(), 1_800_000_000_125) + XCTAssertEqual(try RadrootsClock.fixed(unixSeconds: 1).unixSeconds(requirePositive: true), 1) + } + + func testUnixClockRejectsNonfinitePreEpochZeroAndOverflow() { + let vectors: [(TimeInterval, RadrootsClockError)] = [ + (.nan, .nonfinite), + (.infinity, .nonfinite), + (-0.001, .beforeUnixEpoch), + (TimeInterval.greatestFiniteMagnitude, .overflow), + ] + for (value, expected) in vectors { + XCTAssertThrowsError( + try RadrootsClock.unixMilliseconds(from: Date(timeIntervalSince1970: value)) + ) { error in + XCTAssertEqual(error as? RadrootsClockError, expected) + } + } + XCTAssertThrowsError( + try RadrootsClock.unixSeconds( + from: Date(timeIntervalSince1970: 0), + requirePositive: true + ) + ) { error in + XCTAssertEqual(error as? RadrootsClockError, .zeroNotAllowed) + } + XCTAssertThrowsError( + try RadrootsClock.signedUnixMilliseconds( + from: Date(timeIntervalSince1970: TimeInterval(Int64.max) / 1000 + 1) + ) + ) { error in + XCTAssertEqual(error as? RadrootsClockError, .overflow) + } + } + + func testGenerationTransitionRejectsMaximumWithoutWrapping() throws { + XCTAssertEqual(try RadrootsCheckedStateTransition.nextGeneration(after: 0), 1) + XCTAssertEqual(try RadrootsCheckedStateTransition.nextGeneration(after: UInt64.max - 1), .max) + XCTAssertThrowsError(try RadrootsCheckedStateTransition.nextGeneration(after: .max)) { + error in + XCTAssertEqual(error as? RadrootsStateTransitionError, .generationOverflow) + } + } +} diff --git a/RadrootsTests/RadrootsLifecycleTests.swift b/RadrootsTests/RadrootsLifecycleTests.swift @@ -4,6 +4,24 @@ import RadrootsKit import XCTest final class RadrootsLifecycleTests: XCTestCase { + func testDiagnosticsRejectPreEpochAndSignedOverflowTimestamps() async throws { + let buffer = RadrootsDiagnosticsBuffer() + for timestamp in [ + TimeInterval(-1), + TimeInterval(Int64.max) / 1000 + 1, + ] { + try await buffer.record( + RadrootsTelemetryEvent( + name: "ios.lifecycle.invalid_clock", + occurredAt: Date(timeIntervalSince1970: timestamp) + ) + ) + } + + let records = await buffer.records() + XCTAssertTrue(records.isEmpty) + } + func testBackgroundEventsCompleteExactlyOnceBeforeAndAfterAttachment() async { let router = RadrootsBackgroundEventRouter() let first = CompletionProbe() diff --git a/RadrootsTests/RadrootsStateMigrationTests.swift b/RadrootsTests/RadrootsStateMigrationTests.swift @@ -274,6 +274,45 @@ final class RadrootsStateMigrationTests: XCTestCase { ) } + func testMaximumStoredGenerationFailsAsCorruptionWithoutWrapping() async throws { + let fixture = try StateFixture() + defer { fixture.remove() } + let fileAccess = RadrootsAppleFileAccess(roots: fixture.roots) + _ = try await RadrootsConfigurationStore( + bootstrap: fixture.bootstrap, + roots: fixture.roots + ).load() + var persisted = try configurationObject(fileAccess) + persisted["generation"] = NSNumber(value: UInt64.max) + try fileAccess.write( + .inline(JSONSerialization.data(withJSONObject: persisted, options: [.sortedKeys])), + to: RadrootsFileReference( + scope: .data, + relativePath: "settings/radroots_configuration_v3.json" + ) + ) + let changedBootstrap = RadrootsConfigurationBootstrap( + runtimeMode: fixture.bootstrap.runtimeMode, + relayURLs: ["ws://127.0.0.1:7448"], + blossomOrigins: fixture.bootstrap.blossomOrigins, + keychainServicePrefix: fixture.bootstrap.keychainServicePrefix, + bundleIdentifier: fixture.bootstrap.bundleIdentifier, + appMetadata: fixture.bootstrap.appMetadata + ) + let changed = RadrootsConfigurationStore( + bootstrap: changedBootstrap, + roots: fixture.roots + ) + + do { + _ = try await changed.load() + XCTFail("Maximum persisted generation must fail closed") + } catch { + XCTAssertEqual(error as? RadrootsConfigurationError, .corruptStoredConfiguration) + } + XCTAssertEqual(try configurationObject(fileAccess)["generation"] as? UInt64, .max) + } + func testBootstrapActivationRetainsSelectedNetworkAndClearsPendingRollbackState() async throws { let fixture = try StateFixture() defer { fixture.remove() } @@ -439,6 +478,34 @@ final class RadrootsStateMigrationTests: XCTestCase { ) } + func testSourceGenerationRejectsInvalidInjectedClockWithoutPersistence() async throws { + let fixture = try StateFixture() + defer { fixture.remove() } + for value in [TimeInterval.nan, -1, TimeInterval.greatestFiniteMagnitude, 0] { + let store = RadrootsConfigurationStore( + bootstrap: fixture.bootstrap, + roots: fixture.roots, + clock: RadrootsClock(now: { Date(timeIntervalSince1970: value) }) + ) + do { + _ = try await store.sourceGeneration() + XCTFail("Invalid clock value must fail closed") + } catch { + XCTAssertEqual(error as? RadrootsConfigurationError, .persistenceFailed) + } + } + let fileAccess = RadrootsAppleFileAccess(roots: fixture.roots) + XCTAssertThrowsError( + try fileAccess.read( + RadrootsFileReference( + scope: .data, + relativePath: "state/source_generation_v1.json" + ), + mode: .inline(maxBytes: RadrootsConfigurationStore.maximumStoredConfigurationBytes) + ) + ) + } + func testLegacyIdentityMigrationIsTransactionalAndIdempotent() async throws { let secureStore = InMemorySecureStore() let metadataStore = InMemoryIdentityMetadataStore() diff --git a/RadrootsTests/RadrootsSupportingStoreTests.swift b/RadrootsTests/RadrootsSupportingStoreTests.swift @@ -6,7 +6,10 @@ final class RadrootsSupportingStoreTests: XCTestCase { func testSearchUsesCurrentContextDeduplicatesAndClearsEmptyQueries() async throws { let backend = SupportingBackend() let client = try await Self.startedClient(backend) - let store = RadrootsSearchStore(runtimeClient: client, now: { 1_800_000_000 }) + let store = RadrootsSearchStore( + runtimeClient: client, + clock: .fixed(unixSeconds: 1_800_000_000) + ) store.configure(context: Self.context(id: "farm")) store.updateQuery(" carrots ") @@ -27,7 +30,10 @@ final class RadrootsSupportingStoreTests: XCTestCase { func testSearchContextChangeFencesLateResults() async throws { let backend = SupportingBackend(searchDelayNanoseconds: 40_000_000) let client = try await Self.startedClient(backend) - let store = RadrootsSearchStore(runtimeClient: client, now: { 1_800_000_000 }) + let store = RadrootsSearchStore( + runtimeClient: client, + clock: .fixed(unixSeconds: 1_800_000_000) + ) store.configure(context: Self.context(id: "first")) store.updateQuery("carrots") @@ -45,7 +51,10 @@ final class RadrootsSupportingStoreTests: XCTestCase { func testMePreservesAdoptedProfileFieldsAndCurrentCards() async throws { let backend = SupportingBackend() let client = try await Self.startedClient(backend) - let store = RadrootsMeStore(runtimeClient: client, now: { 1_800_000_000 }) + let store = RadrootsMeStore( + runtimeClient: client, + clock: .fixed(unixSeconds: 1_800_000_000) + ) store.configure(context: Self.context(id: "farm")) await store.start() diff --git a/RadrootsTests/RadrootsTodayStoreTests.swift b/RadrootsTests/RadrootsTodayStoreTests.swift @@ -26,7 +26,7 @@ final class RadrootsTodayStoreTests: XCTestCase { runtimeClient: client, contexts: [context], pageSize: 1, - now: { 1_800_000_100 } + clock: .fixed(unixSeconds: 1_800_000_100) ) await store.reload(refreshProjection: false) @@ -65,7 +65,7 @@ final class RadrootsTodayStoreTests: XCTestCase { let store = RadrootsTodayStore( runtimeClient: client, contexts: [firstContext, secondContext], - now: { 1_800_000_100 } + clock: .fixed(unixSeconds: 1_800_000_100) ) let staleRequest = Task { await store.reload(refreshProjection: false) } @@ -167,7 +167,7 @@ final class RadrootsTodayStoreTests: XCTestCase { let store = RadrootsTodayStore( runtimeClient: client, contexts: [context], - now: { 1_800_000_100 } + clock: .fixed(unixSeconds: 1_800_000_100) ) await store.reload() diff --git a/project.yml b/project.yml @@ -53,6 +53,7 @@ targets: - path: RadrootsTests/RadrootsAddStoreTests.swift - path: RadrootsTests/RadrootsSupportingStoreTests.swift - path: RadrootsTests/RadrootsLifecycleTests.swift + - path: RadrootsTests/RadrootsClockTests.swift - path: RadrootsTests/RadrootsRemoteQualificationTests.swift - path: RadrootsTests/RadrootsUserMessagesTests.swift settings: diff --git a/scripts/verify-package-contract.sh b/scripts/verify-package-contract.sh @@ -250,6 +250,18 @@ then echo "error: presentation state bypasses the governed user-message catalog" >&2 exit 1 fi +test -f "$repo_root/Radroots/Runtime/RadrootsCheckedTime.swift" +grep -Fq 'UInt64(exactly: scaled.rounded(.down))' \ + "$repo_root/Radroots/Runtime/RadrootsCheckedTime.swift" +grep -Fq 'generation.addingReportingOverflow(1)' \ + "$repo_root/Radroots/Runtime/RadrootsCheckedTime.swift" +if rg -n \ + 'UInt64\([^[:cntrl:]]*timeIntervalSince1970|Int64\([^[:cntrl:]]*timeIntervalSince1970|\(upgraded\.generation \?\? 0\) \+ 1' \ + "$repo_root/Radroots" --glob '*.swift' +then + echo "error: production source bypasses checked time or generation conversion" >&2 + exit 1 +fi for resolved in \ "$repo_root/Package.resolved" \