mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
//
|
||||
// PendingFileManager.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Void, Never>?
|
||||
private var receivedMessageContinuation: CheckedContinuation<Void, Never>?
|
||||
private var pendingFileContinuation: CheckedContinuation<Void, Never>?
|
||||
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]) {}
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
//
|
||||
// PendingFileManagerTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user