From 4b3077169a9e4fd498b22681588ebec0f9bdbc53 Mon Sep 17 00:00:00 2001 From: jack Date: Mon, 12 Jan 2026 10:39:19 -1000 Subject: [PATCH] fix BCH-01-002: prevent DoS via file storage exhaustion Incoming files are now held in memory via PendingFileManager instead of being auto-saved to disk. Users must explicitly accept files before they are written. This prevents attackers from exhausting device storage. Key changes: - Add PendingFileManager with configurable limits (max 10 files, 5MB total) - Files auto-expire after 5 minutes if not accepted - LRU eviction when limits are exceeded - Pending files cleared during panic mode (emergencyDisconnectAll) - Add didReceivePendingFileTransfer delegate method - Add acceptPendingFile/declinePendingFile to Transport protocol Security audit reference: Cure53 BCH-01-002 (Medium severity) Co-Authored-By: Claude Opus 4.5 --- bitchat/Protocols/BitchatProtocol.swift | 7 + bitchat/Services/BLE/BLEService.swift | 103 ++++--- bitchat/Services/PendingFileManager.swift | 253 +++++++++++++++ bitchat/Services/Transport.swift | 7 + .../Fragmentation/FragmentationTests.swift | 82 ++++- bitchatTests/PendingFileManagerTests.swift | 288 ++++++++++++++++++ 6 files changed, 683 insertions(+), 57 deletions(-) create mode 100644 bitchat/Services/PendingFileManager.swift create mode 100644 bitchatTests/PendingFileManagerTests.swift diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index f9e692af..217942a0 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -179,6 +179,9 @@ protocol BitchatDelegate: AnyObject { // Bluetooth state updates for user notifications func didUpdateBluetoothState(_ state: CBManagerState) func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) + + // Pending file transfer notification (file held in memory until user accepts) + func didReceivePendingFileTransfer(_ pending: PendingFileTransfer) } // Provide default implementation to make it effectively optional @@ -198,4 +201,8 @@ extension BitchatDelegate { func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) { // Default empty implementation } + + func didReceivePendingFileTransfer(_ pending: PendingFileTransfer) { + // Default empty implementation + } } diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 2d5f0642..b7c4707b 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -550,7 +550,7 @@ final class BLEService: NSObject { func emergencyDisconnectAll() { stopServices() - + // Clear all sessions and peers let cancelledTransfers: [(id: String, items: [DispatchWorkItem])] = collectionsQueue.sync(flags: .barrier) { let entries = activeTransfers.map { ($0.key, $0.value.workItems) } @@ -565,16 +565,19 @@ final class BLEService: NSObject { entry.items.forEach { $0.cancel() } TransferProgressManager.shared.cancel(id: entry.id) } - + // Clear processed messages messageDeduplicator.reset() - + // Clear peripheral references peripherals.removeAll() peerToPeripheralUUID.removeAll() subscribedCentrals.removeAll() centralToPeerID.removeAll() meshTopology.reset() + + // BCH-01-002: Clear pending files held in memory + PendingFileManager.shared.clearAll() } // MARK: Connectivity and peers @@ -1155,60 +1158,30 @@ final class BLEService: NSObject { return } - let fallbackExt = mime.defaultExtension - let subdirectory: String - switch mime.category { - case .audio: - subdirectory = "voicenotes/incoming" - case .image: - subdirectory = "images/incoming" - case .file: - subdirectory = "files/incoming" - } - - guard let destination = saveIncomingFile( - data: filePacket.content, - preferredName: filePacket.fileName, - subdirectory: subdirectory, - fallbackExtension: fallbackExt, - defaultPrefix: mime.category.rawValue - ) else { - return - } - - let marker: String - let fileName = destination.lastPathComponent - switch mime.category { - case .audio: - marker = "[voice] \(fileName)" - case .image: - marker = "[image] \(fileName)" - case .file: - marker = "[file] \(fileName)" - } - let isPrivateMessage = PeerID(hexData: packet.recipientID) == myPeerID if isPrivateMessage { updatePeerLastSeen(peerID) } - let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) - let message = BitchatMessage( - sender: senderNickname, - content: marker, - timestamp: ts, - isRelay: false, - originalSender: nil, - isPrivate: isPrivateMessage, - recipientNickname: nil, - senderPeerID: peerID - ) + // BCH-01-002 Fix: Don't auto-save files to disk. Hold in memory as pending until user accepts. + // This prevents DoS via storage exhaustion from malicious peers flooding with files. + guard let pending = PendingFileManager.shared.addPendingFile( + senderPeerID: peerID, + senderNickname: senderNickname, + fileName: filePacket.fileName, + mimeType: filePacket.mimeType, + content: filePacket.content, + isPrivate: isPrivateMessage + ) else { + SecureLogger.warning("🚫 Rejected file transfer: pending file limits exceeded", category: .security) + return + } - SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session) + SecureLogger.debug("📁 Queued pending file from \(peerID.id.prefix(8))… (\(filePacket.content.count) bytes, id: \(pending.id.prefix(8))…)", category: .session) notifyUI { [weak self] in - self?.delegate?.didReceiveMessage(message) + self?.delegate?.didReceivePendingFileTransfer(pending) } } @@ -1390,6 +1363,40 @@ final class BLEService: NSObject { } } + // MARK: - Pending File Management (BCH-01-002) + + /// Accepts a pending file and saves it to disk. + /// Returns the saved file URL, or nil if the file was not found or save failed. + func acceptPendingFile(id: String) -> URL? { + return PendingFileManager.shared.acceptFile(id: id) { [weak self] pending in + guard let self = self else { return nil } + + let mime = MimeType(pending.mimeType) + let subdirectory: String + switch mime?.category ?? .file { + case .audio: + subdirectory = "voicenotes/incoming" + case .image: + subdirectory = "images/incoming" + case .file: + subdirectory = "files/incoming" + } + + return self.saveIncomingFile( + data: pending.content, + preferredName: pending.fileName, + subdirectory: subdirectory, + fallbackExtension: mime?.defaultExtension, + defaultPrefix: mime?.category.rawValue ?? "file" + ) + } + } + + /// Declines a pending file without saving to disk. + func declinePendingFile(id: String) { + PendingFileManager.shared.declineFile(id: id) + } + private func sendLeave() { SecureLogger.debug("👋 Sending leave announcement", category: .session) let packet = BitchatPacket( diff --git a/bitchat/Services/PendingFileManager.swift b/bitchat/Services/PendingFileManager.swift new file mode 100644 index 00000000..9f8f5b95 --- /dev/null +++ b/bitchat/Services/PendingFileManager.swift @@ -0,0 +1,253 @@ +// +// PendingFileManager.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import BitLogger + +/// Represents a file transfer that has been received but not yet accepted by the user. +/// Files are held in memory until explicitly accepted, preventing DoS via storage exhaustion. +struct PendingFileTransfer: Identifiable { + let id: String + let senderPeerID: PeerID + let senderNickname: String + let fileName: String? + let mimeType: String? + let content: Data + let timestamp: Date + let isPrivate: Bool + + var fileSize: Int { content.count } + + var displayName: String { + if let name = fileName, !name.isEmpty { + return name + } + let ext = MimeType(mimeType)?.defaultExtension ?? "bin" + return "file.\(ext)" + } + + var category: MimeType.Category { + MimeType(mimeType)?.category ?? .file + } +} + +/// Manages pending file transfers with configurable limits to prevent memory exhaustion. +/// Files must be explicitly accepted before being written to disk. +final class PendingFileManager { + + /// Shared instance for app-wide pending file management + static let shared = PendingFileManager() + + /// Configuration for pending file limits + struct Config { + /// Maximum number of pending files allowed + let maxPendingCount: Int + /// Maximum total size of all pending files in bytes + let maxTotalBytes: Int + /// Maximum age before automatic expiration (seconds) + let expirationSeconds: TimeInterval + + static let `default` = Config( + maxPendingCount: 10, + maxTotalBytes: 5 * 1024 * 1024, // 5 MiB total + expirationSeconds: 300 // 5 minutes + ) + } + + private let config: Config + private let queue = DispatchQueue(label: "chat.bitchat.pendingfiles", attributes: .concurrent) + private var pendingFiles: [String: PendingFileTransfer] = [:] + private var expirationTimer: Timer? + + /// Callback when a pending file is added + var onPendingFileAdded: ((PendingFileTransfer) -> Void)? + /// Callback when a pending file expires or is removed + var onPendingFileRemoved: ((String) -> Void)? + + init(config: Config = .default) { + self.config = config + startExpirationTimer() + } + + deinit { + expirationTimer?.invalidate() + } + + // MARK: - Public API + + /// Adds a file to the pending queue. Returns the pending file ID if successful, nil if rejected. + /// Files may be rejected if limits are exceeded. + @discardableResult + func addPendingFile( + senderPeerID: PeerID, + senderNickname: String, + fileName: String?, + mimeType: String?, + content: Data, + isPrivate: Bool + ) -> PendingFileTransfer? { + let id = UUID().uuidString + let pending = PendingFileTransfer( + id: id, + senderPeerID: senderPeerID, + senderNickname: senderNickname, + fileName: fileName, + mimeType: mimeType, + content: content, + timestamp: Date(), + isPrivate: isPrivate + ) + + return queue.sync(flags: .barrier) { [self] in + // Check count limit + if pendingFiles.count >= config.maxPendingCount { + SecureLogger.warning("Pending file rejected: max count (\(config.maxPendingCount)) reached", category: .security) + // Remove oldest file to make room + if let oldest = pendingFiles.values.min(by: { $0.timestamp < $1.timestamp }) { + pendingFiles.removeValue(forKey: oldest.id) + SecureLogger.debug("Evicted oldest pending file \(oldest.id.prefix(8))... to make room", category: .session) + } + } + + // Check total size limit + let currentTotal = pendingFiles.values.reduce(0) { $0 + $1.fileSize } + if currentTotal + content.count > config.maxTotalBytes { + SecureLogger.warning("Pending file rejected: would exceed max total size (\(config.maxTotalBytes) bytes)", category: .security) + // Try to evict old files to make room + var evictedSize = 0 + let sortedByAge = pendingFiles.values.sorted { $0.timestamp < $1.timestamp } + for old in sortedByAge { + if currentTotal - evictedSize + content.count <= config.maxTotalBytes { + break + } + pendingFiles.removeValue(forKey: old.id) + evictedSize += old.fileSize + SecureLogger.debug("Evicted pending file \(old.id.prefix(8))... (\(old.fileSize) bytes) for space", category: .session) + } + + // Check again after eviction + let newTotal = pendingFiles.values.reduce(0) { $0 + $1.fileSize } + if newTotal + content.count > config.maxTotalBytes { + SecureLogger.warning("Cannot accept pending file even after eviction", category: .security) + return nil + } + } + + pendingFiles[id] = pending + SecureLogger.debug("Added pending file \(id.prefix(8))... from \(senderPeerID.id.prefix(8))... (\(content.count) bytes)", category: .session) + + DispatchQueue.main.async { [weak self] in + self?.onPendingFileAdded?(pending) + } + + return pending + } + } + + /// Retrieves a pending file by ID + func getPendingFile(id: String) -> PendingFileTransfer? { + queue.sync { + pendingFiles[id] + } + } + + /// Retrieves all pending files + func getAllPendingFiles() -> [PendingFileTransfer] { + queue.sync { + Array(pendingFiles.values).sorted { $0.timestamp > $1.timestamp } + } + } + + /// Accepts a pending file, saving it to disk and removing from pending queue. + /// Returns the saved file URL, or nil if the file was not found or save failed. + func acceptFile(id: String, saveHandler: (PendingFileTransfer) -> URL?) -> URL? { + let pending = queue.sync(flags: .barrier) { () -> PendingFileTransfer? in + pendingFiles.removeValue(forKey: id) + } + + guard let pending = pending else { + SecureLogger.warning("Cannot accept file \(id.prefix(8))...: not found in pending queue", category: .session) + return nil + } + + guard let url = saveHandler(pending) else { + SecureLogger.error("Failed to save accepted file \(id.prefix(8))...", category: .session) + return nil + } + + SecureLogger.debug("Accepted and saved pending file \(id.prefix(8))... to \(url.lastPathComponent)", category: .session) + return url + } + + /// Declines/removes a pending file without saving + func declineFile(id: String) { + queue.sync(flags: .barrier) { + if let removed = pendingFiles.removeValue(forKey: id) { + SecureLogger.debug("Declined pending file \(id.prefix(8))... from \(removed.senderNickname)", category: .session) + } + } + DispatchQueue.main.async { [weak self] in + self?.onPendingFileRemoved?(id) + } + } + + /// Clears all pending files (e.g., for panic mode) + func clearAll() { + let ids = queue.sync(flags: .barrier) { () -> [String] in + let ids = Array(pendingFiles.keys) + pendingFiles.removeAll() + return ids + } + SecureLogger.debug("Cleared \(ids.count) pending files", category: .session) + for id in ids { + DispatchQueue.main.async { [weak self] in + self?.onPendingFileRemoved?(id) + } + } + } + + /// Returns current statistics + var stats: (count: Int, totalBytes: Int) { + queue.sync { + (pendingFiles.count, pendingFiles.values.reduce(0) { $0 + $1.fileSize }) + } + } + + // MARK: - Private + + private func startExpirationTimer() { + DispatchQueue.main.async { [weak self] in + self?.expirationTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in + self?.expireOldFiles() + } + } + } + + private func expireOldFiles() { + let now = Date() + let expiredIDs = queue.sync(flags: .barrier) { () -> [String] in + var expired: [String] = [] + for (id, pending) in pendingFiles { + if now.timeIntervalSince(pending.timestamp) > config.expirationSeconds { + expired.append(id) + pendingFiles.removeValue(forKey: id) + } + } + return expired + } + + if !expiredIDs.isEmpty { + SecureLogger.debug("Expired \(expiredIDs.count) pending files", category: .session) + for id in expiredIDs { + DispatchQueue.main.async { [weak self] in + self?.onPendingFileRemoved?(id) + } + } + } + } +} diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index f357e4f3..323cfb3a 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -58,6 +58,10 @@ protocol Transport: AnyObject { // QR verification (optional for transports) func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) + + // Pending file management (BCH-01-002: files held in memory until user accepts) + func acceptPendingFile(id: String) -> URL? + func declinePendingFile(id: String) } extension Transport { @@ -70,6 +74,9 @@ extension Transport { func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) { sendMessage(content, mentions: mentions) } + + func acceptPendingFile(id: String) -> URL? { nil } + func declinePendingFile(id: String) {} } protocol TransportPeerEventsDelegate: AnyObject { diff --git a/bitchatTests/Fragmentation/FragmentationTests.swift b/bitchatTests/Fragmentation/FragmentationTests.swift index e507fe25..ceec40dc 100644 --- a/bitchatTests/Fragmentation/FragmentationTests.swift +++ b/bitchatTests/Fragmentation/FragmentationTests.swift @@ -134,18 +134,25 @@ struct FragmentationTests { } } - try await sleep(1.0) + // BCH-01-002: Files are now held as pending until user accepts (no auto-save to disk) + try await capture.waitForPendingFiles(count: 1, timeout: .seconds(2)) - let message = try #require(capture.receivedMessages.first, "Expected file transfer message") - #expect(message.content.hasPrefix("[file]")) + let pending = try #require(capture.pendingFiles.first, "Expected pending file transfer") + #expect(pending.mimeType == "application/octet-stream") + #expect(pending.fileSize == FileTransferLimits.maxPayloadBytes) + #expect(pending.fileName == "limit.bin") - if let fileName = message.content.split(separator: " ").last { - let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) - let filesRoot = base.appendingPathComponent("files", isDirectory: true) - let incoming = filesRoot.appendingPathComponent("files/incoming", isDirectory: true) - let url = incoming.appendingPathComponent(String(fileName)) - try? FileManager.default.removeItem(at: url) + // Verify file was NOT written to disk (the whole point of BCH-01-002 fix) + let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + let filesRoot = base.appendingPathComponent("files", isDirectory: true) + let incoming = filesRoot.appendingPathComponent("files/incoming", isDirectory: true) + if FileManager.default.fileExists(atPath: incoming.path) { + let files = try FileManager.default.contentsOfDirectory(atPath: incoming.path) + #expect(files.isEmpty, "Files should NOT be auto-saved to disk before user acceptance") } + + // Clean up pending files + PendingFileManager.shared.clearAll() } @Test("Invalid fragment header is ignored") @@ -200,10 +207,13 @@ extension FragmentationTests { private let lock = NSLock() private var _publicMessages: [(peerID: PeerID, nickname: String, content: String)] = [] private var _receivedMessages: [BitchatMessage] = [] + private var _pendingFiles: [PendingFileTransfer] = [] private var publicMessageContinuation: CheckedContinuation? private var receivedMessageContinuation: CheckedContinuation? + private var pendingFileContinuation: CheckedContinuation? private var expectedPublicMessageCount: Int = 0 private var expectedReceivedMessageCount: Int = 0 + private var expectedPendingFileCount: Int = 0 var publicMessages: [(peerID: PeerID, nickname: String, content: String)] { lock.lock() @@ -217,6 +227,12 @@ extension FragmentationTests { return _receivedMessages } + var pendingFiles: [PendingFileTransfer] { + lock.lock() + defer { lock.unlock() } + return _pendingFiles + } + func didReceiveMessage(_ message: BitchatMessage) { lock.lock() _receivedMessages.append(message) @@ -233,6 +249,22 @@ extension FragmentationTests { } } + func didReceivePendingFileTransfer(_ pending: PendingFileTransfer) { + lock.lock() + _pendingFiles.append(pending) + let count = _pendingFiles.count + let expected = expectedPendingFileCount + let continuation = pendingFileContinuation + lock.unlock() + + if count >= expected, let cont = continuation { + lock.lock() + pendingFileContinuation = nil + lock.unlock() + cont.resume() + } + } + func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) { lock.lock() _publicMessages.append((peerID, nickname, content)) @@ -317,6 +349,38 @@ extension FragmentationTests { } } + /// Waits for the specified number of pending files to be received + func waitForPendingFiles(count: Int, timeout: Duration = .seconds(2)) async throws { + lock.lock() + if _pendingFiles.count >= count { + lock.unlock() + return + } + expectedPendingFileCount = count + lock.unlock() + + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + await withCheckedContinuation { continuation in + self.lock.lock() + if self._pendingFiles.count >= count { + self.lock.unlock() + continuation.resume() + return + } + self.pendingFileContinuation = continuation + self.lock.unlock() + } + } + group.addTask { + try await Task.sleep(for: timeout) + throw CancellationError() + } + try await group.next() + group.cancelAll() + } + } + func didConnectToPeer(_ peerID: PeerID) {} func didDisconnectFromPeer(_ peerID: PeerID) {} func didUpdatePeerList(_ peers: [PeerID]) {} diff --git a/bitchatTests/PendingFileManagerTests.swift b/bitchatTests/PendingFileManagerTests.swift new file mode 100644 index 00000000..873617fc --- /dev/null +++ b/bitchatTests/PendingFileManagerTests.swift @@ -0,0 +1,288 @@ +// +// PendingFileManagerTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +@testable import bitchat + +/// Tests for BCH-01-002 fix: PendingFileManager prevents DoS via storage exhaustion +struct PendingFileManagerTests { + + @Test("addPendingFile stores file in memory") + func addPendingFile_storesInMemory() { + let manager = PendingFileManager(config: .default) + defer { manager.clearAll() } + + let content = Data(repeating: 0x42, count: 1024) + let pending = manager.addPendingFile( + senderPeerID: PeerID(str: "AABBCCDD11223344"), + senderNickname: "TestUser", + fileName: "test.bin", + mimeType: "application/octet-stream", + content: content, + isPrivate: false + ) + + #expect(pending != nil) + #expect(pending?.fileSize == 1024) + #expect(pending?.fileName == "test.bin") + #expect(manager.stats.count == 1) + #expect(manager.stats.totalBytes == 1024) + } + + @Test("getPendingFile retrieves stored file") + func getPendingFile_retrievesStoredFile() { + let manager = PendingFileManager(config: .default) + defer { manager.clearAll() } + + let content = Data(repeating: 0x42, count: 512) + let pending = manager.addPendingFile( + senderPeerID: PeerID(str: "AABBCCDD11223344"), + senderNickname: "TestUser", + fileName: "test.bin", + mimeType: "image/png", + content: content, + isPrivate: false + ) + + guard let id = pending?.id else { + Issue.record("Failed to add pending file") + return + } + + let retrieved = manager.getPendingFile(id: id) + #expect(retrieved != nil) + #expect(retrieved?.content == content) + } + + @Test("declineFile removes file from queue") + func declineFile_removesFromQueue() { + let manager = PendingFileManager(config: .default) + defer { manager.clearAll() } + + let content = Data(repeating: 0x42, count: 256) + let pending = manager.addPendingFile( + senderPeerID: PeerID(str: "AABBCCDD11223344"), + senderNickname: "TestUser", + fileName: "test.bin", + mimeType: "application/octet-stream", + content: content, + isPrivate: false + ) + + guard let id = pending?.id else { + Issue.record("Failed to add pending file") + return + } + + #expect(manager.stats.count == 1) + manager.declineFile(id: id) + #expect(manager.stats.count == 0) + #expect(manager.getPendingFile(id: id) == nil) + } + + @Test("clearAll removes all pending files") + func clearAll_removesAllFiles() { + let manager = PendingFileManager(config: .default) + + for i in 0..<5 { + _ = manager.addPendingFile( + senderPeerID: PeerID(str: "AABBCCDD1122334\(i)"), + senderNickname: "User\(i)", + fileName: "file\(i).bin", + mimeType: "application/octet-stream", + content: Data(repeating: UInt8(i), count: 100), + isPrivate: false + ) + } + + #expect(manager.stats.count == 5) + manager.clearAll() + #expect(manager.stats.count == 0) + } + + @Test("count limit evicts oldest files") + func countLimit_evictsOldestFiles() { + let config = PendingFileManager.Config( + maxPendingCount: 3, + maxTotalBytes: 1_000_000, + expirationSeconds: 300 + ) + let manager = PendingFileManager(config: config) + defer { manager.clearAll() } + + // Add 3 files (at limit) + var ids: [String] = [] + for i in 0..<3 { + if let pending = manager.addPendingFile( + senderPeerID: PeerID(str: "AABBCCDD1122334\(i)"), + senderNickname: "User\(i)", + fileName: "file\(i).bin", + mimeType: "application/octet-stream", + content: Data(repeating: UInt8(i), count: 100), + isPrivate: false + ) { + ids.append(pending.id) + } + } + + #expect(manager.stats.count == 3) + + // Add 4th file - should evict oldest + let fourth = manager.addPendingFile( + senderPeerID: PeerID(str: "AABBCCDD11223349"), + senderNickname: "User9", + fileName: "file9.bin", + mimeType: "application/octet-stream", + content: Data(repeating: 0x99, count: 100), + isPrivate: false + ) + + #expect(fourth != nil) + #expect(manager.stats.count == 3) // Still at limit + #expect(manager.getPendingFile(id: ids[0]) == nil) // Oldest evicted + #expect(manager.getPendingFile(id: ids[1]) != nil) // Second still exists + } + + @Test("size limit evicts files to make room") + func sizeLimit_evictsFilesToMakeRoom() { + let config = PendingFileManager.Config( + maxPendingCount: 100, + maxTotalBytes: 500, // Very small limit for testing + expirationSeconds: 300 + ) + let manager = PendingFileManager(config: config) + defer { manager.clearAll() } + + // Add 2 files totaling 400 bytes (under limit) + let first = manager.addPendingFile( + senderPeerID: PeerID(str: "AABBCCDD11223340"), + senderNickname: "User0", + fileName: "file0.bin", + mimeType: "application/octet-stream", + content: Data(repeating: 0x00, count: 200), + isPrivate: false + ) + + _ = manager.addPendingFile( + senderPeerID: PeerID(str: "AABBCCDD11223341"), + senderNickname: "User1", + fileName: "file1.bin", + mimeType: "application/octet-stream", + content: Data(repeating: 0x01, count: 200), + isPrivate: false + ) + + #expect(manager.stats.count == 2) + #expect(manager.stats.totalBytes == 400) + + // Add 300-byte file - needs to evict to fit + let third = manager.addPendingFile( + senderPeerID: PeerID(str: "AABBCCDD11223342"), + senderNickname: "User2", + fileName: "file2.bin", + mimeType: "application/octet-stream", + content: Data(repeating: 0x02, count: 300), + isPrivate: false + ) + + #expect(third != nil) + // First file should be evicted to make room + #expect(manager.getPendingFile(id: first!.id) == nil) + #expect(manager.stats.totalBytes <= 500) + } + + @Test("acceptFile saves and removes from queue") + func acceptFile_savesAndRemovesFromQueue() { + let manager = PendingFileManager(config: .default) + defer { manager.clearAll() } + + let content = Data(repeating: 0x42, count: 128) + let pending = manager.addPendingFile( + senderPeerID: PeerID(str: "AABBCCDD11223344"), + senderNickname: "TestUser", + fileName: "test.bin", + mimeType: "application/octet-stream", + content: content, + isPrivate: false + ) + + guard let id = pending?.id else { + Issue.record("Failed to add pending file") + return + } + + #expect(manager.stats.count == 1) + + var savedURL: URL? + let resultURL = manager.acceptFile(id: id) { pending in + // Simulate saving - just return a fake URL for testing + savedURL = URL(fileURLWithPath: "/tmp/test-\(pending.id).bin") + return savedURL + } + + #expect(resultURL == savedURL) + #expect(manager.stats.count == 0) // Removed from queue + #expect(manager.getPendingFile(id: id) == nil) + } + + @Test("getAllPendingFiles returns sorted by timestamp descending") + func getAllPendingFiles_sortedByTimestampDescending() async throws { + let manager = PendingFileManager(config: .default) + defer { manager.clearAll() } + + // Add files with small delays to ensure different timestamps + for i in 0..<3 { + _ = manager.addPendingFile( + senderPeerID: PeerID(str: "AABBCCDD1122334\(i)"), + senderNickname: "User\(i)", + fileName: "file\(i).bin", + mimeType: "application/octet-stream", + content: Data(repeating: UInt8(i), count: 50), + isPrivate: false + ) + try await Task.sleep(for: .milliseconds(10)) + } + + let all = manager.getAllPendingFiles() + #expect(all.count == 3) + + // Should be sorted newest first + for i in 0..<(all.count - 1) { + #expect(all[i].timestamp >= all[i + 1].timestamp) + } + } + + @Test("displayName returns fileName or generates default") + func displayName_returnsFileNameOrGeneratesDefault() { + let manager = PendingFileManager(config: .default) + defer { manager.clearAll() } + + // With fileName + let withName = manager.addPendingFile( + senderPeerID: PeerID(str: "AABBCCDD11223344"), + senderNickname: "TestUser", + fileName: "custom.png", + mimeType: "image/png", + content: Data(repeating: 0x42, count: 100), + isPrivate: false + ) + #expect(withName?.displayName == "custom.png") + + // Without fileName - should use extension from MIME + let withoutName = manager.addPendingFile( + senderPeerID: PeerID(str: "AABBCCDD11223345"), + senderNickname: "TestUser2", + fileName: nil, + mimeType: "audio/mp3", + content: Data(repeating: 0x43, count: 100), + isPrivate: false + ) + #expect(withoutName?.displayName == "file.mp3") + } +}