From 4091a30f105e7a8339954bf91192f1d5c86dd4a6 Mon Sep 17 00:00:00 2001 From: jack Date: Mon, 12 Jan 2026 15:17:09 -1000 Subject: [PATCH] BCH-01-002: Switch from pending files to disk quota management The original PR introduced a PendingFileManager that held incoming files in memory until user acceptance. However, this approach had issues: 1. No UI was implemented for users to accept/decline files 2. Files would expire after 5 minutes, losing legitimate transfers 3. The app only allows specific media types (photos, audio) that users explicitly choose to send, so manual acceptance adds friction This commit replaces the pending file system with disk quota management: - Auto-save files immediately (preserving original UX) - Enforce 100 MB storage quota for incoming files - Auto-delete oldest files when quota is exceeded - Logs cleanup activity for visibility This directly addresses the DoS vulnerability (disk exhaustion from file spam) while maintaining good UX for legitimate transfers. Co-Authored-By: Claude Opus 4.5 --- bitchat/Protocols/BitchatProtocol.swift | 7 - bitchat/Services/BLE/BLEService.swift | 152 ++++++--- bitchat/Services/PendingFileManager.swift | 262 -------------- bitchat/ViewModels/ChatViewModel.swift | 44 --- .../Fragmentation/FragmentationTests.swift | 82 +---- bitchatTests/PendingFileManagerTests.swift | 320 ------------------ 6 files changed, 116 insertions(+), 751 deletions(-) delete mode 100644 bitchat/Services/PendingFileManager.swift delete mode 100644 bitchatTests/PendingFileManagerTests.swift 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") - } -}