Compare commits

...
Author SHA1 Message Date
jackandGitHub 0aaa8cc28f Merge branch 'main' into fix/deduplication-service-thread-safety 2026-01-04 13:56:35 -10:00
jackandGitHub bc312e4aef Merge pull request #929 from permissionlesstech/fix/nostr-transport-thread-safety
fix: add thread safety to NostrTransport read receipt queue
2026-01-04 13:56:17 -10:00
jackandGitHub 6e7509f2be Merge branch 'main' into fix/nostr-transport-thread-safety 2026-01-04 13:52:31 -10:00
jackandGitHub ca06f4d51d Merge pull request #928 from permissionlesstech/fix/noise-dh-secret-clearing
fix: clear DH shared secrets after Noise handshake operations
2026-01-04 13:52:14 -10:00
jackandGitHub 507cb19b91 Merge branch 'main' into fix/deduplication-service-thread-safety 2026-01-04 13:42:17 -10:00
jackandGitHub 04f57e8713 Merge branch 'main' into fix/nostr-transport-thread-safety 2026-01-04 13:41:53 -10:00
jackandGitHub 59c3c4e236 Merge branch 'main' into fix/noise-dh-secret-clearing 2026-01-04 13:41:38 -10:00
jackandClaude Opus 4.5 6cc3a8cde7 fix: add @MainActor to MessageDeduplicationService for thread safety
Added @MainActor annotation to both LRUDeduplicationCache and
MessageDeduplicationService classes. This provides compile-time
enforcement of thread safety since all callers (ChatViewModel,
NostrRelayManager) are already on MainActor.

Benefits:
- Compile-time enforcement prevents future misuse
- Simpler than internal locking mechanisms
- Consistent with existing patterns in codebase

Also adds:
- @MainActor annotation to existing test suites
- 5 new concurrency tests verifying thread safety behavior

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:29:39 -10:00
jackandClaude Opus 4.5 e887e04f40 fix: add thread safety to NostrTransport read receipt queue
Synchronized access to readQueue and isSendingReadAcks using the
existing concurrent DispatchQueue with barrier flags:

- sendReadReceipt(): wrap enqueue in barrier async
- processReadQueueIfNeeded(): extract item within barrier context
- scheduleNextReadAck(): wrap callback in barrier async

This fixes race conditions where concurrent calls could corrupt the
read queue or cause check-then-act bugs on isSendingReadAcks.

Also adds thread safety tests:
- concurrentReadReceiptEnqueue: 100 concurrent enqueue operations
- readQueueProcessingUnderLoad: concurrent enqueue during processing
- isPeerReachableThreadSafety: concurrent read access test

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:29:20 -10:00
jackandClaude Opus 4.5 151b68a497 fix: clear DH shared secrets after Noise handshake operations
Added secureClear() calls for all 6 DH operations in NoiseProtocol.swift
to properly clear sensitive shared secrets from memory after use:

- writeMessage(): .es initiator/responder, .se initiator/responder
- performDHOperation(): .ee and .ss operations

This fixes a forward secrecy vulnerability where shared secrets could
persist in memory after handshake completion.

Also adds:
- TrackingMockKeychain to count secureClear calls in tests
- 3 new tests verifying secureClear is called during handshake

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:28:55 -10:00
jackandClaude Opus 4.5 b4a3ee5777 fix: add @MainActor to MessageDeduplicationService for thread safety
Added @MainActor annotation to both LRUDeduplicationCache and
MessageDeduplicationService classes. This provides compile-time
enforcement of thread safety since all callers (ChatViewModel,
NostrRelayManager) are already on MainActor.

Benefits:
- Compile-time enforcement prevents future misuse
- Simpler than internal locking mechanisms
- Consistent with existing patterns in codebase

Also adds:
- @MainActor annotation to existing test suites
- 5 new concurrency tests verifying thread safety behavior

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:25:43 -10:00
jackandClaude Opus 4.5 8d19d6d62c fix: add thread safety to NostrTransport read receipt queue
Synchronized access to readQueue and isSendingReadAcks using the
existing concurrent DispatchQueue with barrier flags:

- sendReadReceipt(): wrap enqueue in barrier async
- processReadQueueIfNeeded(): extract item within barrier context
- scheduleNextReadAck(): wrap callback in barrier async

This fixes race conditions where concurrent calls could corrupt the
read queue or cause check-then-act bugs on isSendingReadAcks.

Also adds thread safety tests:
- concurrentReadReceiptEnqueue: 100 concurrent enqueue operations
- readQueueProcessingUnderLoad: concurrent enqueue during processing
- isPeerReachableThreadSafety: concurrent read access test

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:23:32 -10:00
jackandClaude Opus 4.5 84fd92ef4b fix: clear DH shared secrets after Noise handshake operations
Added secureClear() calls for all 6 DH operations in NoiseProtocol.swift
to properly clear sensitive shared secrets from memory after use:

- writeMessage(): .es initiator/responder, .se initiator/responder
- performDHOperation(): .ee and .ss operations

This fixes a forward secrecy vulnerability where shared secrets could
persist in memory after handshake completion.

