Compare commits

..
Author SHA1 Message Date
Codex 3c06c9e793 Allow manual CI dispatch 2026-06-26 00:31:25 +02:00
Codex 930354194f Use Xcode Swift in CI 2026-06-26 00:28:54 +02:00
Codex 15293f6b4b Salt SwiftPM cache by toolchain 2026-06-26 00:21:10 +02:00
Codex 842d8f2cd3 Align REQUEST_SYNC filters with Android 2026-06-25 23:09:35 +02:00
13 changed files with 348 additions and 595 deletions
+13 -10
View File
@@ -5,6 +5,7 @@ on:
branches:
- main
pull_request:
workflow_dispatch:
jobs:
test:
@@ -29,22 +30,24 @@ jobs:
- name: Checkout code
uses: actions/checkout@v5
# Use the Xcode-bundled Swift toolchain: it always matches the SDK on
# the runner image. A standalone swift.org toolchain (setup-swift) broke
# whenever the image's Xcode moved ahead of it ("this SDK is not
# supported by the compiler").
- name: Note toolchain version (cache key)
id: swift-version
run: echo "version=$(swift --version 2>/dev/null | head -1 | shasum | cut -c1-12)" >> "$GITHUB_OUTPUT"
- name: Compute Swift cache salt
id: swift_cache_salt
run: |
{
swift --version
xcrun --show-sdk-platform-path
xcrun --show-sdk-version
xcodebuild -version
} | shasum -a 256 | awk '{ print "value=" $1 }' >> "$GITHUB_OUTPUT"
- name: Cache build artifacts
uses: actions/cache@v4
with:
path: ${{ matrix.path }}/.build
key: ${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
key: ${{ runner.os }}-${{ runner.arch }}-${{ matrix.name }}-${{ steps.swift_cache_salt.outputs.value }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
restore-keys: |
${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-
${{ runner.os }}-${{ runner.arch }}-${{ matrix.name }}-${{ steps.swift_cache_salt.outputs.value }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
${{ runner.os }}-${{ runner.arch }}-${{ matrix.name }}-${{ steps.swift_cache_salt.outputs.value }}-
- name: Build tests
# Built separately so the hang watchdog below times only test
+1
View File
@@ -6,6 +6,7 @@
plans/
## AI
CLAUDE.md
AGENTS.md
.claude/
-51
View File
@@ -1,51 +0,0 @@
# SwiftLint configuration for BitChat.
#
# Intentionally pragmatic: rules that would flood the existing codebase are
# disabled so the lint signal stays actionable; re-enable them individually as
# files get cleaned up. Not wired into CI yet — run locally with `swiftlint`
# from the repo root.
included:
- bitchat
- bitchatTests
- localPackages/BitFoundation/Sources
- localPackages/BitFoundation/Tests
- localPackages/BitLogger/Sources
excluded:
- .build
- localPackages/Arti
- localPackages/BitFoundation/.build
- localPackages/BitLogger/.build
disabled_rules:
# Style/volume rules that currently produce noise across the codebase.
- line_length
- identifier_name
- type_name
- type_body_length
- cyclomatic_complexity
- function_parameter_count
- large_tuple
- nesting
- todo
- trailing_comma
- opening_brace
- statement_position
- for_where
- redundant_string_enum_value
- non_optional_string_data_conversion
# Common in tests (force casts/tries on fixtures).
- force_cast
- force_try
file_length:
warning: 600
# BLEService.swift is currently ~3,500 lines; the error threshold sits above
# today's maximum so the codebase passes as-is and only future growth of the
# largest files trips it.
error: 4000
function_body_length:
warning: 100
error: 200
-106
View File
@@ -1,106 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build & Test Commands
```bash
# Build macOS (no signing)
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGNING_ALLOWED=NO build
# Build iOS for simulator
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (iOS)" -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' build
# Run all tests (iOS simulator)
xcodebuild -project bitchat.xcodeproj -scheme bitchat -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' test
# Run tests via Swift Package Manager (used in CI)
swift build && swift test --parallel
# Clean build
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" clean
# Quick dev build and run (macOS only, requires `just`)
just run
```
### Running Specific Tests
```bash
# Run a single test class
xcodebuild test -project bitchat.xcodeproj -scheme bitchat -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' -only-testing:bitchatTests/IntegrationTests
# Run a single test method
xcodebuild test -project bitchat.xcodeproj -scheme bitchat -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' -only-testing:bitchatTests/NoiseProtocolTests/testHandshake
```
## Architecture Overview
BitChat is a **dual-transport P2P messaging app**: Bluetooth mesh for offline local communication, Nostr protocol for internet-based global messaging.
### Local Packages (`localPackages/`)
- **BitFoundation**: Shared foundation types — `BinaryProtocol` (compact binary packet format for BLE), `BitchatPacket`, `BitchatMessage`, `PeerID`, hex/SHA256/compression utilities. Has its own test suite under `localPackages/BitFoundation/Tests` (run `swift test` inside the package).
- **BitLogger**: `SecureLogger` logging.
- **Arti**: Tor integration (exposes the `Tor` product).
### Transport Layer
- **BLEService** (`bitchat/Services/BLE/BLEService.swift`): Core Bluetooth LE mesh networking - peer discovery, connection management, multi-hop relay (max 7 hops), packet fragmentation. The BLE directory contains ~40 focused collaborators (handlers, policies, buffers): `BLEReceivePipeline` dispatches inbound packets to `BLEAnnounceHandler` / `BLENoisePacketHandler` / `BLEPublicMessageHandler` / `BLEFragmentHandler` etc.; outbound planning lives in the `BLEOutbound*` types; peer state in `BLEPeerRegistry`.
- **NostrTransport** (`bitchat/Services/NostrTransport.swift`): Internet transport via Nostr relays with NIP-17 encryption
- **Transport protocol** (`bitchat/Services/Transport.swift`): Common interface both transports implement
### Encryption Layer
- **NoiseProtocol** (`bitchat/Noise/`): Noise XX pattern for end-to-end encryption with forward secrecy
- `NoiseEncryptionService`: Main encryption/decryption API
- `NoiseSessionManager`: Thread-safe per-peer session management
- `NoiseSession`: Individual peer session state (handshake, send/receive ciphers)
- **NostrProtocol** (`bitchat/Nostr/NostrProtocol.swift`): NIP-17 gift-wrapped encryption for Nostr messages
### Protocol Layer
- **BinaryProtocol** (`localPackages/BitFoundation/Sources/BitFoundation/BinaryProtocol.swift`): Compact binary packet format for BLE (lives in BitFoundation, not `bitchat/Protocols/`)
- **BitchatProtocol** (`bitchat/Protocols/BitchatProtocol.swift`): Message types and packet structures
- **LocationChannel/Geohash** (`bitchat/Protocols/`): Geographic channel routing
### Application Layer
- **AppRuntime** (`bitchat/App/AppRuntime.swift`): Composition root. Owns `ChatViewModel`, `ConversationStore`, the feature models (`PublicChatModel`, `PeerListModel`, `LocationPresenceStore`, `PeerIdentityStore`, ...), and the app event stream.
- **ConversationStore** (`bitchat/App/ConversationStore.swift`): Single-writer, single source of truth for conversation message state and selection (see `docs/CONVERSATION-STORE-DESIGN.md`). Feature models and `ChatViewModel` observe it and mutate it through its intent API.
- **ChatViewModel** (`bitchat/ViewModels/ChatViewModel.swift`, ~1,600 lines): Central coordinator that wires and delegates to ~25 focused coordinator/pipeline types in `bitchat/ViewModels/`, e.g.:
- `ChatPrivateConversationCoordinator` / `ChatPublicConversationCoordinator`: DM and public chat flows
- `ChatNostrCoordinator``GeohashSubscriptionManager`, `NostrInboundPipeline`, `GeoPresenceTracker`, `GeoChannelCoordinator`: Nostr/geohash channel logic
- `ChatOutgoingCoordinator`, `ChatDeliveryCoordinator`, `PublicMessagePipeline`: send paths and delivery tracking
- `ChatMediaTransferCoordinator`, `ChatMediaPreparation`: media transfers
- `ChatLifecycleCoordinator`, `ChatTransportEventCoordinator`, `ChatPeerListCoordinator`, `ChatPeerIdentityCoordinator`, `ChatVerificationCoordinator`: lifecycle, transport events, peer state
- `bitchat/ViewModels/Extensions/` (`ChatViewModel+Nostr/+PrivateChat/+Tor`): thin delegation shims kept for call-site stability; the real logic lives in the coordinators
- **MessageRouter** (`bitchat/Services/MessageRouter.swift`): Intelligent transport selection (BLE → Nostr fallback)
- **PrivateChatManager** (`bitchat/Services/PrivateChatManager.swift`): DM session management
## Test Infrastructure
Tests use an **in-memory networking harness** for deterministic, race-free testing:
- **MockBLEService** (`bitchatTests/Mocks/MockBLEService.swift`): Simulated BLE mesh with configurable topology
- `MockBLEService.resetTestBus()` - Clear state in setUp()
- `simulateConnectedPeer(_:)` / `simulateDisconnectedPeer(_:)` - Configure topology
- `autoFloodEnabled` - Enable broadcast flooding for Integration tests only
- **Test categories**:
- `bitchatTests/EndToEnd/`: Full message flow tests with explicit routing
- `bitchatTests/Integration/`: Multi-node topology tests with auto-flooding
- Unit tests: Individual component tests
## Key Patterns
- **Threading**: Use `@MainActor` for UI, `Task { @MainActor in ... }` for main thread dispatch
- **Delegation**: `BitchatDelegate`, `TransportPeerEventsDelegate` for event propagation
- **Session recovery**: On decrypt failure, clear local session and re-initiate Noise handshake (no NACK)
## Device Setup
To run on physical devices:
1. Copy `Configs/Local.xcconfig.example` to `Configs/Local.xcconfig`
2. Add your Developer Team ID to `Local.xcconfig`
3. Replace `group.chat.bitchat` with `group.<your_bundle_id>` in entitlements
+3
View File
@@ -4,6 +4,9 @@ import Foundation
// - 0x01: P (uint8) Golomb-Rice parameter
// - 0x02: M (uint32, big-endian) hash range (N * 2^P)
// - 0x03: data (opaque) GR bitstream bytes (MSB-first)
// - 0x04: wanted types raw MessageType bytes
// - 0x05: minimum timestamp (uint64, big-endian) epoch milliseconds
// - 0x06: fragment id filter (utf8)
struct RequestSyncPacket {
let p: Int
let m: UInt32
+25 -117
View File
@@ -328,85 +328,52 @@ struct NostrProtocol {
return try NostrEvent(from: rumorDict)
}
// MARK: - Encryption (NIP-44 v2/v3)
/// Whether outgoing DMs use the padded "v3:" envelope.
///
/// TWO-PHASE ROLLOUT DO NOT FLIP YET. Deployed clients hard-reject any
/// ciphertext that does not start with "v2:" (`decrypt` below, as shipped,
/// throws `invalidCiphertext` on unknown version prefixes), and the "v2:"
/// payload is the raw UTF-8 rumor JSON, so a padded payload cannot be
/// smuggled inside "v2:" without breaking old receivers either. Enabling
/// this today would strand every client in the field.
///
/// Phase 1 (this change): ship decrypt-side support for "v3:" everywhere.
/// Phase 2 (future release, once phase-1 clients are widely deployed):
/// set this to true so outgoing DMs stop leaking plaintext length to
/// relays.
static let sendPaddedEnvelope = false
/// Internal (rather than private) so tests can exercise both envelope
/// versions directly.
static func encrypt(
// MARK: - Encryption (NIP-44 v2)
private static func encrypt(
plaintext: String,
recipientPubkey: String,
senderKey: P256K.Schnorr.PrivateKey,
padded: Bool = NostrProtocol.sendPaddedEnvelope
senderKey: P256K.Schnorr.PrivateKey
) throws -> String {
guard let recipientPubkeyData = Data(hexString: recipientPubkey) else {
throw NostrError.invalidPublicKey
}
// Encrypting message (XChaCha20-Poly1305, versioned envelope)
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
// Derive shared secret
let sharedSecret = try deriveSharedSecret(
privateKey: senderKey,
publicKey: recipientPubkeyData
)
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info).
// The v3 envelope deliberately reuses the same key derivation; it only
// changes the payload framing (length prefix + padding).
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info)
let key = try deriveNIP44V2Key(from: sharedSecret)
// 24-byte random nonce for XChaCha20-Poly1305
var nonce24 = Data(count: 24)
_ = nonce24.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
}
// v2 payload: raw UTF-8 plaintext (length leaks to relays)
// v3 payload: NIP-44 style [2-byte BE length][plaintext][zero padding]
let pt = Data(plaintext.utf8)
let payload = padded ? try NIP44Padding.pad(pt) : pt
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: payload, key: key, nonce24: nonce24)
// version prefix + base64url(nonce24 || ciphertext || tag)
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
// v2: base64url(nonce24 || ciphertext || tag)
var combined = Data()
combined.append(nonce24)
combined.append(sealed.ciphertext)
combined.append(sealed.tag)
return (padded ? "v3:" : "v2:") + Base64URLCoding.encode(combined)
return "v2:" + Base64URLCoding.encode(combined)
}
/// Internal (rather than private) so tests can exercise both envelope
/// versions directly.
static func decrypt(
private static func decrypt(
ciphertext: String,
senderPubkey: String,
recipientKey: P256K.Schnorr.PrivateKey
) throws -> String {
// Accept both the legacy unpadded "v2:" envelope and the padded "v3:"
// envelope (see `sendPaddedEnvelope` for the rollout plan).
let isPadded: Bool
if ciphertext.hasPrefix("v2:") {
isPadded = false
} else if ciphertext.hasPrefix("v3:") {
isPadded = true
} else {
throw NostrError.invalidCiphertext
}
// Expect NIP-44 v2 format
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
let encoded = String(ciphertext.dropFirst(3))
guard let data = Base64URLCoding.decode(encoded),
data.count > (24 + 16),
@@ -432,23 +399,18 @@ struct NostrProtocol {
}
// If 32 bytes (x-only) try both parities, otherwise single try
let payload: Data
if senderPubkeyData.count == 32 {
let even = Data([0x02]) + senderPubkeyData
if let pt = try? attemptDecrypt(using: even) {
payload = pt
} else {
let odd = Data([0x03]) + senderPubkeyData
payload = try attemptDecrypt(using: odd)
return String(data: pt, encoding: .utf8) ?? ""
}
let odd = Data([0x03]) + senderPubkeyData
let pt = try attemptDecrypt(using: odd)
return String(data: pt, encoding: .utf8) ?? ""
} else {
payload = try attemptDecrypt(using: senderPubkeyData)
let pt = try attemptDecrypt(using: senderPubkeyData)
return String(data: pt, encoding: .utf8) ?? ""
}
// The AEAD tag has already authenticated the payload; unpadding
// failures here mean a malformed sender, not a wrong key.
let plaintextData = isPadded ? try NIP44Padding.unpad(payload) : payload
return String(data: plaintextData, encoding: .utf8) ?? ""
}
private static func deriveSharedSecret(
@@ -679,60 +641,6 @@ enum NostrError: Error {
case encryptionFailed
}
// MARK: - NIP-44 style padding (v3 envelope payload framing)
/// Payload framing for the padded "v3:" envelope, modeled on NIP-44 v2:
/// `[2-byte big-endian plaintext length][plaintext][zero padding]`, where the
/// total is padded to `paddedLength(for:)` power-of-two-derived buckets with
/// a 32-byte minimum so ciphertext length no longer reveals exact plaintext
/// length to relays.
enum NIP44Padding {
static let minPaddedLength = 32
static let maxPlaintextLength = 65535
/// NIP-44's calc_padded_len: pad to 32 bytes minimum, then to a chunk
/// granularity of max(32, nextPowerOfTwo/8).
static func paddedLength(for unpaddedLength: Int) -> Int {
guard unpaddedLength > minPaddedLength else { return minPaddedLength }
// Smallest power of two strictly greater than (unpaddedLength - 1).
let nextPower = 1 << (Int.bitWidth - (unpaddedLength - 1).leadingZeroBitCount)
let chunk = nextPower <= 256 ? 32 : nextPower / 8
return chunk * ((unpaddedLength - 1) / chunk + 1)
}
/// Prefix plaintext with its 2-byte big-endian length and zero-pad to the
/// bucketed length. Rejects empty plaintexts and plaintexts that do not
/// fit the 16-bit length prefix.
static func pad(_ plaintext: Data) throws -> Data {
let length = plaintext.count
guard length >= 1, length <= maxPlaintextLength else {
throw NostrError.encryptionFailed
}
let padded = paddedLength(for: length)
var result = Data(capacity: 2 + padded)
result.append(UInt8(length >> 8))
result.append(UInt8(length & 0xFF))
result.append(plaintext)
result.append(Data(count: padded - length))
return result
}
/// Read the 2-byte length prefix, validate the total padded size matches
/// it exactly, and return the plaintext. Throws on any inconsistency so a
/// malformed (already-authenticated) payload can never over- or
/// under-read.
static func unpad(_ padded: Data) throws -> Data {
guard padded.count >= 2 else { throw NostrError.invalidCiphertext }
let start = padded.startIndex
let length = Int(padded[start]) << 8 | Int(padded[start + 1])
guard length >= 1,
padded.count == 2 + paddedLength(for: length) else {
throw NostrError.invalidCiphertext
}
return padded.subdata(in: (start + 2)..<(start + 2 + length))
}
}
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
private extension NostrProtocol {
+48 -20
View File
@@ -167,6 +167,16 @@ final class GossipSyncManager {
return packet.timestamp >= cutoffMs
}
private func normalizedSyncTypes(_ types: SyncTypeFlags?) -> SyncTypeFlags {
guard let types, !types.isEmpty else { return .publicMessages }
return types
}
private func isAtOrAfterMinimumTimestamp(_ packet: BitchatPacket, sinceTimestamp: UInt64?) -> Bool {
guard let sinceTimestamp else { return true }
return packet.timestamp >= sinceTimestamp
}
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
guard let messageType = MessageType(rawValue: packet.type) else { return }
let isBroadcastRecipient: Bool = {
@@ -218,8 +228,8 @@ final class GossipSyncManager {
}
}
private func sendRequestSync(for types: SyncTypeFlags) {
let payload = buildGcsPayload(for: types)
func sendRequestSync(for types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil) {
let payload = buildGcsPayload(for: types, sinceTimestamp: sinceTimestamp)
let pkt = BitchatPacket(
type: MessageType.requestSync.rawValue,
senderID: Data(hexString: myPeerID.id) ?? Data(),
@@ -233,11 +243,11 @@ final class GossipSyncManager {
delegate?.sendPacket(signed)
}
private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) {
func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil) {
// Register the request for RSR validation
requestSyncManager.registerRequest(to: peerID)
let payload = buildGcsPayload(for: types)
let payload = buildGcsPayload(for: types, sinceTimestamp: sinceTimestamp)
var recipient = Data()
var temp = peerID.id
while temp.count >= 2 && recipient.count < 8 {
@@ -265,7 +275,8 @@ final class GossipSyncManager {
}
private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
let requestedTypes = (request.types ?? .publicMessages)
let requestedTypes = normalizedSyncTypes(request.types)
let sinceTimestamp = request.sinceTimestamp
// Decode GCS into sorted set and prepare membership checker
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
func mightContain(_ id: Data) -> Bool {
@@ -276,7 +287,7 @@ final class GossipSyncManager {
if requestedTypes.contains(.announce) {
for (_, pair) in latestAnnouncementByPeer {
let (idHex, pkt) = pair
guard isPacketFresh(pkt) else { continue }
guard isPacketFresh(pkt), isAtOrAfterMinimumTimestamp(pkt, sinceTimestamp: sinceTimestamp) else { continue }
let idBytes = Data(hexString: idHex) ?? Data()
if !mightContain(idBytes) {
var toSend = pkt
@@ -290,6 +301,7 @@ final class GossipSyncManager {
if requestedTypes.contains(.message) {
let toSendMsgs = messages.allPackets(isFresh: isPacketFresh)
for pkt in toSendMsgs {
guard isAtOrAfterMinimumTimestamp(pkt, sinceTimestamp: sinceTimestamp) else { continue }
let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) {
var toSend = pkt
@@ -303,6 +315,7 @@ final class GossipSyncManager {
if requestedTypes.contains(.fragment) {
let frags = fragments.allPackets(isFresh: isPacketFresh)
for pkt in frags {
guard isAtOrAfterMinimumTimestamp(pkt, sinceTimestamp: sinceTimestamp) else { continue }
let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) {
var toSend = pkt
@@ -316,6 +329,7 @@ final class GossipSyncManager {
if requestedTypes.contains(.fileTransfer) {
let files = fileTransfers.allPackets(isFresh: isPacketFresh)
for pkt in files {
guard isAtOrAfterMinimumTimestamp(pkt, sinceTimestamp: sinceTimestamp) else { continue }
let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) {
var toSend = pkt
@@ -328,25 +342,33 @@ final class GossipSyncManager {
}
// Build REQUEST_SYNC payload using current candidates and GCS params
private func buildGcsPayload(for types: SyncTypeFlags) -> Data {
private func buildGcsPayload(for types: SyncTypeFlags?, sinceTimestamp: UInt64? = nil) -> Data {
let requestedTypes = normalizedSyncTypes(types)
let encodedTypes = types.flatMap { $0.isEmpty ? nil : $0 }
var candidates: [BitchatPacket] = []
if types.contains(.announce) {
for (_, pair) in latestAnnouncementByPeer where isPacketFresh(pair.packet) {
if requestedTypes.contains(.announce) {
for (_, pair) in latestAnnouncementByPeer where isPacketFresh(pair.packet) && isAtOrAfterMinimumTimestamp(pair.packet, sinceTimestamp: sinceTimestamp) {
candidates.append(pair.packet)
}
}
if types.contains(.message) {
candidates.append(contentsOf: messages.allPackets(isFresh: isPacketFresh))
if requestedTypes.contains(.message) {
candidates.append(contentsOf: messages.allPackets(isFresh: isPacketFresh).filter {
isAtOrAfterMinimumTimestamp($0, sinceTimestamp: sinceTimestamp)
})
}
if types.contains(.fragment) {
candidates.append(contentsOf: fragments.allPackets(isFresh: isPacketFresh))
if requestedTypes.contains(.fragment) {
candidates.append(contentsOf: fragments.allPackets(isFresh: isPacketFresh).filter {
isAtOrAfterMinimumTimestamp($0, sinceTimestamp: sinceTimestamp)
})
}
if types.contains(.fileTransfer) {
candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh))
if requestedTypes.contains(.fileTransfer) {
candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh).filter {
isAtOrAfterMinimumTimestamp($0, sinceTimestamp: sinceTimestamp)
})
}
if candidates.isEmpty {
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: encodedTypes, sinceTimestamp: sinceTimestamp)
return req.encode()
}
@@ -356,21 +378,21 @@ final class GossipSyncManager {
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
let nMax = GCSFilter.estimateMaxElements(sizeBytes: config.gcsMaxBytes, p: p)
let cap: Int
if types == .fragment {
if requestedTypes == .fragment {
cap = max(1, config.fragmentCapacity)
} else if types == .fileTransfer {
} else if requestedTypes == .fileTransfer {
cap = max(1, config.fileTransferCapacity)
} else {
cap = max(1, config.seenCapacity)
}
let takeN = min(candidates.count, min(nMax, cap))
if takeN <= 0 {
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: encodedTypes, sinceTimestamp: sinceTimestamp)
return req.encode()
}
let ids: [Data] = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) }
let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr)
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types)
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: encodedTypes, sinceTimestamp: sinceTimestamp)
return req.encode()
}
@@ -461,5 +483,11 @@ extension GossipSyncManager {
messages.allPackets { _ in true }.filter { PeerID(hexData: $0.senderID) == peerID }.count
}
}
func _buildGcsPayloadSynchronously(for types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil) -> Data {
queue.sync {
buildGcsPayload(for: types, sinceTimestamp: sinceTimestamp)
}
}
}
#endif
+25 -14
View File
@@ -1,8 +1,8 @@
import BitFoundation
import Foundation
/// Bitfield describing which message types are covered by a REQUEST_SYNC round.
/// Matches the Android mapping (bit index -> message type).
/// Internal bitfield describing which message types are covered by a REQUEST_SYNC round.
/// The wire TLV uses raw `MessageType` bytes, matching Android's wantedTypes format.
struct SyncTypeFlags: OptionSet {
let rawValue: UInt64
@@ -80,26 +80,37 @@ struct SyncTypeFlags: OptionSet {
}
func toData() -> Data? {
guard rawValue != 0 else { return nil }
var value = rawValue
var bytes: [UInt8] = []
while value > 0 && bytes.count < 8 {
bytes.append(UInt8(value & 0xFF))
value >>= 8
}
while let last = bytes.last, last == 0 {
bytes.removeLast()
}
guard !bytes.isEmpty, bytes.count <= 8 else { return nil }
let bytes = toMessageTypes().map(\.rawValue)
guard !bytes.isEmpty else { return nil }
return Data(bytes)
}
static func decode(_ data: Data) -> SyncTypeFlags? {
guard !data.isEmpty else { return nil }
// Prior experimental iOS builds encoded announce+message as the
// bitfield byte 0x03. Android's upgraded wire format uses the same
// value for LEAVE, which this sync manager does not store, so prefer
// the legacy interpretation for the single-byte ambiguous case.
if data.count == 1 && data[0] == 0x03 {
return .publicMessages
}
let types = data.compactMap { MessageType(rawValue: $0) }
if !types.isEmpty {
return SyncTypeFlags(messageTypes: types)
}
return decodeLegacyBitfield(data)
}
private static func decodeLegacyBitfield(_ data: Data) -> SyncTypeFlags? {
guard (1...8).contains(data.count) else { return nil }
var raw: UInt64 = 0
for (index, byte) in data.enumerated() {
raw |= UInt64(byte) << UInt64(index * 8)
}
return SyncTypeFlags(rawValue: raw)
let flags = SyncTypeFlags(rawValue: raw)
return flags.isEmpty ? nil : flags
}
}
+101
View File
@@ -279,6 +279,107 @@ struct GossipSyncManagerTests {
#expect(sentPackets.count == 1)
#expect(sentPackets[0].type == MessageType.fragment.rawValue)
}
@Test func handleRequestSyncHonorsSinceTimestamp() async throws {
var config = GossipSyncManager.Config()
config.seenCapacity = 5
config.fragmentCapacity = 0
config.fileTransferCapacity = 0
config.messageSyncIntervalSeconds = 0
config.fragmentSyncIntervalSeconds = 0
config.fileTransferSyncIntervalSeconds = 0
let requestSyncManager = RequestSyncManager()
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
let delegate = RecordingDelegate()
manager.delegate = delegate
let sender = try #require(Data(hexString: "aabbccddeeff0011"))
let threshold = UInt64(Date().timeIntervalSince1970 * 1000)
let oldMessage = BitchatPacket(
type: MessageType.message.rawValue,
senderID: sender,
recipientID: nil,
timestamp: threshold - 1,
payload: Data([0x10]),
signature: nil,
ttl: 1
)
let freshMessage = BitchatPacket(
type: MessageType.message.rawValue,
senderID: sender,
recipientID: nil,
timestamp: threshold,
payload: Data([0x20]),
signature: nil,
ttl: 1
)
manager.onPublicPacketSeen(oldMessage)
manager.onPublicPacketSeen(freshMessage)
let peer = PeerID(str: "FFFFFFFFFFFFFFFF")
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .message, sinceTimestamp: threshold)
manager.handleRequestSync(from: peer, request: request)
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
let sentPacket = try #require(delegate.packets.first)
#expect(sentPacket.payload == Data([0x20]))
}
@Test func buildGcsPayloadHonorsSinceTimestamp() throws {
var config = GossipSyncManager.Config()
config.seenCapacity = 5
config.fragmentCapacity = 0
config.fileTransferCapacity = 0
config.messageSyncIntervalSeconds = 0
config.fragmentSyncIntervalSeconds = 0
config.fileTransferSyncIntervalSeconds = 0
config.gcsTargetFpr = 0.000001
let requestSyncManager = RequestSyncManager()
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
let sender = try #require(Data(hexString: "1122334455667788"))
let threshold = UInt64(Date().timeIntervalSince1970 * 1000)
let oldMessage = BitchatPacket(
type: MessageType.message.rawValue,
senderID: sender,
recipientID: nil,
timestamp: threshold - 1,
payload: Data([0xA0]),
signature: nil,
ttl: 1
)
let freshMessage = BitchatPacket(
type: MessageType.message.rawValue,
senderID: sender,
recipientID: nil,
timestamp: threshold + 1,
payload: Data([0xB0]),
signature: nil,
ttl: 1
)
manager.onPublicPacketSeen(oldMessage)
manager.onPublicPacketSeen(freshMessage)
manager._performMaintenanceSynchronously()
let payload = manager._buildGcsPayloadSynchronously(for: .message, sinceTimestamp: threshold)
let request = try #require(RequestSyncPacket.decode(from: payload))
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
let oldBucket = GCSFilter.bucket(for: PacketIdUtil.computeId(oldMessage), modulus: request.m)
let freshBucket = GCSFilter.bucket(for: PacketIdUtil.computeId(freshMessage), modulus: request.m)
#expect(request.types?.contains(.message) == true)
#expect(request.sinceTimestamp == threshold)
#expect(GCSFilter.contains(sortedValues: sorted, candidate: freshBucket))
#expect(GCSFilter.contains(sortedValues: sorted, candidate: oldBucket) == false)
}
}
private final class RecordingDelegate: GossipSyncManager.Delegate {
-153
View File
@@ -289,159 +289,6 @@ struct NostrProtocolTests {
#expect(object["limit"] as? Int == 42)
}
// MARK: - Padding (v3 envelope)
@Test func paddedLengthMatchesNIP44Buckets() {
// Vectors from the NIP-44 reference test suite (calc_padded_len).
let vectors: [(Int, Int)] = [
(1, 32), (16, 32), (32, 32), (33, 64), (37, 64), (45, 64), (49, 64),
(64, 64), (65, 96), (100, 128), (111, 128), (200, 224), (250, 256),
(320, 320), (383, 384), (384, 384), (400, 448), (500, 512),
(512, 512), (515, 640), (700, 768), (800, 896), (900, 1024),
(1020, 1024), (65535, 65536)
]
for (unpadded, expected) in vectors {
#expect(
NIP44Padding.paddedLength(for: unpadded) == expected,
"paddedLength(for: \(unpadded)) should be \(expected)"
)
}
}
@Test func padUnpadRoundTrip() throws {
for length in [1, 2, 31, 32, 33, 100, 320, 1020, 4096, 65535] {
let plaintext = Data((0..<length).map { _ in UInt8.random(in: .min ... .max) })
let padded = try NIP44Padding.pad(plaintext)
#expect(padded.count == 2 + NIP44Padding.paddedLength(for: length))
let unpadded = try NIP44Padding.unpad(padded)
#expect(unpadded == plaintext)
}
}
@Test func padHidesExactLengthWithinBucket() throws {
// Two plaintexts of different length in the same bucket must produce
// identically sized padded payloads (and thus ciphertexts).
let short = try NIP44Padding.pad(Data(repeating: 0x41, count: 65))
let long = try NIP44Padding.pad(Data(repeating: 0x42, count: 96))
#expect(short.count == long.count)
}
@Test func padRejectsOutOfRangePlaintexts() {
#expect(throws: (any Error).self) { try NIP44Padding.pad(Data()) }
#expect(throws: (any Error).self) { try NIP44Padding.pad(Data(count: 65536)) }
}
@Test func unpadRejectsTamperedLengthPrefix() throws {
var padded = try NIP44Padding.pad(Data(repeating: 0x41, count: 40))
// Claimed length larger than the actual payload
var tooLong = padded
tooLong[tooLong.startIndex] = 0xFF
tooLong[tooLong.startIndex + 1] = 0xFF
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(tooLong) }
// Claimed length of zero
var zero = padded
zero[zero.startIndex] = 0x00
zero[zero.startIndex + 1] = 0x00
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(zero) }
// Claimed length whose bucket does not match the payload size
// (payload is bucket 64; a claimed length of 20 expects bucket 32)
var wrongBucket = padded
wrongBucket[wrongBucket.startIndex] = 0x00
wrongBucket[wrongBucket.startIndex + 1] = 0x14
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(wrongBucket) }
// Truncated payloads
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(Data()) }
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(Data([0x00])) }
padded.removeLast()
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(padded) }
// Works on Data slices with non-zero startIndex
let sliced = try (Data([0xAB]) + NIP44Padding.pad(Data(repeating: 0x41, count: 40))).dropFirst()
#expect(try NIP44Padding.unpad(sliced) == Data(repeating: 0x41, count: 40))
}
@Test func paddedEnvelopeRoundTrip_v3() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let plaintext = "padded envelope test"
let ciphertext = try NostrProtocol.encrypt(
plaintext: plaintext,
recipientPubkey: recipient.publicKeyHex,
senderKey: sender.schnorrSigningKey(),
padded: true
)
#expect(ciphertext.hasPrefix("v3:"))
let decrypted = try NostrProtocol.decrypt(
ciphertext: ciphertext,
senderPubkey: sender.publicKeyHex,
recipientKey: recipient.schnorrSigningKey()
)
#expect(decrypted == plaintext)
}
@Test func legacyUnpaddedEnvelopeStillDecrypts_v2() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let plaintext = "legacy v2 envelope"
// What deployed clients send today.
let ciphertext = try NostrProtocol.encrypt(
plaintext: plaintext,
recipientPubkey: recipient.publicKeyHex,
senderKey: sender.schnorrSigningKey(),
padded: false
)
#expect(ciphertext.hasPrefix("v2:"))
let decrypted = try NostrProtocol.decrypt(
ciphertext: ciphertext,
senderPubkey: sender.publicKeyHex,
recipientKey: recipient.schnorrSigningKey()
)
#expect(decrypted == plaintext)
}
@Test func outgoingMessagesStillUseV2UntilRolloutFlagFlips() throws {
// Deployed clients reject anything that is not "v2:", so the padded
// envelope must stay off by default until decrypt-side support is
// widely shipped (see NostrProtocol.sendPaddedEnvelope).
#expect(NostrProtocol.sendPaddedEnvelope == false)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let giftWrap = try NostrProtocol.createPrivateMessage(
content: "default envelope",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
#expect(giftWrap.content.hasPrefix("v2:"))
}
@Test func decryptRejectsUnknownEnvelopeVersion() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let ciphertext = try NostrProtocol.encrypt(
plaintext: "test",
recipientPubkey: recipient.publicKeyHex,
senderKey: sender.schnorrSigningKey(),
padded: false
)
let mutated = "v9:" + ciphertext.dropFirst(3)
#expect(throws: NostrError.invalidCiphertext) {
_ = try NostrProtocol.decrypt(
ciphertext: mutated,
senderPubkey: sender.publicKeyHex,
recipientKey: recipient.schnorrSigningKey()
)
}
}
// MARK: - Helpers
private static func base64URLDecode(_ s: String) -> Data? {
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
@@ -0,0 +1,118 @@
//
// RequestSyncPacketTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import Testing
import BitFoundation
@testable import bitchat
struct RequestSyncPacketTests {
@Test func baseFieldsRoundTrip() throws {
let original = RequestSyncPacket(
p: 7,
m: 12_800,
data: Data([1, 2, 3, 4, 5])
)
let decoded = try #require(RequestSyncPacket.decode(from: original.encode()))
#expect(decoded.p == 7)
#expect(decoded.m == 12_800)
#expect(decoded.data == Data([1, 2, 3, 4, 5]))
#expect(decoded.types == nil)
#expect(decoded.sinceTimestamp == nil)
}
@Test func upgradedFieldsRoundTripAsAndroidWantedTypes() throws {
let original = RequestSyncPacket(
p: 8,
m: 25_600,
data: Data([10, 20, 30]),
types: .publicMessages,
sinceTimestamp: 1_700_000_000_000
)
let encoded = original.encode()
let wantedTypes = try #require(tlvValue(type: 0x04, in: encoded))
let decoded = try #require(RequestSyncPacket.decode(from: encoded))
#expect(wantedTypes == Data([MessageType.announce.rawValue, MessageType.message.rawValue]))
#expect(decoded.p == 8)
#expect(decoded.m == 25_600)
#expect(decoded.data == Data([10, 20, 30]))
#expect(decoded.types?.contains(.announce) == true)
#expect(decoded.types?.contains(.message) == true)
#expect(decoded.sinceTimestamp == 1_700_000_000_000)
}
@Test func decodesLegacyPayloadWithoutUpgradeFields() throws {
let payload = Data([
0x01, 0x00, 0x01, 0x07,
0x02, 0x00, 0x04, 0x00, 0x00, 0x32, 0x00,
0x03, 0x00, 0x03, 0x01, 0x02, 0x03
])
let decoded = try #require(RequestSyncPacket.decode(from: payload))
#expect(decoded.p == 7)
#expect(decoded.m == 12_800)
#expect(decoded.data == Data([1, 2, 3]))
#expect(decoded.types == nil)
#expect(decoded.sinceTimestamp == nil)
}
@Test func decodesAndroidWantedTypesAndMinTimestamp() throws {
let payload = Data([
0x01, 0x00, 0x01, 0x08,
0x02, 0x00, 0x04, 0x00, 0x00, 0x64, 0x00,
0x03, 0x00, 0x02, 0xAA, 0xBB,
0x04, 0x00, 0x02, MessageType.announce.rawValue, MessageType.message.rawValue,
0x05, 0x00, 0x08, 0x00, 0x00, 0x01, 0x8B, 0xCF, 0xE5, 0x68, 0x00
])
let decoded = try #require(RequestSyncPacket.decode(from: payload))
#expect(decoded.p == 8)
#expect(decoded.m == 25_600)
#expect(decoded.data == Data([0xAA, 0xBB]))
#expect(decoded.types?.contains(.announce) == true)
#expect(decoded.types?.contains(.message) == true)
#expect(decoded.sinceTimestamp == 1_700_000_000_000)
}
@Test func decodesLegacyIOSPublicMessageBitfield() throws {
let payload = Data([
0x01, 0x00, 0x01, 0x08,
0x02, 0x00, 0x04, 0x00, 0x00, 0x64, 0x00,
0x03, 0x00, 0x00,
0x04, 0x00, 0x01, 0x03
])
let decoded = try #require(RequestSyncPacket.decode(from: payload))
#expect(decoded.types?.contains(.announce) == true)
#expect(decoded.types?.contains(.message) == true)
}
private func tlvValue(type: UInt8, in data: Data) -> Data? {
var offset = 0
while offset + 3 <= data.count {
let currentType = data[offset]
offset += 1
let length = (Int(data[offset]) << 8) | Int(data[offset + 1])
offset += 2
guard offset + length <= data.count else { return nil }
let value = data.subdata(in: offset..<(offset + length))
offset += length
if currentType == type {
return value
}
}
return nil
}
}
@@ -8,44 +8,12 @@
import struct Foundation.Data
/// Lowercase hex digits used by `hexEncodedString()`.
private let hexDigits: [UInt8] = Array("0123456789abcdef".utf8)
/// Maps an ASCII byte to its hex nibble value, or nil for non-hex characters.
/// Accepts both lowercase and uppercase hex digits.
@inline(__always)
private func hexNibble(_ ascii: UInt8) -> UInt8? {
switch ascii {
case UInt8(ascii: "0")...UInt8(ascii: "9"):
return ascii - UInt8(ascii: "0")
case UInt8(ascii: "a")...UInt8(ascii: "f"):
return ascii - UInt8(ascii: "a") + 10
case UInt8(ascii: "A")...UInt8(ascii: "F"):
return ascii - UInt8(ascii: "A") + 10
default:
return nil
}
}
public extension Data {
/// Lowercase hex representation of the bytes.
///
/// Lookup-table based: this sits on the hot BLE receive path (it is called
/// several times per received packet via `PeerID(hexData:)`), where the
/// previous per-byte `String(format: "%02x", _)` implementation spent most
/// of its time re-parsing the format string through Foundation.
func hexEncodedString() -> String {
if isEmpty {
if self.isEmpty {
return ""
}
var output = [UInt8](repeating: 0, count: count * 2)
var i = 0
for byte in self {
output[i] = hexDigits[Int(byte >> 4)]
output[i + 1] = hexDigits[Int(byte & 0x0F)]
i += 2
}
return String(decoding: output, as: UTF8.self)
return self.map { String(format: "%02x", $0) }.joined()
}
/// Initialize Data from a hex string.
@@ -60,28 +28,28 @@ public extension Data {
hex = String(hex.dropFirst(2))
}
let ascii = Array(hex.utf8)
// Reject odd-length strings
guard ascii.count % 2 == 0 else {
guard hex.count % 2 == 0 else {
return nil
}
// Accept empty strings
guard !ascii.isEmpty else {
// Reject empty strings
guard !hex.isEmpty else {
self = Data()
return
}
var data = Data(capacity: ascii.count / 2)
var index = 0
while index < ascii.count {
guard let high = hexNibble(ascii[index]),
let low = hexNibble(ascii[index + 1]) else {
let len = hex.count / 2
var data = Data(capacity: len)
var index = hex.startIndex
for _ in 0..<len {
let nextIndex = hex.index(index, offsetBy: 2)
guard let byte = UInt8(String(hex[index..<nextIndex]), radix: 16) else {
return nil
}
data.append((high << 4) | low)
index += 2
data.append(byte)
index = nextIndex
}
self = data
@@ -1,78 +0,0 @@
//
// DataHexTests.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 BitFoundation
struct DataHexTests {
// MARK: - Encoding
@Test func encode_knownVectors() {
#expect(Data().hexEncodedString() == "")
#expect(Data([0x00]).hexEncodedString() == "00")
#expect(Data([0x0f]).hexEncodedString() == "0f")
#expect(Data([0xf0]).hexEncodedString() == "f0")
#expect(Data([0xff]).hexEncodedString() == "ff")
#expect(Data([0xde, 0xad, 0xbe, 0xef]).hexEncodedString() == "deadbeef")
#expect(Data([0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef]).hexEncodedString() == "0123456789abcdef")
}
@Test func encode_allByteValues_matchesFormatReference() {
let all = Data((0...255).map { UInt8($0) })
let reference = (0...255).map { String(format: "%02x", $0) }.joined()
#expect(all.hexEncodedString() == reference)
}
@Test func encode_worksOnDataSlices() {
let data = Data([0xaa, 0xde, 0xad, 0xbe, 0xef, 0xbb])
let slice = data.dropFirst().dropLast()
#expect(slice.hexEncodedString() == "deadbeef")
}
// MARK: - Decoding
@Test func decode_knownVectors() {
#expect(Data(hexString: "deadbeef") == Data([0xde, 0xad, 0xbe, 0xef]))
#expect(Data(hexString: "DEADBEEF") == Data([0xde, 0xad, 0xbe, 0xef]))
#expect(Data(hexString: "DeAdBeEf") == Data([0xde, 0xad, 0xbe, 0xef]))
#expect(Data(hexString: "00") == Data([0x00]))
#expect(Data(hexString: "0123456789abcdef") == Data([0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef]))
}
@Test func decode_handlesPrefixAndWhitespace() {
#expect(Data(hexString: "0xdeadbeef") == Data([0xde, 0xad, 0xbe, 0xef]))
#expect(Data(hexString: "0XDEADBEEF") == Data([0xde, 0xad, 0xbe, 0xef]))
#expect(Data(hexString: " deadbeef\n") == Data([0xde, 0xad, 0xbe, 0xef]))
#expect(Data(hexString: "") == Data())
#expect(Data(hexString: "0x") == Data())
}
@Test func decode_rejectsInvalidInput() {
#expect(Data(hexString: "abc") == nil) // odd length
#expect(Data(hexString: "zz") == nil) // non-hex characters
#expect(Data(hexString: "0xg1") == nil) // non-hex after prefix
#expect(Data(hexString: "+f") == nil) // sign characters are not hex
#expect(Data(hexString: "-0") == nil)
#expect(Data(hexString: "a\u{00e9}") == nil) // non-ASCII
#expect(Data(hexString: "de ad") == nil) // interior whitespace
}
// MARK: - Round trip
@Test func roundTrip_randomLengths() {
for length in [0, 1, 2, 3, 8, 16, 31, 32, 33, 64, 255, 1024] {
let data = Data((0..<length).map { _ in UInt8.random(in: .min ... .max) })
let hex = data.hexEncodedString()
#expect(hex.count == length * 2)
#expect(Data(hexString: hex) == data)
#expect(Data(hexString: hex.uppercased()) == data)
}
}
}