mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 07:45:20 +00:00
Harden panic keychain and media cleanup
This commit is contained in:
@@ -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
|
||||
@@ -203,6 +208,99 @@ struct ChatMediaTransferCoordinatorContextTests {
|
||||
#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
|
||||
))
|
||||
|
||||
let delayedRelease = Task.detached(priority: .userInitiated) {
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
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)
|
||||
await delayedRelease.value
|
||||
|
||||
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()
|
||||
@@ -222,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(_ sourceURL: 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
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import Testing
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
@@ -24,6 +25,77 @@ struct PreviewKeychainManagerTests {
|
||||
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")
|
||||
@@ -72,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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user