Also adds:
- TrackingMockKeychain to count secureClear calls in tests
- 3 new tests verifying secureClear is called during handshake

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:21:01 -10:00
jackandGitHub f71bd506fd Merge pull request #920 from malkovitc/feat/472-optimize-message-deduplicator
perf: optimize MessageDeduplicator performance
2026-01-04 12:41:48 -10:00
jackandGitHub ddd7ef5668 Merge branch 'main' into feat/472-optimize-message-deduplicator 2026-01-04 12:37:01 -10:00
jackandGitHub 548a20e77d Merge pull request #919 from malkovitc/fix/646-harden-hex-parsing
fix: harden hex string parsing
2026-01-04 12:33:16 -10:00
jackandGitHub 6e231d10c5 Merge branch 'main' into fix/646-harden-hex-parsing 2026-01-04 12:28:39 -10:00
jackandGitHub 4c9f6e689e Merge pull request #918 from malkovitc/fix/voice-recorder-simulator-build
fix(build): exclude allowBluetoothHFP on iOS Simulator
2026-01-04 12:25:11 -10:00
jackandGitHub 7e73b65240 Merge branch 'main' into fix/voice-recorder-simulator-build 2026-01-04 12:19:52 -10:00
jackandGitHub f5e5f7b98e Merge pull request #916 from malkovitc/fix/797-consolidate-keychain
refactor: consolidate KeychainHelper into KeychainManager
2026-01-04 12:13:15 -10:00
evgeniy.chernomortsev b15d92ebb5 perf: optimize MessageDeduplicator performance (#472)
- Make Entry struct Equatable for better testability
- Remove down to 75% of maxCount instead of fixed 100 items for better
  amortization of cleanup cost
- Reuse Date instance to reduce allocations in isDuplicate()
- Add documentation comments for public methods
- Improve memory capacity management in cleanup()
2025-12-09 18:48:14 +04:00
evgeniy.chernomortsev 6efe9d02fb fix: harden hex string parsing (#646)
Improve Data(hexString:) to handle edge cases:
- Reject odd-length strings (return nil)
- Support optional 0x/0X prefix
- Trim leading/trailing whitespace
- Handle empty strings correctly

Add comprehensive unit tests for hex parsing.
2025-12-09 15:01:59 +04:00
evgeniy.chernomortsev 5a66f03400 fix(build): exclude allowBluetoothHFP on iOS Simulator
allowBluetoothHFP is not available on iOS Simulator, causing build
failures. Use conditional compilation to exclude this option when
building for simulator while keeping it for device builds.
2025-12-09 14:50:34 +04:00
evgeniy.chernomortsevandClaude Opus 4.5 a221b22691 refactor: consolidate KeychainHelper into KeychainManager (#797)
Merge KeychainHelper functionality into KeychainManager to provide
a single, unified API for all keychain operations.

- Remove KeychainHelper.swift and KeychainHelperProtocol
- Add generic save/load/delete methods to KeychainManagerProtocol
- Update NostrIdentityBridge to use KeychainManagerProtocol
- Update FavoritesPersistenceService to use KeychainManagerProtocol
- Update PreviewKeychainManager with new methods
- Update MockKeychain and add MockKeychainHelper typealias for backwards compatibility

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 14:42:55 +04:00
16 changed files with 809 additions and 127 deletions
@@ -58,11 +58,20 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
guard session.recordPermission == .granted else { guard session.recordPermission == .granted else {
throw RecorderError.microphoneAccessDenied throw RecorderError.microphoneAccessDenied
} }
#if targetEnvironment(simulator)
// allowBluetoothHFP is not available on iOS Simulator
try session.setCategory(
.playAndRecord,
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothA2DP]
)
#else
try session.setCategory( try session.setCategory(
.playAndRecord, .playAndRecord,
mode: .default, mode: .default,
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP] options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
) )
#endif
try session.setActive(true, options: .notifyOthersOnDeactivation) try session.setActive(true, options: .notifyOthersOnDeactivation)
#endif #endif
#if os(macOS) #if os(macOS)
+26 -8
View File
@@ -610,14 +610,20 @@ final class NoiseHandshakeState {
throw NoiseError.missingKeys throw NoiseError.missingKeys
} }
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic) let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic)
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) }) var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData)
// Clear sensitive shared secret
keychain.secureClear(&sharedData)
} else { } else {
guard let localStatic = localStaticPrivate, guard let localStatic = localStaticPrivate,
let remoteEphemeral = remoteEphemeralPublic else { let remoteEphemeral = remoteEphemeralPublic else {
throw NoiseError.missingKeys throw NoiseError.missingKeys
} }
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral) let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral)
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) }) var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData)
// Clear sensitive shared secret
keychain.secureClear(&sharedData)
} }
case .se: case .se:
@@ -628,14 +634,20 @@ final class NoiseHandshakeState {
throw NoiseError.missingKeys throw NoiseError.missingKeys
} }
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral) let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral)
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) }) var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData)
// Clear sensitive shared secret
keychain.secureClear(&sharedData)
} else { } else {
guard let localEphemeral = localEphemeralPrivate, guard let localEphemeral = localEphemeralPrivate,
let remoteStatic = remoteStaticPublic else { let remoteStatic = remoteStaticPublic else {
throw NoiseError.missingKeys throw NoiseError.missingKeys
} }
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic) let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic)
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) }) var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData)
// Clear sensitive shared secret
keychain.secureClear(&sharedData)
} }
case .ss: case .ss:
@@ -724,8 +736,11 @@ final class NoiseHandshakeState {
throw NoiseError.missingKeys throw NoiseError.missingKeys
} }
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteEphemeral) let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteEphemeral)
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) }) var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData)
// Clear sensitive shared secret
keychain.secureClear(&sharedData)
case .es: case .es:
if role == .initiator { if role == .initiator {
guard let localEphemeral = localEphemeralPrivate, guard let localEphemeral = localEphemeralPrivate,
@@ -778,8 +793,11 @@ final class NoiseHandshakeState {
throw NoiseError.missingKeys throw NoiseError.missingKeys
} }
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic) let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic)
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) }) var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData)
// Clear sensitive shared secret
keychain.secureClear(&sharedData)
case .e, .s: case .e, .s:
break break
} }
-50
View File
@@ -1,50 +0,0 @@
import Foundation
protocol KeychainHelperProtocol {
func save(key: String, data: Data, service: String, accessible: CFString?)
func load(key: String, service: String) -> Data?
func delete(key: String, service: String)
}
/// Keychain helper for secure storage
struct KeychainHelper: KeychainHelperProtocol {
func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
if let accessible = accessible {
query[kSecAttrAccessible as String] = accessible
}
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
}
func load(key: String, service: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else { return nil }
return result as? Data
}
func delete(key: String, service: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
}
+2 -2
View File
@@ -12,9 +12,9 @@ final class NostrIdentityBridge {
private var derivedIdentityCache: [String: NostrIdentity] = [:] private var derivedIdentityCache: [String: NostrIdentity] = [:]
private let cacheLock = NSLock() private let cacheLock = NSLock()
private let keychain: KeychainHelperProtocol private let keychain: KeychainManagerProtocol
init(keychain: KeychainHelperProtocol = KeychainHelper()) { init(keychain: KeychainManagerProtocol = KeychainManager()) {
self.keychain = keychain self.keychain = keychain
} }
+28 -6
View File
@@ -23,20 +23,42 @@ extension Data {
return digest.map { String(format: "%02x", $0) }.joined() return digest.map { String(format: "%02x", $0) }.joined()
} }
/// Initialize Data from a hex string.
/// - Parameter hexString: A hex string, optionally prefixed with "0x" or "0X".
/// Whitespace is trimmed. Must have even length after prefix removal.
/// - Returns: nil if the string has odd length or contains invalid hex characters.
init?(hexString: String) { init?(hexString: String) {
let len = hexString.count / 2 var hex = hexString.trimmingCharacters(in: .whitespaces)
// Remove optional 0x prefix
if hex.hasPrefix("0x") || hex.hasPrefix("0X") {
hex = String(hex.dropFirst(2))
}
// Reject odd-length strings
guard hex.count % 2 == 0 else {
return nil
}
// Reject empty strings
guard !hex.isEmpty else {
self = Data()
return
}
let len = hex.count / 2
var data = Data(capacity: len) var data = Data(capacity: len)
var index = hexString.startIndex var index = hex.startIndex
for _ in 0..<len { for _ in 0..<len {
let nextIndex = hexString.index(index, offsetBy: 2) let nextIndex = hex.index(index, offsetBy: 2)
guard let byte = UInt8(String(hexString[index..<nextIndex]), radix: 16) else { guard let byte = UInt8(String(hex[index..<nextIndex]), radix: 16) else {
return nil return nil
} }
data.append(byte) data.append(byte)
index = nextIndex index = nextIndex
} }
self = data self = data
} }
} }
@@ -26,7 +26,7 @@ final class FavoritesPersistenceService: ObservableObject {
private static let storageKey = "chat.bitchat.favorites" private static let storageKey = "chat.bitchat.favorites"
private static let keychainService = "chat.bitchat.favorites" private static let keychainService = "chat.bitchat.favorites"
private let keychain: KeychainHelperProtocol private let keychain: KeychainManagerProtocol
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship @Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
@Published private(set) var mutualFavorites: Set<Data> = [] @Published private(set) var mutualFavorites: Set<Data> = []
@@ -35,8 +35,8 @@ final class FavoritesPersistenceService: ObservableObject {
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
static let shared = FavoritesPersistenceService() static let shared = FavoritesPersistenceService()
init(keychain: KeychainHelperProtocol = KeychainHelper()) { init(keychain: KeychainManagerProtocol = KeychainManager()) {
self.keychain = keychain self.keychain = keychain
loadFavorites() loadFavorites()
+56 -3
View File
@@ -15,11 +15,19 @@ protocol KeychainManagerProtocol {
func getIdentityKey(forKey key: String) -> Data? func getIdentityKey(forKey key: String) -> Data?
func deleteIdentityKey(forKey key: String) -> Bool func deleteIdentityKey(forKey key: String) -> Bool
func deleteAllKeychainData() -> Bool func deleteAllKeychainData() -> Bool
func secureClear(_ data: inout Data) func secureClear(_ data: inout Data)
func secureClear(_ string: inout String) func secureClear(_ string: inout String)
func verifyIdentityKeyExists() -> Bool func verifyIdentityKeyExists() -> Bool
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
/// Save data with a custom service name
func save(key: String, data: Data, service: String, accessible: CFString?)
/// Load data from a custom service
func load(key: String, service: String) -> Data?
/// Delete data from a custom service
func delete(key: String, service: String)
} }
final class KeychainManager: KeychainManagerProtocol { final class KeychainManager: KeychainManagerProtocol {
@@ -309,9 +317,54 @@ final class KeychainManager: KeychainManagerProtocol {
} }
// MARK: - Debug // MARK: - Debug
func verifyIdentityKeyExists() -> Bool { func verifyIdentityKeyExists() -> Bool {
let key = "identity_noiseStaticKey" let key = "identity_noiseStaticKey"
return retrieveData(forKey: key) != nil return retrieveData(forKey: key) != nil
} }
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
/// Save data with a custom service name
func save(key: String, data: Data, service customService: String, accessible: CFString?) {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: customService,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
if let accessible = accessible {
query[kSecAttrAccessible as String] = accessible
}
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
}
/// Load data from a custom service
func load(key: String, service customService: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: customService,
kSecAttrAccount as String: key,
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else { return nil }
return result as? Data
}
/// Delete data from a custom service
func delete(key: String, service customService: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: customService,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
} }
@@ -12,6 +12,8 @@ import Foundation
/// Generic LRU (Least Recently Used) cache for deduplication. /// Generic LRU (Least Recently Used) cache for deduplication.
/// Uses an efficient O(1) lookup with periodic compaction. /// Uses an efficient O(1) lookup with periodic compaction.
/// Thread-safe via @MainActor - all callers are already on main actor.
@MainActor
final class LRUDeduplicationCache<Value> { final class LRUDeduplicationCache<Value> {
private var map: [String: Value] = [:] private var map: [String: Value] = [:]
private var order: [String] = [] private var order: [String] = []
@@ -157,6 +159,8 @@ enum ContentNormalizer {
/// Service that manages message deduplication using LRU caches. /// Service that manages message deduplication using LRU caches.
/// Provides separate caches for content-based dedup and Nostr event ID dedup. /// Provides separate caches for content-based dedup and Nostr event ID dedup.
/// Thread-safe via @MainActor - all callers are already on main actor.
@MainActor
final class MessageDeduplicationService { final class MessageDeduplicationService {
/// Cache for content-based near-duplicate detection /// Cache for content-based near-duplicate detection
+14 -9
View File
@@ -149,8 +149,11 @@ final class NostrTransport: Transport, @unchecked Sendable {
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
// Enqueue and process with throttling to avoid relay rate limits // Enqueue and process with throttling to avoid relay rate limits
readQueue.append(QueuedRead(receipt: receipt, peerID: peerID)) // Use barrier to synchronize access to readQueue
processReadQueueIfNeeded() queue.async(flags: .barrier) { [weak self] in
self?.readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
self?.processReadQueueIfNeeded()
}
} }
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
@@ -260,16 +263,17 @@ extension NostrTransport {
// MARK: - Private Helpers // MARK: - Private Helpers
extension NostrTransport { extension NostrTransport {
/// Must be called within a barrier on `queue`
private func processReadQueueIfNeeded() { private func processReadQueueIfNeeded() {
guard !isSendingReadAcks else { return } guard !isSendingReadAcks else { return }
guard !readQueue.isEmpty else { return } guard !readQueue.isEmpty else { return }
isSendingReadAcks = true isSendingReadAcks = true
sendNextReadAck() let item = readQueue.removeFirst()
sendReadAckItem(item)
} }
private func sendNextReadAck() { /// Sends a single read ack item (called after extraction from queue within barrier)
guard !readQueue.isEmpty else { isSendingReadAcks = false; return } private func sendReadAckItem(_ item: QueuedRead) {
let item = readQueue.removeFirst()
Task { @MainActor in Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return } guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return } guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
@@ -301,9 +305,10 @@ extension NostrTransport {
private func scheduleNextReadAck() { private func scheduleNextReadAck() {
DispatchQueue.main.asyncAfter(deadline: .now() + readAckInterval) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + readAckInterval) { [weak self] in
guard let self = self else { return } self?.queue.async(flags: .barrier) { [weak self] in
self.isSendingReadAcks = false self?.isSendingReadAcks = false
self.processReadQueueIfNeeded() self?.processReadQueueIfNeeded()
}
} }
} }
+28 -21
View File
@@ -5,7 +5,7 @@ import Foundation
/// Thread-safe deduplicator with LRU eviction and time-based expiry. /// Thread-safe deduplicator with LRU eviction and time-based expiry.
/// Used for both message ID deduplication (network layer) and content key deduplication (UI layer). /// Used for both message ID deduplication (network layer) and content key deduplication (UI layer).
final class MessageDeduplicator { final class MessageDeduplicator {
private struct Entry { private struct Entry: Equatable {
let id: String let id: String
let timestamp: Date let timestamp: Date
} }
@@ -31,18 +31,20 @@ final class MessageDeduplicator {
self.maxCount = maxCount self.maxCount = maxCount
} }
/// Check if message is duplicate and add if not /// Check if message is duplicate and add if not.
/// - Parameter id: The message identifier to check.
/// - Returns: `true` if the message was already seen, `false` otherwise.
func isDuplicate(_ id: String) -> Bool { func isDuplicate(_ id: String) -> Bool {
lock.lock() lock.lock()
defer { lock.unlock() } defer { lock.unlock() }
cleanupOldEntries() let now = Date()
cleanupOldEntries(before: now.addingTimeInterval(-maxAge))
if lookup[id] != nil { if lookup[id] != nil {
return true return true
} }
let now = Date()
entries.append(Entry(id: id, timestamp: now)) entries.append(Entry(id: id, timestamp: now))
lookup[id] = now lookup[id] = now
trimIfNeeded() trimIfNeeded()
@@ -89,18 +91,22 @@ final class MessageDeduplicator {
} }
private func trimIfNeeded() { private func trimIfNeeded() {
// Soft-cap and advance head by a chunk to avoid O(n) shifting let activeCount = entries.count - head
if (entries.count - head) > maxCount { guard activeCount > maxCount else { return }
let removeCount = min(100, entries.count - head)
for i in head..<(head + removeCount) { // Remove down to 75% of maxCount for better amortization
lookup.removeValue(forKey: entries[i].id) let targetCount = (maxCount * 3) / 4
} let removeCount = activeCount - targetCount
head += removeCount
// Periodically compact to reclaim memory for i in head..<(head + removeCount) {
if head > entries.count / 2 { lookup.removeValue(forKey: entries[i].id)
entries.removeFirst(head) }
head = 0 head += removeCount
}
// Compact when head exceeds half the array to reclaim memory
if head > entries.count / 2 {
entries.removeFirst(head)
head = 0
} }
} }
@@ -114,24 +120,25 @@ final class MessageDeduplicator {
lookup.removeAll() lookup.removeAll()
} }
/// Periodic cleanup /// Periodic cleanup of expired entries and memory optimization.
func cleanup() { func cleanup() {
lock.lock() lock.lock()
defer { lock.unlock() } defer { lock.unlock() }
cleanupOldEntries() cleanupOldEntries(before: Date().addingTimeInterval(-maxAge))
if entries.capacity > maxCount * 2 { // Shrink capacity if significantly oversized
if entries.capacity > maxCount * 2 && entries.count < maxCount {
entries.reserveCapacity(maxCount) entries.reserveCapacity(maxCount)
} }
} }
private func cleanupOldEntries() { private func cleanupOldEntries(before cutoff: Date) {
let cutoff = Date().addingTimeInterval(-maxAge)
while head < entries.count, entries[head].timestamp < cutoff { while head < entries.count, entries[head].timestamp < cutoff {
lookup.removeValue(forKey: entries[head].id) lookup.removeValue(forKey: entries[head].id)
head += 1 head += 1
} }
// Compact when head exceeds half the array
if head > 0 && head > entries.count / 2 { if head > 0 && head > entries.count / 2 {
entries.removeFirst(head) entries.removeFirst(head)
head = 0 head = 0
@@ -10,32 +10,51 @@ import Foundation
final class PreviewKeychainManager: KeychainManagerProtocol { final class PreviewKeychainManager: KeychainManagerProtocol {
private var storage: [String: Data] = [:] private var storage: [String: Data] = [:]
private var serviceStorage: [String: [String: Data]] = [:]
init() {} init() {}
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
storage[key] = keyData storage[key] = keyData
return true return true
} }
func getIdentityKey(forKey key: String) -> Data? { func getIdentityKey(forKey key: String) -> Data? {
storage[key] storage[key]
} }
func deleteIdentityKey(forKey key: String) -> Bool { func deleteIdentityKey(forKey key: String) -> Bool {
storage.removeValue(forKey: key) storage.removeValue(forKey: key)
return true return true
} }
func deleteAllKeychainData() -> Bool { func deleteAllKeychainData() -> Bool {
storage.removeAll() storage.removeAll()
serviceStorage.removeAll()
return true return true
} }
func secureClear(_ data: inout Data) {} func secureClear(_ data: inout Data) {}
func secureClear(_ string: inout String) {} func secureClear(_ string: inout String) {}
func verifyIdentityKeyExists() -> Bool { func verifyIdentityKeyExists() -> Bool {
storage["identity_noiseStaticKey"] != nil storage["identity_noiseStaticKey"] != nil
} }
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
func save(key: String, data: Data, service: String, accessible: CFString?) {
if serviceStorage[service] == nil {
serviceStorage[service] = [:]
}
serviceStorage[service]?[key] = data
}
func load(key: String, service: String) -> Data? {
serviceStorage[service]?[key]
}
func delete(key: String, service: String) {
serviceStorage[service]?.removeValue(forKey: key)
}
} }
@@ -12,6 +12,8 @@ import Foundation
// MARK: - LRU Deduplication Cache Tests // MARK: - LRU Deduplication Cache Tests
@Suite("LRU Deduplication Cache")
@MainActor
struct LRUDeduplicationCacheTests { struct LRUDeduplicationCacheTests {
// MARK: - Basic Operations // MARK: - Basic Operations
@@ -265,6 +267,8 @@ struct ContentNormalizerTests {
// MARK: - Message Deduplication Service Tests // MARK: - Message Deduplication Service Tests
@Suite("Message Deduplication Service")
@MainActor
struct MessageDeduplicationServiceTests { struct MessageDeduplicationServiceTests {
// MARK: - Content Deduplication // MARK: - Content Deduplication
@@ -467,4 +471,134 @@ struct MessageDeduplicationServiceTests {
#expect(service.contentTimestamp(for: "hello world") == now) #expect(service.contentTimestamp(for: "hello world") == now)
#expect(service.contentTimestamp(for: "Hello World") == now) #expect(service.contentTimestamp(for: "Hello World") == now)
} }
// MARK: - Thread Safety Tests (via @MainActor enforcement)
@Test("Concurrent content recording is safe via MainActor")
func concurrentContentRecording() async {
let service = MessageDeduplicationService(contentCapacity: 1000, nostrEventCapacity: 1000)
let iterations = 100
// All operations run on MainActor due to @MainActor annotation
// This test verifies the pattern works correctly
await withTaskGroup(of: Void.self) { group in
for i in 0..<iterations {
group.addTask { @MainActor in
service.recordContent("Message \(i)", timestamp: Date())
}
}
}
// Verify some entries were recorded
#expect(service.contentTimestamp(for: "Message 0") != nil)
#expect(service.contentTimestamp(for: "Message 99") != nil)
}
@Test("Concurrent Nostr event recording is safe via MainActor")
func concurrentNostrEventRecording() async {
let service = MessageDeduplicationService(contentCapacity: 1000, nostrEventCapacity: 1000)
let iterations = 100
await withTaskGroup(of: Void.self) { group in
for i in 0..<iterations {
group.addTask { @MainActor in
service.recordNostrEvent("event_\(i)")
}
}
}
// Verify events were recorded
#expect(service.hasProcessedNostrEvent("event_0"))
#expect(service.hasProcessedNostrEvent("event_99"))
}
@Test("Mixed concurrent operations are safe via MainActor")
func concurrentMixedOperations() async {
let service = MessageDeduplicationService(contentCapacity: 1000, nostrEventCapacity: 1000)
let iterations = 50
await withTaskGroup(of: Void.self) { group in
// Content recording tasks
for i in 0..<iterations {
group.addTask { @MainActor in
service.recordContent("Content \(i)", timestamp: Date())
}
}
// Event recording tasks
for i in 0..<iterations {
group.addTask { @MainActor in
service.recordNostrEvent("event_\(i)")
}
}
// ACK recording tasks
for i in 0..<iterations {
group.addTask { @MainActor in
service.recordNostrAck("ack_\(i)")
}
}
// Read tasks
for i in 0..<iterations {
group.addTask { @MainActor in
_ = service.contentTimestamp(for: "Content \(i)")
_ = service.hasProcessedNostrEvent("event_\(i)")
_ = service.hasProcessedNostrAck("ack_\(i)")
}
}
}
// If we reach here without crashes, the test passes
}
}
// MARK: - LRU Cache Thread Safety Tests
@Suite("LRU Cache Thread Safety")
@MainActor
struct LRUCacheThreadSafetyTests {
@Test("Concurrent cache access is safe via MainActor")
func concurrentCacheAccess() async {
let cache = LRUDeduplicationCache<Int>(capacity: 500)
let iterations = 100
await withTaskGroup(of: Void.self) { group in
// Write tasks
for i in 0..<iterations {
group.addTask { @MainActor in
cache.record("key_\(i)", value: i)
}
}
// Read tasks
for i in 0..<iterations {
group.addTask { @MainActor in
_ = cache.contains("key_\(i)")
_ = cache.value(for: "key_\(i)")
}
}
}
// Verify cache is in consistent state
#expect(cache.count <= 500) // Respects capacity
}
@Test("Cache eviction under concurrent load is safe")
func cacheEvictionUnderLoad() async {
let cache = LRUDeduplicationCache<Int>(capacity: 10)
let iterations = 100
await withTaskGroup(of: Void.self) { group in
for i in 0..<iterations {
group.addTask { @MainActor in
cache.record("key_\(i)", value: i)
}
}
}
// Cache should maintain its capacity constraint
#expect(cache.count == 10)
}
} }
+108 -18
View File
@@ -11,54 +11,144 @@ import Foundation
final class MockKeychain: KeychainManagerProtocol { final class MockKeychain: KeychainManagerProtocol {
private var storage: [String: Data] = [:] private var storage: [String: Data] = [:]
private var serviceStorage: [String: [String: Data]] = [:]
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
storage[key] = keyData storage[key] = keyData
return true return true
} }
func getIdentityKey(forKey key: String) -> Data? { func getIdentityKey(forKey key: String) -> Data? {
storage[key] storage[key]
} }
func deleteIdentityKey(forKey key: String) -> Bool { func deleteIdentityKey(forKey key: String) -> Bool {
storage.removeValue(forKey: key) storage.removeValue(forKey: key)
return true return true
} }
func deleteAllKeychainData() -> Bool { func deleteAllKeychainData() -> Bool {
storage.removeAll() storage.removeAll()
serviceStorage.removeAll()
return true return true
} }
func secureClear(_ data: inout Data) { func secureClear(_ data: inout Data) {
//
data = Data() data = Data()
} }
func secureClear(_ string: inout String) { func secureClear(_ string: inout String) {
string = "" string = ""
} }
func verifyIdentityKeyExists() -> Bool { func verifyIdentityKeyExists() -> Bool {
storage["identity_noiseStaticKey"] != nil storage["identity_noiseStaticKey"] != nil
} }
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
func save(key: String, data: Data, service: String, accessible: CFString?) {
if serviceStorage[service] == nil {
serviceStorage[service] = [:]
}
serviceStorage[service]?[key] = data
}
func load(key: String, service: String) -> Data? {
serviceStorage[service]?[key]
}
func delete(key: String, service: String) {
serviceStorage[service]?.removeValue(forKey: key)
}
} }
final class MockKeychainHelper: KeychainHelperProtocol { /// Typealias for backwards compatibility with tests using MockKeychainHelper
private typealias Service = String typealias MockKeychainHelper = MockKeychain
private typealias Key = String
private var storage: [Service: [Key: Data]] = [:] /// Mock keychain that tracks secureClear calls for testing DH secret clearing
final class TrackingMockKeychain: KeychainManagerProtocol {
private var storage: [String: Data] = [:]
private var serviceStorage: [String: [String: Data]] = [:]
/// Thread-safe counter for secureClear calls
private let lock = NSLock()
private var _secureClearDataCallCount = 0
private var _secureClearStringCallCount = 0
var secureClearDataCallCount: Int {
lock.lock()
defer { lock.unlock() }
return _secureClearDataCallCount
}
var secureClearStringCallCount: Int {
lock.lock()
defer { lock.unlock() }
return _secureClearStringCallCount
}
var totalSecureClearCallCount: Int {
return secureClearDataCallCount + secureClearStringCallCount
}
func resetCounts() {
lock.lock()
defer { lock.unlock() }
_secureClearDataCallCount = 0
_secureClearStringCallCount = 0
}
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
storage[key] = keyData
return true
}
func getIdentityKey(forKey key: String) -> Data? {
storage[key]
}
func deleteIdentityKey(forKey key: String) -> Bool {
storage.removeValue(forKey: key)
return true
}
func deleteAllKeychainData() -> Bool {
storage.removeAll()
serviceStorage.removeAll()
return true
}
func secureClear(_ data: inout Data) {
lock.lock()
_secureClearDataCallCount += 1
lock.unlock()
data = Data()
}
func secureClear(_ string: inout String) {
lock.lock()
_secureClearStringCallCount += 1
lock.unlock()
string = ""
}
func verifyIdentityKeyExists() -> Bool {
storage["identity_noiseStaticKey"] != nil
}
func save(key: String, data: Data, service: String, accessible: CFString?) { func save(key: String, data: Data, service: String, accessible: CFString?) {
storage[service]?[key] = data if serviceStorage[service] == nil {
serviceStorage[service] = [:]
}
serviceStorage[service]?[key] = data
} }
func load(key: String, service: String) -> Data? { func load(key: String, service: String) -> Data? {
storage[service]?[key] serviceStorage[service]?[key]
} }
func delete(key: String, service: String) { func delete(key: String, service: String) {
storage[service]?.removeValue(forKey: key) serviceStorage[service]?.removeValue(forKey: key)
} }
} }
+155
View File
@@ -789,4 +789,159 @@ struct NoiseProtocolTests {
"Message \(index + 1): Decrypted payload should match original") "Message \(index + 1): Decrypted payload should match original")
} }
} }
// MARK: - DH Shared Secret Clearing Tests
@Test func secureClearCalledDuringHandshake() throws {
// Use TrackingMockKeychain to verify secureClear is called
let trackingKeychain = TrackingMockKeychain()
let aliceKey = Curve25519.KeyAgreement.PrivateKey()
let bobKey = Curve25519.KeyAgreement.PrivateKey()
let alice = NoiseSession(
peerID: PeerID(str: "alice-test"),
role: .initiator,
keychain: trackingKeychain,
localStaticKey: aliceKey
)
let bob = NoiseSession(
peerID: PeerID(str: "bob-test"),
role: .responder,
keychain: trackingKeychain,
localStaticKey: bobKey
)
// Perform handshake
let msg1 = try alice.startHandshake()
let msg2 = try bob.processHandshakeMessage(msg1)!
let msg3 = try alice.processHandshakeMessage(msg2)!
_ = try bob.processHandshakeMessage(msg3)
// In Noise XX pattern handshake:
// - Message 1 (initiator): e token only (no DH)
// - Message 2 (responder): e, ee, s, es tokens (2 DH operations: ee, es)
// - Message 3 (initiator): s, se tokens (1 DH operation: se)
// Total in writeMessage: 3 DH operations (ee, es, se)
//
// In readMessage (performDHOperation):
// - After msg1: no DH
// - After msg2: ee, es (2 DH operations)
// - After msg3: se (1 DH operation)
// Total in performDHOperation: 3 DH operations
//
// Grand total: 6 DH operations requiring secureClear
//
// Note: .ss pattern is only used in certain handshake patterns, not XX
let expectedMinimumCalls = 6
#expect(
trackingKeychain.secureClearDataCallCount >= expectedMinimumCalls,
"Expected at least \(expectedMinimumCalls) secureClear calls for DH secrets, got \(trackingKeychain.secureClearDataCallCount)"
)
}
@Test func encryptionWorksAfterSecureClear() throws {
// Verify that encryption/decryption still works correctly after adding secureClear
let trackingKeychain = TrackingMockKeychain()
let aliceKey = Curve25519.KeyAgreement.PrivateKey()
let bobKey = Curve25519.KeyAgreement.PrivateKey()
let alice = NoiseSession(
peerID: PeerID(str: "alice-test-enc"),
role: .initiator,
keychain: trackingKeychain,
localStaticKey: aliceKey
)
let bob = NoiseSession(
peerID: PeerID(str: "bob-test-enc"),
role: .responder,
keychain: trackingKeychain,
localStaticKey: bobKey
)
// Perform handshake
let msg1 = try alice.startHandshake()
let msg2 = try bob.processHandshakeMessage(msg1)!
let msg3 = try alice.processHandshakeMessage(msg2)!
_ = try bob.processHandshakeMessage(msg3)
// Verify both sessions are established
#expect(alice.isEstablished())
#expect(bob.isEstablished())
// Verify secureClear was called (basic sanity check)
#expect(trackingKeychain.secureClearDataCallCount > 0)
// Test encryption from Alice to Bob
let plaintext1 = "Hello from Alice after secureClear!".data(using: .utf8)!
let ciphertext1 = try alice.encrypt(plaintext1)
let decrypted1 = try bob.decrypt(ciphertext1)
#expect(decrypted1 == plaintext1)
// Test encryption from Bob to Alice
let plaintext2 = "Hello from Bob after secureClear!".data(using: .utf8)!
let ciphertext2 = try bob.encrypt(plaintext2)
let decrypted2 = try alice.decrypt(ciphertext2)
#expect(decrypted2 == plaintext2)
// Test multiple messages to verify cipher state is correct
for i in 1...10 {
let msg = "Message \(i) from Alice".data(using: .utf8)!
let cipher = try alice.encrypt(msg)
let dec = try bob.decrypt(cipher)
#expect(dec == msg)
}
}
@Test func secureClearCalledInBothWriteAndReadPaths() throws {
// Verify secureClear is called in both writeMessage and readMessage paths
// We do this by checking the count increases at each step
let aliceKeychain = TrackingMockKeychain()
let bobKeychain = TrackingMockKeychain()
let aliceKey = Curve25519.KeyAgreement.PrivateKey()
let bobKey = Curve25519.KeyAgreement.PrivateKey()
let alice = NoiseSession(
peerID: PeerID(str: "alice-paths"),
role: .initiator,
keychain: aliceKeychain,
localStaticKey: aliceKey
)
let bob = NoiseSession(
peerID: PeerID(str: "bob-paths"),
role: .responder,
keychain: bobKeychain,
localStaticKey: bobKey
)
// Message 1: Alice writes (e token only, no DH)
let msg1 = try alice.startHandshake()
let aliceCountAfterMsg1 = aliceKeychain.secureClearDataCallCount
// No DH in message 1 for initiator
#expect(aliceCountAfterMsg1 == 0, "No DH secrets in message 1 write")
// Bob reads message 1 (no DH) and writes message 2 (ee, es DH operations)
let msg2 = try bob.processHandshakeMessage(msg1)!
let bobCountAfterMsg2 = bobKeychain.secureClearDataCallCount
// Bob should have cleared secrets for: ee (read), es (read), ee (write), es (write)
#expect(bobCountAfterMsg2 >= 2, "Bob should clear DH secrets when processing/writing message 2")
// Alice reads message 2 (ee, es) and writes message 3 (se)
let msg3 = try alice.processHandshakeMessage(msg2)!
let aliceCountAfterMsg3 = aliceKeychain.secureClearDataCallCount
// Alice should have cleared: ee (read), es (read), se (write)
#expect(aliceCountAfterMsg3 >= 3, "Alice should clear DH secrets when processing/writing message 3")
// Bob reads message 3 (se)
_ = try bob.processHandshakeMessage(msg3)
let bobFinalCount = bobKeychain.secureClearDataCallCount
// Bob should have additionally cleared: se (read)
#expect(bobFinalCount > bobCountAfterMsg2, "Bob should clear DH secrets when processing message 3")
}
} }
@@ -0,0 +1,108 @@
//
// NostrTransportTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import Testing
@testable import bitchat
@Suite("NostrTransport Thread Safety Tests")
struct NostrTransportTests {
@Test("Concurrent read receipt enqueue does not crash")
@MainActor
func concurrentReadReceiptEnqueue() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
// Create 100 concurrent read receipt submissions
let iterations = 100
await withTaskGroup(of: Void.self) { group in
for i in 0..<iterations {
group.addTask {
let receipt = ReadReceipt(
originalMessageID: UUID().uuidString,
readerID: PeerID(str: String(format: "%016x", i)),
readerNickname: "Reader\(i)"
)
let peerID = PeerID(str: String(format: "%016x", i))
transport.sendReadReceipt(receipt, to: peerID)
}
}
}
// If we reach here without crashing, the test passes
// The concurrent enqueue operations completed without data races
}
@Test("Read queue processes under concurrent load")
@MainActor
func readQueueProcessingUnderLoad() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
// Rapidly enqueue many receipts from multiple concurrent sources
let iterations = 50
// First batch - rapid fire
for i in 0..<iterations {
let receipt = ReadReceipt(
originalMessageID: UUID().uuidString,
readerID: PeerID(str: String(format: "%016x", i)),
readerNickname: "Reader\(i)"
)
transport.sendReadReceipt(receipt, to: PeerID(str: String(format: "%016x", i)))
}
// Give some time for processing to start
try await Task.sleep(nanoseconds: 100_000_000) // 100ms
// Second batch - while first might be processing
await withTaskGroup(of: Void.self) { group in
for i in iterations..<(iterations * 2) {
group.addTask {
let receipt = ReadReceipt(
originalMessageID: UUID().uuidString,
readerID: PeerID(str: String(format: "%016x", i)),
readerNickname: "Reader\(i)"
)
transport.sendReadReceipt(receipt, to: PeerID(str: String(format: "%016x", i)))
}
}
}
// If we reach here without crashing or deadlocking, test passes
}
@Test("isPeerReachable is thread safe")
@MainActor
func isPeerReachableThreadSafety() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
let iterations = 100
// Concurrent reads on isPeerReachable
await withTaskGroup(of: Bool.self) { group in
for i in 0..<iterations {
group.addTask {
let peerID = PeerID(str: String(format: "%016x", i))
return transport.isPeerReachable(peerID)
}
}
// Collect results (all should be false since no favorites configured)
for await result in group {
#expect(result == false)
}
}
}
}
+108
View File
@@ -0,0 +1,108 @@
//
// HexStringTests.swift
// bitchatTests
//
// Tests for Data(hexString:) hex parsing
//
import Testing
import Foundation
@testable import bitchat
struct HexStringTests {
// MARK: - Valid Hex Strings
@Test func validHexString() {
let data = Data(hexString: "0102030405")
#expect(data == Data([0x01, 0x02, 0x03, 0x04, 0x05]))
}
@Test func validHexStringUppercase() {
let data = Data(hexString: "AABBCCDD")
#expect(data == Data([0xAA, 0xBB, 0xCC, 0xDD]))
}
@Test func validHexStringMixedCase() {
let data = Data(hexString: "aAbBcCdD")
#expect(data == Data([0xAA, 0xBB, 0xCC, 0xDD]))
}
@Test func validHexStringWith0xPrefix() {
let data = Data(hexString: "0x0102030405")
#expect(data == Data([0x01, 0x02, 0x03, 0x04, 0x05]))
}
@Test func validHexStringWith0XPrefix() {
let data = Data(hexString: "0XAABBCCDD")
#expect(data == Data([0xAA, 0xBB, 0xCC, 0xDD]))
}
@Test func validHexStringWithWhitespace() {
let data = Data(hexString: " 0102030405 ")
#expect(data == Data([0x01, 0x02, 0x03, 0x04, 0x05]))
}
@Test func validHexStringWith0xPrefixAndWhitespace() {
let data = Data(hexString: " 0x0102030405 ")
#expect(data == Data([0x01, 0x02, 0x03, 0x04, 0x05]))
}
@Test func emptyHexString() {
let data = Data(hexString: "")
#expect(data == Data())
}
@Test func emptyHexStringWithWhitespace() {
let data = Data(hexString: " ")
#expect(data == Data())
}
@Test func emptyHexStringWith0xPrefix() {
let data = Data(hexString: "0x")
#expect(data == Data())
}
// MARK: - Invalid Hex Strings
@Test func oddLengthHexStringReturnsNil() {
let data = Data(hexString: "012")
#expect(data == nil)
}
@Test func oddLengthHexStringWith0xPrefixReturnsNil() {
let data = Data(hexString: "0x012")
#expect(data == nil)
}
@Test func invalidCharactersReturnNil() {
let data = Data(hexString: "GHIJ")
#expect(data == nil)
}
@Test func mixedValidAndInvalidCharactersReturnNil() {
let data = Data(hexString: "01GH")
#expect(data == nil)
}
@Test func specialCharactersReturnNil() {
let data = Data(hexString: "01-02")
#expect(data == nil)
}
// MARK: - Round Trip Tests
@Test func roundTripConversion() {
let original = Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF])
let hexString = original.hexEncodedString()
let roundTripped = Data(hexString: hexString)
#expect(roundTripped == original)
}
@Test func roundTripConversionWith0xPrefix() {
let original = Data([0xDE, 0xAD, 0xBE, 0xEF])
let hexString = "0x" + original.hexEncodedString()
let roundTripped = Data(hexString: hexString)
#expect(roundTripped == original)
}
}