Make panic wipe deterministic and device-bound (#1431)

* Make panic wipe deterministic and device-bound

* Scope install markers to iOS

* Harden panic recovery and service shutdown

* Invalidate queued BLE ingress during panic

* Harden panic keychain and media cleanup

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: jack <jack@deck.local>
This commit is contained in:
jack
2026-07-26 10:28:50 +02:00
committed by GitHub
co-authored by jack jack
parent fb8fe39713
commit cd727c6867
30 changed files with 2747 additions and 299 deletions
+174
View File
@@ -572,6 +572,108 @@ struct BLEServiceCoreTests {
#expect(ble.myPeerID == PeerID(str: newFingerprint.prefix(16)))
}
@Test
func panicSuspension_dropsLateOutboundWorkUntilCommit() async {
let ble = makeService()
let outbound = OutboundPacketTap()
ble._test_onOutboundPacket = outbound.record
let packet = makePublicPacket(
content: "late callback",
sender: ble.myPeerID,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000)
)
ble.suspendForPanicReset()
ble.sendPacket(packet)
#expect(outbound.count(ofType: .message) == 0)
ble.completePanicReset(restartServices: false)
ble.sendPacket(packet)
#expect(outbound.count(ofType: .message) == 1)
}
@Test @MainActor
func panicSuspension_invalidatesQueuedMainActorIngress() async {
let ble = makeService()
let delegate = TransportEventCaptureDelegate()
ble.eventDelegate = delegate
let message = BitchatMessage(
id: "pre-panic-ingress",
sender: "Peer",
content: "must not survive panic",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: PeerID(str: "1122334455667788")
)
// The test already owns MainActor, so this task cannot run until the
// synchronous panic boundary below has invalidated its generation.
ble._test_emitTransportEvent(.messageReceived(message))
ble.suspendForPanicReset()
await Task.yield()
#expect(delegate.messageIDs.isEmpty)
ble.completePanicReset(restartServices: false)
ble._test_emitTransportEvent(.messageReceived(message))
await Task.yield()
#expect(delegate.messageIDs == [message.id])
}
@Test @MainActor
func panicSuspension_rejectsPausedBLEReceiveBeforeMessageQueueHandoff() async {
let ble = makeService()
let gate = ReceivePacketHandoffGate()
ble._test_beforeReceivePacketHandoff = gate.pause
ble._test_onReceivePacketHandoff = gate.recordHandoff
defer {
gate.release()
ble._test_beforeReceivePacketHandoff = nil
ble._test_onReceivePacketHandoff = nil
}
let sender = PeerID(str: "1122334455667788")
let packet = makePublicPacket(
content: "must not cross panic",
sender: sender,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000)
)
ble._test_handlePacketFromBLEQueue(packet, fromPeerID: sender)
#expect(await TestHelpers.waitUntil(
{ gate.hasPaused },
timeout: TestConstants.longTimeout
))
// Panic closes the lifecycle before waiting for the paused bleQueue
// callback. Releasing it afterward lets the callback enqueue its
// messageQueue handoff, where the captured generation must be rejected
// before packet processing starts.
let panicIngressObserver = PanicIngressObserver(service: ble)
let didObservePanicClosure = await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
let didObserveClosure = panicIngressObserver.waitUntilClosed(
timeout: TestConstants.defaultTimeout
)
gate.release()
continuation.resume(returning: didObserveClosure)
}
ble.suspendForPanicReset()
}
#expect(didObservePanicClosure)
#expect(gate.handoffCount == 0)
// A packet captured under the reopened lifecycle still crosses the
// same handoff, proving the test did not merely disable the hook.
ble.completePanicReset(restartServices: false)
ble._test_handlePacketFromBLEQueue(packet, fromPeerID: sender)
#expect(await TestHelpers.waitUntil(
{ gate.handoffCount == 1 },
timeout: TestConstants.longTimeout
))
}
@Test
func modifiedServices_rediscoverWhenBitChatServiceIsInvalidated() async throws {
let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB")
@@ -666,6 +768,68 @@ private final class OutboundPacketTap {
}
}
private final class ReceivePacketHandoffGate: @unchecked Sendable {
private let condition = NSCondition()
private var paused = false
private var released = false
private var recordedHandoffCount = 0
var hasPaused: Bool {
condition.lock()
defer { condition.unlock() }
return paused
}
var handoffCount: Int {
condition.lock()
defer { condition.unlock() }
return recordedHandoffCount
}
func pause() {
condition.lock()
paused = true
condition.broadcast()
while !released {
condition.wait()
}
condition.unlock()
}
func release() {
condition.lock()
released = true
condition.broadcast()
condition.unlock()
}
func recordHandoff() {
condition.lock()
recordedHandoffCount += 1
condition.unlock()
}
}
/// Lets a dedicated dispatch worker observe the lock-protected panic gate
/// without treating the full BLE service as generally Sendable.
private final class PanicIngressObserver: @unchecked Sendable {
private let service: BLEService
init(service: BLEService) {
self.service = service
}
func waitUntilClosed(timeout: TimeInterval) -> Bool {
let deadline = DispatchTime.now().uptimeNanoseconds
+ UInt64(timeout * 1_000_000_000)
while service._test_isPanicIngressOpen,
DispatchTime.now().uptimeNanoseconds < deadline {
Thread.sleep(forTimeInterval: 0.001)
}
return !service._test_isPanicIngressOpen
}
}
private func makeService() -> BLEService {
let keychain = MockKeychain()
let identityManager = MockIdentityManager(keychain)
@@ -724,3 +888,13 @@ private final class PublicCaptureDelegate: BitchatDelegate {
return publicMessages
}
}
@MainActor
private final class TransportEventCaptureDelegate: TransportEventDelegate {
private(set) var messageIDs: [String] = []
func didReceiveTransportEvent(_ event: TransportEvent) {
guard case .messageReceived(let message) = event else { return }
messageIDs.append(message.id)
}
}
@@ -16,6 +16,11 @@
import Testing
import Foundation
import BitFoundation
#if os(iOS)
import UIKit
#else
import AppKit
#endif
@testable import bitchat
// MARK: - Mock Context
@@ -188,6 +193,114 @@ struct ChatMediaTransferCoordinatorContextTests {
#expect(coordinator.messageIDToTransferId.isEmpty)
}
@Test @MainActor
func resetForPanic_cancelsEveryTransportTransferAndClearsMappings() {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
coordinator.registerTransfer(transferId: "t1", messageID: "m1")
coordinator.registerTransfer(transferId: "t1", messageID: "m2")
coordinator.registerTransfer(transferId: "t2", messageID: "m3")
coordinator.resetForPanic()
#expect(Set(context.cancelledTransfers) == Set(["t1", "t2"]))
#expect(coordinator.transferIdToMessageIDs.isEmpty)
#expect(coordinator.messageIDToTransferId.isEmpty)
}
@Test @MainActor
func resetForPanic_waitsForActiveImageWriterBeforeReturning() async throws {
let context = MockChatMediaTransferContext()
let sourceURL = try makeCoordinatorTestImageURL()
let outputURL = FileManager.default.temporaryDirectory
.appendingPathComponent("panic-prepared-\(UUID().uuidString).jpg")
let preparer = PausedImagePreparer(outputURL: outputURL)
let coordinator = ChatMediaTransferCoordinator(
context: context,
prepareImagePacket: { sourceURL in
try preparer.prepare(sourceURL)
}
)
defer {
preparer.release()
try? FileManager.default.removeItem(at: sourceURL)
try? FileManager.default.removeItem(at: outputURL)
}
coordinator.sendImage(from: sourceURL)
#expect(await TestHelpers.waitUntil(
{ preparer.hasStarted },
timeout: TestConstants.longTimeout
))
DispatchQueue.global(qos: .userInitiated).asyncAfter(
deadline: .now() + .milliseconds(100)
) {
preparer.release()
}
coordinator.resetForPanic()
// The synchronous reset boundary cannot return while a pre-panic
// writer can still create output. The real panic path deletes media
// immediately after this method returns.
#expect(preparer.hasFinished)
try? FileManager.default.removeItem(at: outputURL)
#expect(await TestHelpers.waitUntil(
{ !FileManager.default.fileExists(atPath: outputURL.path) },
timeout: TestConstants.longTimeout
))
await Task.yield()
#expect(context.privateFileSends.isEmpty)
#expect(context.broadcastFileSends.isEmpty)
#expect(context.systemMessages.isEmpty)
}
@Test @MainActor
func imagePreparation_doesNotRetainCoordinatorOrDeallocatedContext() async throws {
let sourceURL = try makeCoordinatorTestImageURL()
let outputURL = FileManager.default.temporaryDirectory
.appendingPathComponent("released-context-\(UUID().uuidString).jpg")
let preparer = PausedImagePreparer(outputURL: outputURL)
var context: MockChatMediaTransferContext? = MockChatMediaTransferContext()
var coordinator: ChatMediaTransferCoordinator? = ChatMediaTransferCoordinator(
context: context!,
prepareImagePacket: { sourceURL in
try preparer.prepare(sourceURL)
}
)
weak var weakContext: MockChatMediaTransferContext?
weak var weakCoordinator: ChatMediaTransferCoordinator?
weakContext = context
weakCoordinator = coordinator
defer {
preparer.release()
try? FileManager.default.removeItem(at: sourceURL)
try? FileManager.default.removeItem(at: outputURL)
}
coordinator?.sendImage(from: sourceURL)
#expect(await TestHelpers.waitUntil(
{ preparer.hasStarted },
timeout: TestConstants.longTimeout
))
coordinator = nil
context = nil
#expect(weakCoordinator == nil)
#expect(weakContext == nil)
preparer.release()
#expect(await TestHelpers.waitUntil(
{ preparer.hasFinished },
timeout: TestConstants.longTimeout
))
#expect(await TestHelpers.waitUntil(
{ !FileManager.default.fileExists(atPath: outputURL.path) },
timeout: TestConstants.longTimeout
))
}
@Test @MainActor
func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws {
let context = MockChatMediaTransferContext()
@@ -207,3 +320,91 @@ struct ChatMediaTransferCoordinatorContextTests {
#expect(coordinator.transferIdToMessageIDs.isEmpty)
}
}
private final class PausedImagePreparer: @unchecked Sendable {
private let condition = NSCondition()
private let outputURL: URL
private var started = false
private var released = false
private var finished = false
init(outputURL: URL) {
self.outputURL = outputURL
}
var hasStarted: Bool {
condition.lock()
defer { condition.unlock() }
return started
}
var hasFinished: Bool {
condition.lock()
defer { condition.unlock() }
return finished
}
func prepare(_ _: URL) throws -> ChatPreparedImage {
condition.lock()
started = true
condition.broadcast()
while !released {
condition.wait()
}
condition.unlock()
let data = Data("prepared image".utf8)
try data.write(to: outputURL, options: .atomic)
let packet = BitchatFilePacket(
fileName: outputURL.lastPathComponent,
fileSize: UInt64(data.count),
mimeType: "image/jpeg",
content: data
)
condition.lock()
finished = true
condition.broadcast()
condition.unlock()
return ChatPreparedImage(outputURL: outputURL, packet: packet)
}
func release() {
condition.lock()
released = true
condition.broadcast()
condition.unlock()
}
}
private func makeCoordinatorTestImageURL() throws -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("coordinator-image-\(UUID().uuidString).png")
#if os(iOS)
let image = UIGraphicsImageRenderer(size: CGSize(width: 16, height: 16))
.image { context in
UIColor.systemBlue.setFill()
context.fill(CGRect(x: 0, y: 0, width: 16, height: 16))
}
guard let data = image.pngData() else {
throw CoordinatorImageTestError.encodingFailed
}
#else
let image = NSImage(size: NSSize(width: 16, height: 16))
image.lockFocus()
NSColor.systemBlue.setFill()
NSRect(x: 0, y: 0, width: 16, height: 16).fill()
image.unlockFocus()
guard let tiff = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiff),
let data = bitmap.representation(using: .png, properties: [:]) else {
throw CoordinatorImageTestError.encodingFailed
}
#endif
try data.write(to: url, options: .atomic)
return url
}
private enum CoordinatorImageTestError: Error {
case encodingFailed
}
+164 -3
View File
@@ -15,8 +15,13 @@ import BitFoundation
/// Creates a ChatViewModel with mock dependencies for testing
@MainActor
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
let keychain = MockKeychain()
private func makeTestableViewModel(
keychain injectedKeychain: MockKeychain? = nil,
panicMediaWipe: (() throws -> Void)? = nil,
panicRecoveryOperations: PanicRecoveryOperations? = nil,
panicNetworkLifecycle: PanicNetworkLifecycle = .noop
) -> (viewModel: ChatViewModel, transport: MockTransport) {
let keychain = injectedKeychain ?? MockKeychain()
let keychainHelper = MockKeychainHelper()
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
let identityManager = MockIdentityManager(keychain)
@@ -26,7 +31,10 @@ private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: Mo
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
transport: transport
transport: transport,
panicMediaWipe: panicMediaWipe,
panicRecoveryOperations: panicRecoveryOperations,
panicNetworkLifecycle: panicNetworkLifecycle
)
return (viewModel, transport)
@@ -1116,6 +1124,159 @@ struct ChatViewModelBluetoothTests {
struct ChatViewModelPanicTests {
@Test @MainActor
func panicClearAllData_finishesMediaWipeBeforeReturning() {
var wipeFinished = false
let (viewModel, _) = makeTestableViewModel(panicMediaWipe: {
wipeFinished = true
})
viewModel.panicClearAllData()
#expect(wipeFinished)
}
@Test @MainActor
func panicClearAllData_stopsNetworkBeforeWipeAndRestartsAfterCommit() {
var events: [String] = []
let lifecycle = PanicNetworkLifecycle(
stop: { events.append("stop") },
restart: { events.append("restart") }
)
let (viewModel, _) = makeTestableViewModel(
panicMediaWipe: { events.append("wipe") },
panicNetworkLifecycle: lifecycle
)
let completed = viewModel.panicClearAllData()
#expect(completed)
#expect(events == ["stop", "wipe", "restart"])
#expect(viewModel.networkActivationAllowed)
}
@Test @MainActor
func panicKeychainFailureKeepsRecoveryPendingAndServicesStopped() {
let keychain = MockKeychain()
keychain.simulatedDeleteAllResult = false
var events: [String] = []
let operations = PanicRecoveryOperations(
isPending: { false },
begin: {
events.append("begin")
return PanicRecoveryIntent(
fileMarkerEstablished: true,
externalMarkerEstablished: false
)
},
wipeMedia: { _ in events.append("wipe") },
complete: { events.append("complete") }
)
let lifecycle = PanicNetworkLifecycle(
stop: { events.append("stop") },
restart: { events.append("restart") }
)
let (viewModel, transport) = makeTestableViewModel(
keychain: keychain,
panicRecoveryOperations: operations,
panicNetworkLifecycle: lifecycle
)
let startsBeforePanic = transport.startServicesCallCount
let completed = viewModel.panicClearAllData()
#expect(!completed)
#expect(events == ["stop", "begin", "wipe"])
#expect(keychain.deleteAllCallCount == 1)
#expect(transport.startServicesCallCount == startsBeforePanic)
#expect(!viewModel.networkActivationAllowed)
}
@Test @MainActor
func pendingPanicRecoveryCompletesBeforeTransportBootstrap() {
var events: [String] = []
let operations = PanicRecoveryOperations(
isPending: {
events.append("read")
return true
},
begin: {
events.append("begin")
return PanicRecoveryIntent(
fileMarkerEstablished: true,
externalMarkerEstablished: false
)
},
wipeMedia: { _ in events.append("wipe") },
complete: { events.append("complete") }
)
let (viewModel, transport) = makeTestableViewModel(
panicRecoveryOperations: operations
)
#expect(events == ["read", "begin", "wipe", "complete"])
#expect(transport.emergencyDisconnectCallCount == 1)
#expect(transport.startServicesCallCount == 1)
#expect(viewModel.networkActivationAllowed)
}
@Test @MainActor
func failedStartupRecoveryLeavesTransportAndNetworkBlocked() {
enum WipeFailure: Error { case failed }
var completedMarker = false
let operations = PanicRecoveryOperations(
isPending: { true },
begin: {
PanicRecoveryIntent(
fileMarkerEstablished: true,
externalMarkerEstablished: false
)
},
wipeMedia: { _ in throw WipeFailure.failed },
complete: { completedMarker = true }
)
let (viewModel, transport) = makeTestableViewModel(
panicRecoveryOperations: operations
)
#expect(!completedMarker)
#expect(transport.emergencyDisconnectCallCount == 1)
#expect(transport.startServicesCallCount == 0)
#expect(!viewModel.networkActivationAllowed)
}
@Test @MainActor
func failedStartupKeychainRecoveryLeavesIntentAndTransportBlocked() {
let keychain = MockKeychain()
keychain.simulatedDeleteAllResult = false
var events: [String] = []
let operations = PanicRecoveryOperations(
isPending: { true },
begin: {
events.append("begin")
return PanicRecoveryIntent(
fileMarkerEstablished: true,
externalMarkerEstablished: true
)
},
wipeMedia: { _ in events.append("wipe") },
complete: { events.append("complete") }
)
let (viewModel, transport) = makeTestableViewModel(
keychain: keychain,
panicRecoveryOperations: operations
)
#expect(events == ["begin", "wipe"])
#expect(keychain.deleteAllCallCount == 1)
#expect(transport.emergencyDisconnectCallCount == 1)
#expect(transport.startServicesCallCount == 0)
#expect(!viewModel.networkActivationAllowed)
}
@Test @MainActor
func panicClearAllData_delegatesToTransport() async {
let (viewModel, transport) = makeTestableViewModel()
+4
View File
@@ -18,6 +18,8 @@ final class MockKeychain: KeychainManagerProtocol {
var simulatedReadError: KeychainReadResult?
var simulatedSaveError: KeychainSaveResult?
var simulatedGenericReadError: KeychainReadResult?
var simulatedDeleteAllResult = true
private(set) var deleteAllCallCount = 0
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
storage[key] = keyData
@@ -34,6 +36,8 @@ final class MockKeychain: KeychainManagerProtocol {
}
func deleteAllKeychainData() -> Bool {
deleteAllCallCount += 1
guard simulatedDeleteAllResult else { return false }
storage.removeAll()
serviceStorage.removeAll()
return true
@@ -1,10 +1,103 @@
import Foundation
import Security
import Testing
import BitFoundation
@testable import bitchat
@Suite("PreviewKeychainManager Tests")
struct PreviewKeychainManagerTests {
@Test("Install lifecycle distinguishes upgrade, reinstall, bootstrap, and unreadable keychain")
func installLifecycleDecision() {
#expect(KeychainManager.installLifecycleAction(
containerKnowsMarker: true,
markerRead: .success(Data([1]))
) == .markerPresent)
#expect(KeychainManager.installLifecycleAction(
containerKnowsMarker: false,
markerRead: .success(Data([1]))
) == .clearStaleKeys)
#expect(KeychainManager.installLifecycleAction(
containerKnowsMarker: false,
markerRead: .itemNotFound
) == .bootstrapMarker)
#expect(KeychainManager.installLifecycleAction(
containerKnowsMarker: false,
markerRead: .deviceLocked
) == .retryLater)
#expect(KeychainManager.installLifecycleAction(
containerKnowsMarker: false,
cleanupPending: true,
markerRead: .itemNotFound
) == .clearStaleKeys)
}
@Test("Accessibility migration covers custom services and retries after any incomplete update")
func accessibilityMigrationCoversEveryApplicationOwnedService() {
let primaryService = "chat.bitchat.test-primary"
var visitedServices: [String] = []
let completed = KeychainManager
.migrateAccessibilityForApplicationOwnedServices(
primaryService: primaryService
) { service in
visitedServices.append(service)
return service == "chat.bitchat.favorites"
? errSecInteractionNotAllowed
: errSecItemNotFound
}
#expect(!completed)
#expect(visitedServices.first == primaryService)
#expect(Set(visitedServices).isSuperset(of: [
"chat.bitchat.nostr",
"chat.bitchat.favorites",
"chat.bitchat.outbox"
]))
#expect(Set(visitedServices).count == visitedServices.count)
let retryCompleted = KeychainManager
.migrateAccessibilityForApplicationOwnedServices(
primaryService: primaryService
) { _ in errSecSuccess }
#expect(retryCompleted)
}
@Test("Keychain cleanup is complete only when every owned scope is clean")
func keychainCleanupRequiresEveryApplicationOwnedService() {
let primaryService = "chat.bitchat.test-primary"
var visitedServices: [String] = []
let partialCleanup = KeychainManager
.deleteApplicationOwnedKeychainServices(
primaryService: primaryService
) { service in
visitedServices.append(service)
return service == "chat.bitchat.outbox"
? errSecInteractionNotAllowed
: errSecSuccess
}
#expect(!partialCleanup)
#expect(visitedServices.first == primaryService)
#expect(Set(visitedServices).isSuperset(of: [
"chat.bitchat.nostr",
"chat.bitchat.favorites",
"chat.bitchat.outbox"
]))
#expect(Set(visitedServices).count == visitedServices.count)
let emptyCleanup = KeychainManager
.deleteApplicationOwnedKeychainServices(
primaryService: primaryService
) { _ in errSecItemNotFound }
#expect(emptyCleanup)
#expect(KeychainManager.completedApplicationGroupDelete(status: -34018))
#expect(!KeychainManager.completedApplicationGroupDelete(
status: errSecInteractionNotAllowed
))
}
@Test("Preview keychain manager stores identity and service-scoped data in memory")
func previewKeychainManagerRoundTripsData() {
let manager = PreviewKeychainManager()
@@ -51,4 +144,132 @@ struct PreviewKeychainManagerTests {
Issue.record("Expected preview keychain to be empty after deleteAllKeychainData")
}
}
@Test("Failed reinstall cleanup blocks stale data until a successful retry")
func failedReinstallCleanupBlocksEveryNamespaceUntilSuccessfulRetry() {
let gate = KeychainInstallAccessGate()
var cleanupCanComplete = false
var reconciliationAttempts = 0
var manager: PreviewKeychainManager!
manager = PreviewKeychainManager(
installAccessGate: gate
) {
reconciliationAttempts += 1
guard cleanupCanComplete else { return false }
return manager.deleteAllKeychainData()
}
let staleIdentity = Data([1, 2, 3])
let staleFavorite = Data([4, 5, 6])
let staleOutbox = Data([7, 8, 9])
let staleCustom = Data([10, 11, 12])
#expect(manager.saveIdentityKey(
staleIdentity,
forKey: "noiseStaticKey"
))
#expect(manager.saveIdentityKey(
staleIdentity,
forKey: "identity_noiseStaticKey"
))
#expect(manager.verifyIdentityKeyExists())
manager.save(
key: "favorite",
data: staleFavorite,
service: "chat.bitchat.favorites",
accessible: nil
)
manager.save(
key: "outbox",
data: staleOutbox,
service: "chat.bitchat.outbox",
accessible: nil
)
manager.save(
key: "custom",
data: staleCustom,
service: "chat.bitchat.future-custom",
accessible: nil
)
gate.block()
#expect(manager.getIdentityKey(forKey: "noiseStaticKey") == nil)
#expect(!manager.verifyIdentityKeyExists())
if case .accessDenied = manager.getIdentityKeyWithResult(
forKey: "noiseStaticKey"
) {
} else {
Issue.record("Expected blocked identity read to fail closed")
}
for (key, service) in [
("favorite", "chat.bitchat.favorites"),
("outbox", "chat.bitchat.outbox"),
("custom", "chat.bitchat.future-custom")
] {
#expect(manager.load(key: key, service: service) == nil)
if case .accessDenied = manager.loadWithResult(
key: key,
service: service
) {
} else {
Issue.record(
"Expected blocked \(service) read to fail closed"
)
}
}
#expect(!manager.saveIdentityKey(
Data([13]),
forKey: "replacement"
))
if case .accessDenied = manager.saveIdentityKeyWithResult(
Data([14]),
forKey: "replacement"
) {
} else {
Issue.record("Expected blocked identity save to fail closed")
}
let failedAttempts = reconciliationAttempts
#expect(failedAttempts > 0)
cleanupCanComplete = true
// The first access retries cleanup synchronously. It must not return
// any surviving value from before the reinstall.
#expect(manager.getIdentityKey(forKey: "noiseStaticKey") == nil)
#expect(reconciliationAttempts == failedAttempts + 1)
#expect(manager.load(
key: "favorite",
service: "chat.bitchat.favorites"
) == nil)
#expect(manager.load(
key: "outbox",
service: "chat.bitchat.outbox"
) == nil)
#expect(manager.load(
key: "custom",
service: "chat.bitchat.future-custom"
) == nil)
let replacementIdentity = Data([21, 22, 23])
let replacementCustom = Data([24, 25, 26])
#expect(manager.saveIdentityKey(
replacementIdentity,
forKey: "noiseStaticKey"
))
#expect(manager.getIdentityKey(
forKey: "noiseStaticKey"
) == replacementIdentity)
manager.save(
key: "custom",
data: replacementCustom,
service: "chat.bitchat.future-custom",
accessible: nil
)
#expect(manager.load(
key: "custom",
service: "chat.bitchat.future-custom"
) == replacementCustom)
}
}
@@ -370,6 +370,132 @@ struct BLEFileTransferHandlerTests {
#expect(!FileManager.default.fileExists(atPath: evictable.path))
}
@Test
func panicWipeDeletesEveryManagedMediaFileAndRecreatesEmptyDirectories() throws {
let base = FileManager.default.temporaryDirectory
.appendingPathComponent("panic-media-wipe-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: base) }
let store = BLEIncomingFileStore(baseDirectory: base)
let subdirectories = [
"voicenotes/incoming",
"voicenotes/outgoing",
"images/incoming",
"images/outgoing",
"files/incoming",
"files/outgoing"
]
for subdirectory in subdirectories {
let directory = base
.appendingPathComponent("files", isDirectory: true)
.appendingPathComponent(subdirectory, isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try Data("secret".utf8).write(to: directory.appendingPathComponent("artifact.bin"))
}
let unmanaged = base.appendingPathComponent("files/legacy/secret.bin")
try FileManager.default.createDirectory(at: unmanaged.deletingLastPathComponent(), withIntermediateDirectories: true)
try Data("legacy".utf8).write(to: unmanaged)
try store.panicWipe()
#expect(!FileManager.default.fileExists(atPath: unmanaged.path))
for subdirectory in subdirectories {
let directory = base
.appendingPathComponent("files", isDirectory: true)
.appendingPathComponent(subdirectory, isDirectory: true)
var isDirectory: ObjCBool = false
#expect(FileManager.default.fileExists(atPath: directory.path, isDirectory: &isDirectory))
#expect(isDirectory.boolValue)
#expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty)
}
}
@Test
func panicWipeAttemptsDeletionWhenMarkerPersistenceFails() throws {
enum MarkerFailure: Error { case unavailable }
let base = FileManager.default.temporaryDirectory
.appendingPathComponent(
"panic-marker-failure-\(UUID().uuidString)",
isDirectory: true
)
defer { try? FileManager.default.removeItem(at: base) }
let secret = base
.appendingPathComponent("files/images/outgoing", isDirectory: true)
.appendingPathComponent("secret.jpg")
try FileManager.default.createDirectory(
at: secret.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try Data("secret".utf8).write(to: secret)
let store = BLEIncomingFileStore(
baseDirectory: base,
panicMarkerWriter: { _, _ in throw MarkerFailure.unavailable }
)
do {
try store.panicWipe(hasDurablePendingMarker: false)
Issue.record("Expected the missing durable marker to fail closed")
} catch {
// The marker error is reported only after the deletion attempt.
}
#expect(!FileManager.default.fileExists(atPath: secret.path))
#expect(
FileManager.default.fileExists(
atPath: secret.deletingLastPathComponent().path
)
)
}
@Test
func externalMarkerAllowsDeletionToCommitWhenFileMarkerFails() throws {
enum MarkerFailure: Error { case unavailable }
let base = FileManager.default.temporaryDirectory
.appendingPathComponent(
"panic-external-marker-\(UUID().uuidString)",
isDirectory: true
)
defer { try? FileManager.default.removeItem(at: base) }
let secret = base
.appendingPathComponent("files/voicenotes/incoming", isDirectory: true)
.appendingPathComponent("secret.m4a")
try FileManager.default.createDirectory(
at: secret.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try Data("secret".utf8).write(to: secret)
let store = BLEIncomingFileStore(
baseDirectory: base,
panicMarkerWriter: { _, _ in throw MarkerFailure.unavailable }
)
try store.panicWipe(hasDurablePendingMarker: true)
#expect(!FileManager.default.fileExists(atPath: secret.path))
}
@Test
func panicRecoveryMarkerPersistsUntilExplicitCommit() throws {
let base = FileManager.default.temporaryDirectory
.appendingPathComponent(
"panic-recovery-marker-\(UUID().uuidString)",
isDirectory: true
)
defer { try? FileManager.default.removeItem(at: base) }
let store = BLEIncomingFileStore(baseDirectory: base)
try store.markPanicRecoveryPending()
#expect(try store.isPanicRecoveryPending())
try store.panicWipe(hasDurablePendingMarker: true)
#expect(try store.isPanicRecoveryPending())
try store.completePanicRecovery()
#expect(try !store.isPanicRecoveryPending())
}
private func expectNoSideEffects(_ recorder: Recorder) {
#expect(recorder.signedNameQueries.isEmpty)
#expect(recorder.trackedPackets.isEmpty)
@@ -76,6 +76,7 @@ final class GeohashPresenceServiceTests: XCTestCase {
burstMaxDelay: 0
)
service.start()
service.performHeartbeat()
let sentAllAllowedChannels = await waitUntil { sentGeohashes.count == 3 }
@@ -83,7 +84,7 @@ final class GeohashPresenceServiceTests: XCTestCase {
XCTAssertEqual(Set(sentGeohashes), Set(["9q", "9q8y", "9q8yy"]))
XCTAssertEqual(Set(lookedUpGeohashes), Set(["9q", "9q8y", "9q8yy"]))
XCTAssertEqual(sleptNanoseconds.count, 3)
XCTAssertEqual(scheduler.intervals, [17])
XCTAssertEqual(scheduler.intervals, [17, 17])
}
func test_performHeartbeat_skipsBroadcastWhenTorIsNotReady() async {
@@ -97,11 +98,12 @@ final class GeohashPresenceServiceTests: XCTestCase {
loopMaxInterval: 21
)
service.start()
service.performHeartbeat()
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(sendCount, 0)
XCTAssertEqual(scheduler.intervals, [21])
XCTAssertEqual(scheduler.intervals, [21, 21])
}
func test_performHeartbeat_skipsBroadcastWhenAppIsBackgrounded() async {
@@ -115,11 +117,45 @@ final class GeohashPresenceServiceTests: XCTestCase {
loopMaxInterval: 22
)
service.start()
service.performHeartbeat()
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(sendCount, 0)
XCTAssertEqual(scheduler.intervals, [22])
XCTAssertEqual(scheduler.intervals, [22, 22])
}
func test_stopForPanic_cancelsTimerAndSuppressesDelayedBroadcast() async throws {
let identity = try NostrIdentity.generate()
let scheduler = MockGeohashPresenceScheduler()
var sleeperContinuation: CheckedContinuation<Void, Never>?
var sendCount = 0
let service = makeService(
scheduler: scheduler,
deriveIdentity: { _ in identity },
relaySender: { _, _ in sendCount += 1 },
sleeper: { _ in
await withCheckedContinuation { continuation in
sleeperContinuation = continuation
}
},
burstMinDelay: 1,
burstMaxDelay: 1
)
service.start()
service.performHeartbeat()
let delayStarted = await waitUntil {
sleeperContinuation != nil
}
XCTAssertTrue(delayStarted)
service.stopForPanic()
sleeperContinuation?.resume()
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(sendCount, 0)
XCTAssertEqual(scheduler.timers.first?.invalidateCallCount, 1)
}
func test_broadcastPresence_skipsSendWhenNoRelaysAreAvailable() async throws {
@@ -91,6 +91,53 @@ final class NetworkActivationServiceTests: XCTestCase {
XCTAssertGreaterThanOrEqual(context.relayController.connectCallCount, 1)
}
func test_stopForPanic_synchronouslyStopsAndIgnoresPublisherUpdates() async {
let context = makeService(permission: .authorized, favorites: [])
context.service.start()
context.service.stopForPanic()
let connectCountAfterStop = context.relayController.connectCallCount
let startCountAfterStop = context.torController.startIfNeededCallCount
context.favoritesSubject.send([Data([0x01])])
context.reachability.set(false)
context.reachability.set(true)
try? await Task.sleep(nanoseconds: 30_000_000)
XCTAssertFalse(context.service.activationAllowed)
XCTAssertEqual(context.reachability.stopCallCount, 1)
XCTAssertEqual(context.torController.autoStartAllowedValues.last, false)
XCTAssertEqual(context.proxyController.proxyModes.last, false)
XCTAssertGreaterThanOrEqual(
context.torController.shutdownCompletelyCallCount,
1
)
XCTAssertGreaterThanOrEqual(
context.relayController.disconnectCallCount,
1
)
XCTAssertEqual(
context.relayController.connectCallCount,
connectCountAfterStop
)
XCTAssertEqual(
context.torController.startIfNeededCallCount,
startCountAfterStop
)
}
func test_start_afterPanicStop_reestablishesSubscriptions() {
let context = makeService(permission: .authorized, favorites: [])
context.service.start()
context.service.stopForPanic()
context.service.start()
XCTAssertTrue(context.service.activationAllowed)
XCTAssertEqual(context.reachability.startCallCount, 2)
XCTAssertEqual(context.relayController.connectCallCount, 2)
}
private func makeService(
permission: LocationChannelManager.PermissionState,
favorites: Set<Data>
@@ -104,6 +151,7 @@ final class NetworkActivationServiceTests: XCTestCase {
let torController = MockNetworkActivationTorController()
let relayController = MockNetworkActivationRelayController()
let proxyController = MockNetworkActivationProxyController()
let reachability = MockNetworkActivationReachability()
let notificationCenter = NotificationCenter()
let service = NetworkActivationService(
storage: storage,
@@ -111,7 +159,7 @@ final class NetworkActivationServiceTests: XCTestCase {
mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(),
permissionProvider: { permissionSubject.value },
mutualFavoritesProvider: { favoritesSubject.value },
reachabilityMonitor: AlwaysReachableMonitor(),
reachabilityMonitor: reachability,
torController: torController,
relayController: relayController,
proxyController: proxyController,
@@ -121,6 +169,7 @@ final class NetworkActivationServiceTests: XCTestCase {
service: service,
storage: storage,
favoritesSubject: favoritesSubject,
reachability: reachability,
torController: torController,
relayController: relayController,
proxyController: proxyController,
@@ -148,12 +197,38 @@ private struct NetworkActivationTestContext {
let service: NetworkActivationService
let storage: UserDefaults
let favoritesSubject: CurrentValueSubject<Set<Data>, Never>
let reachability: MockNetworkActivationReachability
let torController: MockNetworkActivationTorController
let relayController: MockNetworkActivationRelayController
let proxyController: MockNetworkActivationProxyController
let notificationCenter: NotificationCenter
}
@MainActor
private final class MockNetworkActivationReachability:
NetworkReachabilityMonitoring {
private let subject = CurrentValueSubject<Bool, Never>(true)
private(set) var startCallCount = 0
private(set) var stopCallCount = 0
var isReachable: Bool { subject.value }
var reachabilityPublisher: AnyPublisher<Bool, Never> {
subject.removeDuplicates().dropFirst().eraseToAnyPublisher()
}
func start() {
startCallCount += 1
}
func stop() {
stopCallCount += 1
}
func set(_ reachable: Bool) {
subject.send(reachable)
}
}
@MainActor
private final class MockNetworkActivationTorController: NetworkActivationTorControlling {
private(set) var autoStartAllowedValues: [Bool] = []
@@ -213,6 +213,7 @@ private final class ControllableReachabilityMonitor: NetworkReachabilityMonitori
subject.removeDuplicates().dropFirst().eraseToAnyPublisher()
}
func start() { startCalled = true }
func stop() { startCalled = false }
func set(_ reachable: Bool) { subject.send(reachable) }
}
@@ -61,6 +61,7 @@ private final class GatedVoiceCaptureSession: VoiceCaptureSession {
private let startError: Error?
private(set) var finishStarted = false
private(set) var cancelCount = 0
private(set) var panicCancelCount = 0
private var finishContinuation: CheckedContinuation<URL?, Never>?
init(startError: Error? = nil) {
@@ -83,6 +84,10 @@ private final class GatedVoiceCaptureSession: VoiceCaptureSession {
cancelCount += 1
}
func panicCancelSynchronously() {
panicCancelCount += 1
}
func resolveFinish(with url: URL?) {
let continuation = finishContinuation
finishContinuation = nil
@@ -204,4 +209,57 @@ struct VoiceCaptureSessionTests {
}
#expect(viewModel.state == .idle)
}
@Test func panicSynchronouslyCancelsActiveCaptureAndResetsUI() async {
let session = GatedVoiceCaptureSession()
let viewModel = VoiceRecordingViewModel()
viewModel.sessionProvider = { session }
viewModel.start(shouldShow: true)
await waitUntil { self.isRecording(viewModel.state) }
viewModel.panicWipe()
#expect(session.panicCancelCount == 1)
#expect(viewModel.state == .idle)
#expect(!viewModel.isLiveStreaming)
}
@Test func panicInvalidatesARecordingAlreadyFinalizing() async throws {
let session = GatedVoiceCaptureSession()
let viewModel = VoiceRecordingViewModel()
viewModel.sessionProvider = { session }
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("voice-panic-\(UUID().uuidString).m4a")
try Data([0x01]).write(to: url)
var delivered = false
viewModel.start(shouldShow: true)
await waitUntil { self.isRecording(viewModel.state) }
viewModel.finish { _ in delivered = true }
await waitUntil { session.finishStarted }
viewModel.panicWipe()
session.resolveFinish(with: url)
await waitUntil {
!FileManager.default.fileExists(atPath: url.path)
}
#expect(!delivered)
#expect(viewModel.state == .idle)
}
@Test func liveSessionPanicStopsCaptureWithoutSendingControl() {
let capture = StubPTTCapture(stopResult: (nil, 0))
var sentPackets: [Data] = []
let session = PTTLiveVoiceSession(
sendPacket: { sentPackets.append($0) },
capture: capture
)
session.panicCancelSynchronously()
#expect(capture.cancelCount == 1)
#expect(sentPackets.isEmpty)
}
}
+29
View File
@@ -360,6 +360,35 @@ struct VoiceRecorderTests {
#expect(FileManager.default.fileExists(atPath: secondURL.path))
}
@Test func classicSessionPanicStopsRecorderAndDeletesFileBeforeReturning() async throws {
let directory = try makeTemporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let rawSession = VoiceRecorderTestSession()
let coordinator = AudioSessionCoordinator(session: rawSession)
let factory = TestVoiceAudioRecorderFactory(plans: [.success])
let voiceRecorder = VoiceRecorder(
sessionCoordinator: coordinator,
recorderFactory: factory,
permissionGranted: { true },
paddingInterval: 0,
outputDirectory: directory
)
let capture = VoiceNoteCaptureSession(recorder: voiceRecorder)
try await capture.start()
let url = try #require(factory.urls.first)
let recorder = try #require(factory.recorders.first)
capture.panicCancelSynchronously()
#expect(recorder.stopCallCount == 1)
#expect(!recorder.isRecording)
#expect(!FileManager.default.fileExists(atPath: url.path))
await coordinator.drain()
#expect(rawSession.activationCalls == [true, false])
}
private func verifyFailedStart(
firstPlan: TestVoiceAudioRecorderFactory.Plan,
expectedPrepareCalls: Int,