diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index 217942a0..f9e692af 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -179,9 +179,6 @@ 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 @@ -201,8 +198,4 @@ 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 b7c4707b..05288f4f 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -575,9 +575,6 @@ final class BLEService: NSObject { subscribedCentrals.removeAll() centralToPeerID.removeAll() meshTopology.reset() - - // BCH-01-002: Clear pending files held in memory - PendingFileManager.shared.clearAll() } // MARK: Connectivity and peers @@ -1158,30 +1155,63 @@ final class BLEService: NSObject { return } + // BCH-01-002: Enforce storage quota before saving + enforceIncomingFilesQuota(reservingBytes: filePacket.content.count) + + 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) } - // 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 - } + 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 + ) - SecureLogger.debug("📁 Queued pending file from \(peerID.id.prefix(8))… (\(filePacket.content.count) bytes, id: \(pending.id.prefix(8))…)", category: .session) + SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session) notifyUI { [weak self] in - self?.delegate?.didReceivePendingFileTransfer(pending) + self?.delegate?.didReceiveMessage(message) } } @@ -1363,38 +1393,70 @@ final class BLEService: NSObject { } } - // MARK: - Pending File Management (BCH-01-002) + // MARK: - Storage Quota 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 } + /// Maximum total storage for incoming files (100 MB) + private static let incomingFilesQuota: Int64 = 100 * 1024 * 1024 - 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" + /// Enforces storage quota for incoming files by deleting oldest files when quota is exceeded. + /// Call before saving a new incoming file. + private func enforceIncomingFilesQuota(reservingBytes: Int) { + do { + let base = try applicationFilesDirectory() + let incomingDirs = [ + base.appendingPathComponent("voicenotes/incoming", isDirectory: true), + base.appendingPathComponent("images/incoming", isDirectory: true), + base.appendingPathComponent("files/incoming", isDirectory: true) + ] + + // Gather all incoming files with their sizes and modification dates + var allFiles: [(url: URL, size: Int64, modified: Date)] = [] + let fileManager = FileManager.default + + for dir in incomingDirs { + guard fileManager.fileExists(atPath: dir.path) else { continue } + guard let contents = try? fileManager.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey], + options: [.skipsHiddenFiles] + ) else { continue } + + for fileURL in contents { + guard let attrs = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]), + let size = attrs.fileSize, + let modified = attrs.contentModificationDate else { continue } + allFiles.append((url: fileURL, size: Int64(size), modified: modified)) + } } - return self.saveIncomingFile( - data: pending.content, - preferredName: pending.fileName, - subdirectory: subdirectory, - fallbackExtension: mime?.defaultExtension, - defaultPrefix: mime?.category.rawValue ?? "file" - ) - } - } + // Calculate current usage + let currentUsage = allFiles.reduce(0) { $0 + $1.size } + let targetUsage = Self.incomingFilesQuota - Int64(reservingBytes) - /// Declines a pending file without saving to disk. - func declinePendingFile(id: String) { - PendingFileManager.shared.declineFile(id: id) + guard currentUsage > targetUsage else { return } + + // Sort by modification date (oldest first) and delete until under quota + let sortedFiles = allFiles.sorted { $0.modified < $1.modified } + var freedSpace: Int64 = 0 + let needToFree = currentUsage - targetUsage + + for file in sortedFiles { + guard freedSpace < needToFree else { break } + do { + try fileManager.removeItem(at: file.url) + freedSpace += file.size + SecureLogger.debug("🗑️ BCH-01-002: Deleted old incoming file to free space: \(file.url.lastPathComponent)", category: .security) + } catch { + SecureLogger.warning("⚠️ Failed to delete old file for quota: \(error)", category: .security) + } + } + + if freedSpace > 0 { + SecureLogger.info("📊 BCH-01-002: Freed \(ByteCountFormatter.string(fromByteCount: freedSpace, countStyle: .file)) to stay within incoming files quota", category: .security) + } + } catch { + SecureLogger.warning("⚠️ Could not enforce storage quota: \(error)", category: .security) + } } private func sendLeave() { diff --git a/bitchat/Services/PendingFileManager.swift b/bitchat/Services/PendingFileManager.swift deleted file mode 100644 index bbeacbf9..00000000 --- a/bitchat/Services/PendingFileManager.swift +++ /dev/null @@ -1,262 +0,0 @@ -// -// 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. - /// Note: File is only removed from queue after successful save to prevent data loss. - func acceptFile(id: String, saveHandler: (PendingFileTransfer) -> URL?) -> URL? { - // First, get the pending file without removing it - let pending = queue.sync { () -> PendingFileTransfer? in - pendingFiles[id] - } - - guard let pending = pending else { - SecureLogger.warning("Cannot accept file \(id.prefix(8))...: not found in pending queue", category: .session) - return nil - } - - // Attempt to save the file - guard let url = saveHandler(pending) else { - // Save failed - keep the file in queue so user can retry - SecureLogger.error("Failed to save accepted file \(id.prefix(8))... - keeping in queue for retry", category: .session) - return nil - } - - // Only remove from queue after successful save - _ = queue.sync(flags: .barrier) { - pendingFiles.removeValue(forKey: id) - } - - 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/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 6fb448ed..39aff8df 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -3218,50 +3218,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv } } - // MARK: - Pending File Transfers (BCH-01-002) - - func didReceivePendingFileTransfer(_ pending: PendingFileTransfer) { - Task { @MainActor in - // Check if sender is blocked - if blockedUsers.contains(pending.senderNickname) { - SecureLogger.debug("Ignoring pending file from blocked user: \(pending.senderNickname)", category: .session) - PendingFileManager.shared.declineFile(id: pending.id) - return - } - - // Create a system message to notify user about the pending file - let fileDescription = "\(pending.displayName) (\(ByteCountFormatter.string(fromByteCount: Int64(pending.fileSize), countStyle: .file)))" - let message = BitchatMessage( - id: "pending-\(pending.id)", - sender: pending.senderNickname, - content: "[Pending file: \(fileDescription)] - Accept or decline in file transfers", - timestamp: pending.timestamp, - isRelay: false, - originalSender: nil, - isPrivate: pending.isPrivate, - recipientNickname: pending.isPrivate ? nickname : nil, - senderPeerID: pending.senderPeerID, - mentions: nil - ) - - // Route to appropriate chat - if pending.isPrivate { - handlePrivateMessage(message) - } else { - handlePublicMessage(message) - } - - // Send notification - NotificationService.shared.sendLocalNotification( - title: "Incoming file from \(pending.senderNickname)", - body: fileDescription, - identifier: "pending-file-\(pending.id)" - ) - - SecureLogger.debug("📁 Notified UI of pending file: \(pending.id.prefix(8))... from \(pending.senderNickname)", category: .session) - } - } - // MARK: - Peer Connection Events func didConnectToPeer(_ peerID: PeerID) { diff --git a/bitchatTests/Fragmentation/FragmentationTests.swift b/bitchatTests/Fragmentation/FragmentationTests.swift index ceec40dc..e507fe25 100644 --- a/bitchatTests/Fragmentation/FragmentationTests.swift +++ b/bitchatTests/Fragmentation/FragmentationTests.swift @@ -134,25 +134,18 @@ struct FragmentationTests { } } - // 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)) + try await sleep(1.0) - 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") + let message = try #require(capture.receivedMessages.first, "Expected file transfer message") + #expect(message.content.hasPrefix("[file]")) - // 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") + 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) } - - // Clean up pending files - PendingFileManager.shared.clearAll() } @Test("Invalid fragment header is ignored") @@ -207,13 +200,10 @@ 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() @@ -227,12 +217,6 @@ extension FragmentationTests { return _receivedMessages } - var pendingFiles: [PendingFileTransfer] { - lock.lock() - defer { lock.unlock() } - return _pendingFiles - } - func didReceiveMessage(_ message: BitchatMessage) { lock.lock() _receivedMessages.append(message) @@ -249,22 +233,6 @@ 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)) @@ -349,38 +317,6 @@ 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 deleted file mode 100644 index a59fddfc..00000000 --- a/bitchatTests/PendingFileManagerTests.swift +++ /dev/null @@ -1,320 +0,0 @@ -// -// 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("acceptFile keeps file in queue on save failure") - func acceptFile_keepsFileOnSaveFailure() { - 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) - - // Simulate save failure by returning nil - let resultURL = manager.acceptFile(id: id) { _ in - return nil // Save failed - } - - #expect(resultURL == nil) - #expect(manager.stats.count == 1) // Still in queue for retry - #expect(manager.getPendingFile(id: id) != nil) // Can still retrieve it - } - - @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") - } -}