From b896dd3f61db99d462fee819e2aed5812483c51a Mon Sep 17 00:00:00 2001 From: vekovius Date: Wed, 5 Aug 2026 12:43:44 -0500 Subject: [PATCH 1/3] Enforce MessageDeduplicator count bound --- bitchat/Utils/MessageDeduplicator.swift | 1 + bitchatTests/MessageDeduplicatorTests.swift | 28 +++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 bitchatTests/MessageDeduplicatorTests.swift diff --git a/bitchat/Utils/MessageDeduplicator.swift b/bitchat/Utils/MessageDeduplicator.swift index 215384fa..27909ad4 100644 --- a/bitchat/Utils/MessageDeduplicator.swift +++ b/bitchat/Utils/MessageDeduplicator.swift @@ -61,6 +61,7 @@ final class MessageDeduplicator { let now = Date() entries.append(Entry(id: id, timestamp: now)) lookup[id] = now + trimIfNeeded() } } diff --git a/bitchatTests/MessageDeduplicatorTests.swift b/bitchatTests/MessageDeduplicatorTests.swift new file mode 100644 index 00000000..6016d7a9 --- /dev/null +++ b/bitchatTests/MessageDeduplicatorTests.swift @@ -0,0 +1,28 @@ +// +// MessageDeduplicatorTests.swift +// bitchatTests +// +// Tests for MessageDeduplicator. +// This is free and unencumbered software released into the public domain. +// + +import Foundation +import Testing +@testable import bitchat + +@Suite("Message Deduplicator") +struct MessageDeduplicatorTests { + @Test func markProcessed_enforcesMaximumCount() { + let deduplicator = MessageDeduplicator(maxAge: 300, maxCount: 4) + + for id in ["a", "b", "c", "d", "e"] { + deduplicator.markProcessed(id) + } + + #expect(!deduplicator.contains("a")) + #expect(!deduplicator.contains("b")) + #expect(deduplicator.contains("c")) + #expect(deduplicator.contains("d")) + #expect(deduplicator.contains("e")) + } +} From fb5b1349364b9b9f1a88a6117eafcd990ca05ddf Mon Sep 17 00:00:00 2001 From: vekovius Date: Wed, 5 Aug 2026 16:21:40 -0500 Subject: [PATCH 2/3] Clean expired IDs before marked-entry trimming --- bitchat/Utils/MessageDeduplicator.swift | 4 +++- bitchatTests/MessageDeduplicatorTests.swift | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/bitchat/Utils/MessageDeduplicator.swift b/bitchat/Utils/MessageDeduplicator.swift index 27909ad4..d2fc489d 100644 --- a/bitchat/Utils/MessageDeduplicator.swift +++ b/bitchat/Utils/MessageDeduplicator.swift @@ -57,8 +57,10 @@ final class MessageDeduplicator { lock.lock() defer { lock.unlock() } + let now = Date() + cleanupOldEntries(before: now.addingTimeInterval(-maxAge)) + if lookup[id] == nil { - let now = Date() entries.append(Entry(id: id, timestamp: now)) lookup[id] = now trimIfNeeded() diff --git a/bitchatTests/MessageDeduplicatorTests.swift b/bitchatTests/MessageDeduplicatorTests.swift index 6016d7a9..6db1117d 100644 --- a/bitchatTests/MessageDeduplicatorTests.swift +++ b/bitchatTests/MessageDeduplicatorTests.swift @@ -25,4 +25,20 @@ struct MessageDeduplicatorTests { #expect(deduplicator.contains("d")) #expect(deduplicator.contains("e")) } + + @Test func markProcessed_cleansExpiredEntriesBeforeCountTrim() async { + let deduplicator = MessageDeduplicator(maxAge: 0.1, maxCount: 4) + + deduplicator.markProcessed("expired") + try? await Task.sleep(nanoseconds: 200_000_000) + + for id in ["b", "c", "d", "e"] { + deduplicator.markProcessed(id) + } + + #expect(deduplicator.contains("b")) + #expect(deduplicator.contains("c")) + #expect(deduplicator.contains("d")) + #expect(deduplicator.contains("e")) + } } From 97f94e7593e241207c0c318295c7df3aa893f439 Mon Sep 17 00:00:00 2001 From: vekovius Date: Fri, 14 Aug 2026 13:16:29 -0500 Subject: [PATCH 3/3] Make deduplicator expiry tests deterministic --- bitchat/Utils/MessageDeduplicator.swift | 14 ++++++++++---- bitchatTests/MessageDeduplicatorTests.swift | 18 ++++++++++++++---- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/bitchat/Utils/MessageDeduplicator.swift b/bitchat/Utils/MessageDeduplicator.swift index d2fc489d..66bab3c7 100644 --- a/bitchat/Utils/MessageDeduplicator.swift +++ b/bitchat/Utils/MessageDeduplicator.swift @@ -16,6 +16,7 @@ final class MessageDeduplicator { private let lock = NSLock() private let maxAge: TimeInterval private let maxCount: Int + private let dateProvider: () -> Date /// Initialize with default config from TransportConfig convenience init() { @@ -26,9 +27,14 @@ final class MessageDeduplicator { } /// Initialize with custom config for content deduplication - init(maxAge: TimeInterval, maxCount: Int) { + init( + maxAge: TimeInterval, + maxCount: Int, + dateProvider: @escaping () -> Date = Date.init + ) { self.maxAge = maxAge self.maxCount = maxCount + self.dateProvider = dateProvider } /// Check if message is duplicate and add if not. @@ -38,7 +44,7 @@ final class MessageDeduplicator { lock.lock() defer { lock.unlock() } - let now = Date() + let now = dateProvider() cleanupOldEntries(before: now.addingTimeInterval(-maxAge)) if lookup[id] != nil { @@ -57,7 +63,7 @@ final class MessageDeduplicator { lock.lock() defer { lock.unlock() } - let now = Date() + let now = dateProvider() cleanupOldEntries(before: now.addingTimeInterval(-maxAge)) if lookup[id] == nil { @@ -109,7 +115,7 @@ final class MessageDeduplicator { lock.lock() defer { lock.unlock() } - cleanupOldEntries(before: Date().addingTimeInterval(-maxAge)) + cleanupOldEntries(before: dateProvider().addingTimeInterval(-maxAge)) // Shrink capacity if significantly oversized if entries.capacity > maxCount * 2 && entries.count < maxCount { diff --git a/bitchatTests/MessageDeduplicatorTests.swift b/bitchatTests/MessageDeduplicatorTests.swift index 6db1117d..af7d29cc 100644 --- a/bitchatTests/MessageDeduplicatorTests.swift +++ b/bitchatTests/MessageDeduplicatorTests.swift @@ -13,7 +13,12 @@ import Testing @Suite("Message Deduplicator") struct MessageDeduplicatorTests { @Test func markProcessed_enforcesMaximumCount() { - let deduplicator = MessageDeduplicator(maxAge: 300, maxCount: 4) + let now = Date(timeIntervalSince1970: 1_000) + let deduplicator = MessageDeduplicator( + maxAge: 300, + maxCount: 4, + dateProvider: { now } + ) for id in ["a", "b", "c", "d", "e"] { deduplicator.markProcessed(id) @@ -26,11 +31,16 @@ struct MessageDeduplicatorTests { #expect(deduplicator.contains("e")) } - @Test func markProcessed_cleansExpiredEntriesBeforeCountTrim() async { - let deduplicator = MessageDeduplicator(maxAge: 0.1, maxCount: 4) + @Test func markProcessed_cleansExpiredEntriesBeforeCountTrim() { + var now = Date(timeIntervalSince1970: 1_000) + let deduplicator = MessageDeduplicator( + maxAge: 300, + maxCount: 4, + dateProvider: { now } + ) deduplicator.markProcessed("expired") - try? await Task.sleep(nanoseconds: 200_000_000) + now.addTimeInterval(301) for id in ["b", "c", "d", "e"] { deduplicator.markProcessed(id)