Merge remote-tracking branch 'origin/feat/wifi-bulk-transport' into feat/integration-all

# Conflicts:
#	bitchat/Protocols/BitchatProtocol.swift
#	bitchat/Protocols/PeerCapabilities+Local.swift
#	bitchat/ViewModels/ChatTransportEventCoordinator.swift
#	bitchat/ViewModels/NostrInboundPipeline.swift
This commit is contained in:
jack
2026-07-06 22:18:59 +02:00
22 changed files with 2717 additions and 24 deletions
+45
View File
@@ -216,6 +216,51 @@ struct BLEServiceCoreTests {
cachedServiceUUIDs: [BLEService.serviceUUID, otherService]
))
}
// Regression: a stable/verified private chat addresses the peer by its
// full 64-hex Noise key, but the Noise session (and capabilities/
// connection) are keyed by the short routing ID. The Wi-Fi bulk send path
// must normalize the ID first, or `hasEstablishedSession` misses and
// Wi-Fi is never offered for a large payload to a direct `.wifiBulk` peer.
@Test
func wifiBulkEligibility_resolvesWhenAddressedBy64HexNoiseKey() throws {
let ble = makeService()
let noiseKey = Data((0..<32).map { UInt8(($0 &* 7) &+ 3) })
let fullKey = PeerID(str: noiseKey.hexEncodedString()) // 64-hex Noise key
#expect(fullKey.noiseKey != nil)
let shortID = fullKey.toShort() // 16-hex routing ID
#expect(shortID != fullKey)
// Establish a real Noise session in the service's noise engine, keyed
// by the short routing ID (exactly as a completed handshake would).
let peer = NoiseEncryptionService(keychain: MockKeychain())
let noise = ble._test_noiseService
let m1 = try noise.initiateHandshake(with: shortID)
let m2 = try #require(try peer.processHandshakeMessage(from: shortID, message: m1))
let m3 = try #require(try noise.processHandshakeMessage(from: shortID, message: m2))
_ = try peer.processHandshakeMessage(from: shortID, message: m3)
#expect(noise.hasEstablishedSession(with: shortID))
// The bug in raw form: querying by the 64-hex key misses the session.
#expect(!noise.hasEstablishedSession(with: fullKey))
// Register the peer as a directly-connected `.wifiBulk` neighbor.
ble._test_registerConnectedPeer(fullKey, capabilities: [.wifiBulk])
let bigPayload = FileTransferLimits.maxWifiBulkPayloadBytes
// The send path normalizes first, so eligibility + offer resolve for
// both the short ID and the full 64-hex Noise key.
let viaShort = ble._test_wifiBulkSendCandidate(payloadBytes: bigPayload, to: shortID)
let viaFull = ble._test_wifiBulkSendCandidate(payloadBytes: bigPayload, to: fullKey)
#expect(viaShort.hasEstablishedNoiseSession)
#expect(viaFull.hasEstablishedNoiseSession)
#expect(viaFull.isDirectlyConnected)
#expect(viaFull.peerCapabilities.contains(.wifiBulk))
#expect(WifiBulkPolicy.shouldOffer(viaShort, enabled: true))
#expect(WifiBulkPolicy.shouldOffer(viaFull, enabled: true))
}
}
private func makeService() -> BLEService {
@@ -0,0 +1,264 @@
//
// WifiBulkCryptoTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import CryptoKit
import BitFoundation
import Foundation
import Testing
@testable import bitchat
@Suite("WifiBulk channel crypto")
struct WifiBulkCryptoTests {
private static func keyHex(_ key: SymmetricKey) -> String {
key.withUnsafeBytes { Data($0).map { String(format: "%02x", $0) }.joined() }
}
private func makeKey() throws -> SymmetricKey {
try #require(WifiBulkCrypto.deriveKey(
senderToken: Data(repeating: 0x11, count: 32),
receiverToken: Data(repeating: 0x22, count: 32),
transferID: Data(repeating: 0x33, count: 16)
))
}
// MARK: HKDF derivation
@Test("HKDF derivation matches fixed vectors")
func hkdfVectors() throws {
// Vectors computed independently with CryptoKit HKDF<SHA256>,
// ikm = senderToken receiverToken, salt = transferID,
// info = "bitchat-bulk-v1", 32 bytes out.
let key1 = try makeKey()
#expect(Self.keyHex(key1) == "9ee6f4bf7753a8a9564d6760b7064e31657f1a6bcca2b3ff266bb975cc4f66eb")
let key2 = try #require(WifiBulkCrypto.deriveKey(
senderToken: Data((0..<32).map { UInt8($0) }),
receiverToken: Data((0..<32).map { UInt8(255 - $0) }),
transferID: Data((0..<16).map { UInt8($0 * 3) })
))
#expect(Self.keyHex(key2) == "432ebb559f2f546d632a91d53b5c25af36f15d1ba53917910a0041329dc0efd4")
}
@Test("HKDF derivation is order- and role-sensitive")
func hkdfRoleSensitivity() throws {
let a = Data(repeating: 0x11, count: 32)
let b = Data(repeating: 0x22, count: 32)
let tid = Data(repeating: 0x33, count: 16)
let forward = try #require(WifiBulkCrypto.deriveKey(senderToken: a, receiverToken: b, transferID: tid))
let reversed = try #require(WifiBulkCrypto.deriveKey(senderToken: b, receiverToken: a, transferID: tid))
#expect(Self.keyHex(forward) != Self.keyHex(reversed))
}
@Test("HKDF derivation rejects wrong-length inputs")
func hkdfRejectsBadLengths() {
let token = Data(repeating: 1, count: 32)
let tid = Data(repeating: 2, count: 16)
#expect(WifiBulkCrypto.deriveKey(senderToken: Data(count: 31), receiverToken: token, transferID: tid) == nil)
#expect(WifiBulkCrypto.deriveKey(senderToken: token, receiverToken: Data(count: 33), transferID: tid) == nil)
#expect(WifiBulkCrypto.deriveKey(senderToken: token, receiverToken: token, transferID: Data(count: 15)) == nil)
}
// MARK: Frame sealing
@Test("Frame seal/open round-trips")
func frameRoundTrip() throws {
let key = try makeKey()
let plaintext = Data((0..<1000).map { UInt8($0 % 251) })
let body = try WifiBulkCrypto.sealFrameBody(plaintext, direction: .senderToReceiver, counter: 7, key: key)
let opened = try WifiBulkCrypto.openFrameBody(body, direction: .senderToReceiver, counter: 7, key: key)
#expect(opened == plaintext)
}
@Test("Tampered frames are rejected")
func tamperRejection() throws {
let key = try makeKey()
var body = try WifiBulkCrypto.sealFrameBody(Data(repeating: 9, count: 64), direction: .senderToReceiver, counter: 0, key: key)
body[body.count - 1] ^= 0x01 // flip a tag bit
#expect(throws: WifiBulkCryptoError.authenticationFailed) {
try WifiBulkCrypto.openFrameBody(body, direction: .senderToReceiver, counter: 0, key: key)
}
}
@Test("Frames cannot be replayed at another counter or reflected across directions")
func nonceBinding() throws {
let key = try makeKey()
let body = try WifiBulkCrypto.sealFrameBody(Data(repeating: 9, count: 64), direction: .senderToReceiver, counter: 3, key: key)
#expect(throws: WifiBulkCryptoError.nonceMismatch) {
try WifiBulkCrypto.openFrameBody(body, direction: .senderToReceiver, counter: 4, key: key)
}
#expect(throws: WifiBulkCryptoError.nonceMismatch) {
try WifiBulkCrypto.openFrameBody(body, direction: .receiverToSender, counter: 3, key: key)
}
}
@Test("Frames sealed under a different key are rejected")
func wrongKeyRejection() throws {
let key = try makeKey()
let otherKey = try #require(WifiBulkCrypto.deriveKey(
senderToken: Data(repeating: 0x44, count: 32),
receiverToken: Data(repeating: 0x22, count: 32),
transferID: Data(repeating: 0x33, count: 16)
))
let body = try WifiBulkCrypto.sealFrameBody(Data(repeating: 9, count: 64), direction: .senderToReceiver, counter: 0, key: otherKey)
#expect(throws: WifiBulkCryptoError.authenticationFailed) {
try WifiBulkCrypto.openFrameBody(body, direction: .senderToReceiver, counter: 0, key: key)
}
}
@Test("Auth and receipt control frames validate and reject forgeries")
func controlFrames() throws {
let key = try makeKey()
let transferID = Data(repeating: 0x33, count: 16)
let hash = Data(repeating: 0x55, count: 32)
let auth = try WifiBulkCrypto.makeClientAuthFrameBody(transferID: transferID, key: key)
#expect(WifiBulkCrypto.validateClientAuthFrameBody(auth, transferID: transferID, key: key))
#expect(!WifiBulkCrypto.validateClientAuthFrameBody(auth, transferID: Data(repeating: 0x34, count: 16), key: key))
var forgedAuth = auth
forgedAuth[forgedAuth.count - 1] ^= 0x01
#expect(!WifiBulkCrypto.validateClientAuthFrameBody(forgedAuth, transferID: transferID, key: key))
let receipt = try WifiBulkCrypto.makeReceiptFrameBody(payloadHash: hash, key: key)
#expect(WifiBulkCrypto.validateReceiptFrameBody(receipt, payloadHash: hash, key: key))
#expect(!WifiBulkCrypto.validateReceiptFrameBody(receipt, payloadHash: Data(repeating: 0x56, count: 32), key: key))
// An auth frame is not a receipt (distinct counter).
#expect(!WifiBulkCrypto.validateReceiptFrameBody(auth, payloadHash: hash, key: key))
}
// MARK: Frame buffer
@Test("Frame buffer reassembles frames from arbitrary byte boundaries")
func frameBufferReassembly() throws {
let bodyA = Data(repeating: 0xAA, count: 100)
let bodyB = Data(repeating: 0xBB, count: 5)
var stream = WifiBulkCrypto.frameData(body: bodyA)
stream.append(WifiBulkCrypto.frameData(body: bodyB))
let buffer = WifiBulkFrameBuffer(maxBodyBytes: 1024)
// Drip-feed 3 bytes at a time.
var extracted: [Data] = []
var index = stream.startIndex
while index < stream.endIndex {
let next = stream.index(index, offsetBy: 3, limitedBy: stream.endIndex) ?? stream.endIndex
buffer.append(Data(stream[index..<next]))
while let body = try buffer.nextFrameBody() {
extracted.append(body)
}
index = next
}
#expect(extracted == [bodyA, bodyB])
#expect(try buffer.nextFrameBody() == nil)
}
@Test("Frame buffer rejects oversized frame lengths without buffering them")
func frameBufferOversizeRejection() {
let buffer = WifiBulkFrameBuffer(maxBodyBytes: 64)
buffer.append(WifiBulkCrypto.frameData(body: Data(repeating: 1, count: 65)).prefix(8))
#expect(throws: WifiBulkCryptoError.frameTooLarge) {
_ = try buffer.nextFrameBody()
}
}
// MARK: Payload assembler
private func sealedChunks(_ payload: Data, chunkSize: Int, key: SymmetricKey) throws -> [Data] {
try stride(from: 0, to: payload.count, by: chunkSize).enumerated().map { index, offset in
try WifiBulkCrypto.sealFrameBody(
Data(payload[offset..<min(offset + chunkSize, payload.count)]),
direction: .senderToReceiver,
counter: UInt64(index),
key: key
)
}
}
@Test("Assembler reassembles and verifies a chunked payload")
func assemblerHappyPath() throws {
let key = try makeKey()
let payload = Data((0..<200_000).map { UInt8($0 % 253) })
let assembler = try #require(WifiBulkPayloadAssembler(
key: key,
expectedSize: UInt64(payload.count),
expectedHash: Data(SHA256.hash(data: payload)),
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
))
var result: Data?
for chunk in try sealedChunks(payload, chunkSize: 64 * 1024, key: key) {
result = try assembler.consume(frameBody: chunk)
}
#expect(result == payload)
}
@Test("Assembler rejects a payload whose final hash mismatches the offer")
func assemblerHashMismatch() throws {
let key = try makeKey()
let payload = Data(repeating: 0x77, count: 100_000)
let assembler = try #require(WifiBulkPayloadAssembler(
key: key,
expectedSize: UInt64(payload.count),
expectedHash: Data(repeating: 0, count: 32), // wrong hash
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
))
let chunks = try sealedChunks(payload, chunkSize: 64 * 1024, key: key)
_ = try assembler.consume(frameBody: chunks[0])
#expect(throws: WifiBulkCryptoError.hashMismatch) {
_ = try assembler.consume(frameBody: chunks[1])
}
}
@Test("Assembler rejects overflow beyond the offered size")
func assemblerOverflow() throws {
let key = try makeKey()
let payload = Data(repeating: 0x77, count: 1000)
let assembler = try #require(WifiBulkPayloadAssembler(
key: key,
expectedSize: 500, // offer promised less than the sender streams
expectedHash: Data(SHA256.hash(data: payload)),
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
))
let chunk = try WifiBulkCrypto.sealFrameBody(payload, direction: .senderToReceiver, counter: 0, key: key)
#expect(throws: WifiBulkCryptoError.payloadOverflow) {
_ = try assembler.consume(frameBody: chunk)
}
}
@Test("Assembler enforces the receiver-side size cap at construction")
func assemblerSizeCap() throws {
let key = try makeKey()
#expect(WifiBulkPayloadAssembler(
key: key,
expectedSize: UInt64(FileTransferLimits.maxWifiBulkPayloadBytes) + 1,
expectedHash: Data(repeating: 0, count: 32),
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
) == nil)
#expect(WifiBulkPayloadAssembler(
key: key,
expectedSize: 0,
expectedHash: Data(repeating: 0, count: 32),
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
) == nil)
}
@Test("Assembler rejects out-of-order chunks")
func assemblerOutOfOrder() throws {
let key = try makeKey()
let payload = Data(repeating: 0x42, count: 100_000)
let assembler = try #require(WifiBulkPayloadAssembler(
key: key,
expectedSize: UInt64(payload.count),
expectedHash: Data(SHA256.hash(data: payload)),
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
))
let chunks = try sealedChunks(payload, chunkSize: 64 * 1024, key: key)
#expect(throws: WifiBulkCryptoError.nonceMismatch) {
_ = try assembler.consume(frameBody: chunks[1]) // skip chunk 0
}
}
}
@@ -0,0 +1,246 @@
//
// WifiBulkLoopbackTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import CryptoKit
import BitFoundation
import Foundation
import Network
import Testing
@testable import bitchat
/// End-to-end integration over real Network.framework sockets on localhost:
/// two `WifiBulkTransferService` instances negotiate through an in-process
/// "Noise" pipe, then move the payload over a genuine TCP connection using
/// the production listener/browser-free test hooks (peer-to-peer and Bonjour
/// are disabled unit-test hosts have no AWDL or mDNS access).
@Suite("WifiBulk loopback integration", .serialized)
struct WifiBulkLoopbackTests {
private static let senderPeer = PeerID(str: "00112233aabbccdd")
private static let receiverPeer = PeerID(str: "ddccbbaa33221100")
private func makeConfig() -> WifiBulkTransferServiceConfig {
var config = WifiBulkTransferServiceConfig()
config.usePeerToPeer = false
config.publishBonjourService = false
config.offerTimeout = 5.0
config.transferWindow = 10.0
return config
}
@Test("Payload crosses a real TCP loopback channel and lands verified")
func loopbackTransferSucceeds() async throws {
let payload = Data((0..<300_000).map { UInt8($0 % 249) })
let delivered = WifiBulkTestBox<Data>()
let progress = WifiBulkTestBox<String>()
let fallbacks = WifiBulkTestBox<Bool>()
let ports = WifiBulkTestBox<(Data, UInt16)>()
let offers = WifiBulkTestBox<Data>()
var senderService: WifiBulkTransferService?
var receiverService: WifiBulkTransferService?
// Once the receiver has accepted AND the listener port is known,
// connect the receiver straight to 127.0.0.1 (Bonjour stand-in).
let accepted = WifiBulkTestBox<Data>()
let connectIfReady: () -> Void = {
guard let (transferID, port) = ports.values.first,
accepted.values.contains(transferID),
let nwPort = NWEndpoint.Port(rawValue: port) else { return }
receiverService?._test_connectIncoming(
transferID: transferID,
to: NWEndpoint.hostPort(host: "127.0.0.1", port: nwPort)
)
}
let senderEnv = WifiBulkTransferServiceEnvironment(
sendNoisePayload: { typed, _ in
// Sender receiver control plane (offer).
guard typed.first == NoisePayloadType.bulkTransferOffer.rawValue else { return true }
offers.append(Data(typed.dropFirst()))
receiverService?.handleOfferPayload(Data(typed.dropFirst()), from: Self.senderPeer)
return true
},
isPeerConnected: { _ in true },
deliverReceivedFile: { _, _, _ in },
progressStart: { id, total in progress.append("start:\(id):\(total)") },
progressChunkSent: { id in progress.append("chunk:\(id)") },
progressReset: { id in progress.append("reset:\(id)") },
progressCancel: { id in progress.append("cancel:\(id)") }
)
let receiverEnv = WifiBulkTransferServiceEnvironment(
sendNoisePayload: { typed, _ in
// Receiver sender control plane (response).
guard typed.first == NoisePayloadType.bulkTransferResponse.rawValue else { return true }
let body = Data(typed.dropFirst())
if let response = WifiBulkResponse.decode(body), response.accepted {
accepted.append(response.transferID)
}
senderService?.handleResponsePayload(body, from: Self.receiverPeer)
connectIfReady()
return true
},
isPeerConnected: { _ in true },
deliverReceivedFile: { data, peer, limit in
#expect(peer == Self.senderPeer)
#expect(limit == FileTransferLimits.maxWifiBulkPayloadBytes)
delivered.append(data)
},
progressStart: { _, _ in },
progressChunkSent: { _ in },
progressReset: { _ in },
progressCancel: { _ in }
)
let sender = WifiBulkTransferService(environment: senderEnv, config: makeConfig())
sender._test_onListenerReady = { transferID, port in
ports.append((transferID, port))
connectIfReady()
}
let receiver = WifiBulkTransferService(environment: receiverEnv, config: makeConfig())
senderService = sender
receiverService = receiver
sender.sendFile(payload: payload, to: Self.receiverPeer, transferId: "t-loopback") {
fallbacks.append(true)
}
#expect(await wifiBulkWait(timeout: 10.0) { delivered.count == 1 })
#expect(delivered.values.first == payload)
#expect(fallbacks.count == 0)
// Deterministic teardown: both sides drop all transfer state.
#expect(await wifiBulkWait { sender._test_activeOutgoingCount == 0 })
#expect(await wifiBulkWait { receiver._test_activeIncomingCount == 0 })
// Progress mirrored the BLE contract: start with the chunk total,
// then exactly `total` chunk ticks (the last one gated on the receipt).
let totalChunks = (payload.count + TransportConfig.wifiBulkChunkBytes - 1) / TransportConfig.wifiBulkChunkBytes
#expect(await wifiBulkWait { progress.values.filter { $0 == "chunk:t-loopback" }.count == totalChunks })
#expect(progress.values.first == "start:t-loopback:\(totalChunks)")
#expect(!progress.values.contains("reset:t-loopback"))
}
@Test("A gatecrasher without the channel key is disconnected and the real peer still succeeds")
func gatecrasherIsRejected() async throws {
let payload = Data((0..<150_000).map { UInt8($0 % 241) })
let delivered = WifiBulkTestBox<Data>()
let fallbacks = WifiBulkTestBox<Bool>()
let ports = WifiBulkTestBox<(Data, UInt16)>()
let accepted = WifiBulkTestBox<Data>()
var senderService: WifiBulkTransferService?
var receiverService: WifiBulkTransferService?
let gatecrashed = WifiBulkTestBox<Bool>()
let connectIfReady: () -> Void = {
guard let (transferID, port) = ports.values.first,
accepted.values.contains(transferID),
gatecrashed.count > 0,
let nwPort = NWEndpoint.Port(rawValue: port) else { return }
receiverService?._test_connectIncoming(
transferID: transferID,
to: NWEndpoint.hostPort(host: "127.0.0.1", port: nwPort)
)
}
let senderEnv = WifiBulkTransferServiceEnvironment(
sendNoisePayload: { typed, _ in
guard typed.first == NoisePayloadType.bulkTransferOffer.rawValue else { return true }
receiverService?.handleOfferPayload(Data(typed.dropFirst()), from: Self.senderPeer)
return true
},
isPeerConnected: { _ in true },
deliverReceivedFile: { _, _, _ in },
progressStart: { _, _ in },
progressChunkSent: { _ in },
progressReset: { _ in },
progressCancel: { _ in }
)
let receiverEnv = WifiBulkTransferServiceEnvironment(
sendNoisePayload: { typed, _ in
guard typed.first == NoisePayloadType.bulkTransferResponse.rawValue else { return true }
let body = Data(typed.dropFirst())
if let response = WifiBulkResponse.decode(body), response.accepted {
accepted.append(response.transferID)
}
senderService?.handleResponsePayload(body, from: Self.receiverPeer)
connectIfReady()
return true
},
isPeerConnected: { _ in true },
deliverReceivedFile: { data, _, _ in delivered.append(data) },
progressStart: { _, _ in },
progressChunkSent: { _ in },
progressReset: { _ in },
progressCancel: { _ in }
)
let sender = WifiBulkTransferService(environment: senderEnv, config: makeConfig())
let gateQueue = DispatchQueue(label: "test.gatecrasher")
sender._test_onListenerReady = { transferID, port in
ports.append((transferID, port))
guard let nwPort = NWEndpoint.Port(rawValue: port) else { return }
// A stranger who saw the Bonjour advertisement connects first and
// sends garbage that cannot carry a valid MAC.
let crasher = NWConnection(
to: NWEndpoint.hostPort(host: "127.0.0.1", port: nwPort),
using: .tcp
)
crasher.stateUpdateHandler = { state in
if case .ready = state {
let junkBody = Data(repeating: 0xAA, count: 60)
crasher.send(
content: WifiBulkCrypto.frameData(body: junkBody),
completion: .contentProcessed { _ in
gatecrashed.append(true)
connectIfReady()
}
)
}
}
crasher.start(queue: gateQueue)
}
let receiver = WifiBulkTransferService(environment: receiverEnv, config: makeConfig())
senderService = sender
receiverService = receiver
sender.sendFile(payload: payload, to: Self.receiverPeer, transferId: "t-gatecrash") {
fallbacks.append(true)
}
#expect(await wifiBulkWait(timeout: 10.0) { delivered.count == 1 })
#expect(delivered.values.first == payload)
#expect(fallbacks.count == 0)
#expect(await wifiBulkWait { sender._test_activeOutgoingCount == 0 })
}
@Test("Receiver vanishing mid-negotiation leaves the sender to time out into BLE")
func vanishedReceiverFallsBack() async {
var config = makeConfig()
config.offerTimeout = 0.3
let fallbacks = WifiBulkTestBox<Bool>()
let environment = WifiBulkTransferServiceEnvironment(
sendNoisePayload: { _, _ in true }, // offer sent, receiver never answers
isPeerConnected: { _ in true },
deliverReceivedFile: { _, _, _ in },
progressStart: { _, _ in },
progressChunkSent: { _ in },
progressReset: { _ in },
progressCancel: { _ in }
)
let sender = WifiBulkTransferService(environment: environment, config: config)
sender.sendFile(payload: Data(repeating: 1, count: 100_000), to: Self.receiverPeer, transferId: "t-vanish") {
fallbacks.append(true)
}
#expect(await wifiBulkWait { fallbacks.count == 1 })
#expect(sender._test_activeOutgoingCount == 0)
}
}
@@ -0,0 +1,107 @@
//
// WifiBulkMessagesTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
import Testing
@testable import bitchat
@Suite("WifiBulk offer/response TLV")
struct WifiBulkMessagesTests {
private func makeOffer(
fileSize: UInt64 = 300_000,
serviceName: String = "a1b2c3d4e5f60718a1b2c3d4e5f60718"
) -> WifiBulkOffer {
WifiBulkOffer(
transferID: Data(repeating: 0xAB, count: WifiBulkWire.transferIDLength),
fileSize: fileSize,
payloadHash: Data(repeating: 0xCD, count: WifiBulkWire.hashLength),
token: Data(repeating: 0xEF, count: WifiBulkWire.tokenLength),
serviceName: serviceName
)
}
@Test("Offer round-trips through TLV encoding")
func offerRoundTrip() throws {
let offer = makeOffer()
let encoded = try #require(offer.encode())
let decoded = try #require(WifiBulkOffer.decode(encoded))
#expect(decoded == offer)
}
@Test("Offer encode rejects malformed field lengths")
func offerEncodeRejectsBadFields() {
#expect(WifiBulkOffer(
transferID: Data(repeating: 1, count: 15), // short transferID
fileSize: 1,
payloadHash: Data(repeating: 2, count: 32),
token: Data(repeating: 3, count: 32),
serviceName: "x"
).encode() == nil)
#expect(makeOffer(serviceName: "").encode() == nil)
#expect(makeOffer(serviceName: String(repeating: "a", count: 64)).encode() == nil)
}
@Test("Offer decode rejects missing or wrong-length fields")
func offerDecodeRejectsMalformed() throws {
let encoded = try #require(makeOffer().encode())
// Truncation anywhere breaks a TLV boundary or drops a required field.
#expect(WifiBulkOffer.decode(encoded.dropLast(1)) == nil)
#expect(WifiBulkOffer.decode(encoded.prefix(3)) == nil)
#expect(WifiBulkOffer.decode(Data()) == nil)
// A wrong-length transferID TLV is ignored, leaving the field missing.
var mangled = Data([0x01, 0x00, 0x02, 0xAA, 0xBB]) // transferID of 2 bytes
mangled.append(encoded.dropFirst(3 + WifiBulkWire.transferIDLength))
#expect(WifiBulkOffer.decode(mangled) == nil)
}
@Test("Offer decode skips unknown TLVs for forward compatibility")
func offerDecodeSkipsUnknownTLVs() throws {
var encoded = try #require(makeOffer().encode())
encoded.append(contentsOf: [0x7F, 0x00, 0x03, 0x01, 0x02, 0x03]) // unknown type 0x7F
let decoded = try #require(WifiBulkOffer.decode(encoded))
#expect(decoded == makeOffer())
}
@Test("Accept response round-trips with token")
func acceptResponseRoundTrip() throws {
let response = WifiBulkResponse.accept(
transferID: Data(repeating: 0x11, count: WifiBulkWire.transferIDLength),
token: Data(repeating: 0x22, count: WifiBulkWire.tokenLength)
)
let encoded = try #require(response.encode())
let decoded = try #require(WifiBulkResponse.decode(encoded))
#expect(decoded == response)
#expect(decoded.accepted)
#expect(decoded.token?.count == WifiBulkWire.tokenLength)
}
@Test("Decline response round-trips without token")
func declineResponseRoundTrip() throws {
let response = WifiBulkResponse.decline(
transferID: Data(repeating: 0x11, count: WifiBulkWire.transferIDLength)
)
let encoded = try #require(response.encode())
let decoded = try #require(WifiBulkResponse.decode(encoded))
#expect(decoded == response)
#expect(!decoded.accepted)
#expect(decoded.token == nil)
}
@Test("Accept response without a token is rejected")
func acceptWithoutTokenRejected() throws {
// Hand-build: transferID + accepted=1, no token TLV.
var data = Data()
WifiBulkWire.appendTLV(0x01, value: Data(repeating: 0x11, count: 16), into: &data)
WifiBulkWire.appendTLV(0x02, value: Data([1]), into: &data)
#expect(WifiBulkResponse.decode(data) == nil)
}
}
@@ -0,0 +1,150 @@
//
// WifiBulkPolicyTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
import Testing
@testable import bitchat
@Suite("WifiBulk policy")
struct WifiBulkPolicyTests {
private func candidate(
payloadBytes: Int = 300_000,
capabilities: PeerCapabilities = [.wifiBulk],
direct: Bool = true,
session: Bool = true
) -> WifiBulkPolicy.SendCandidate {
WifiBulkPolicy.SendCandidate(
payloadBytes: payloadBytes,
peerCapabilities: capabilities,
isDirectlyConnected: direct,
hasEstablishedNoiseSession: session
)
}
@Test("Eligible large transfer to a direct wifiBulk peer is offered")
func eligibleTransferOffered() {
#expect(WifiBulkPolicy.shouldOffer(candidate(), enabled: true))
}
@Test("Fallback matrix: every failed gate keeps the transfer on BLE")
func fallbackMatrix() {
// Feature disabled.
#expect(!WifiBulkPolicy.shouldOffer(candidate(), enabled: false))
// Small file: negotiation overhead not worth it.
#expect(!WifiBulkPolicy.shouldOffer(candidate(payloadBytes: 64 * 1024), enabled: true))
// Peer doesn't advertise the capability.
#expect(!WifiBulkPolicy.shouldOffer(candidate(capabilities: []), enabled: true))
#expect(!WifiBulkPolicy.shouldOffer(candidate(capabilities: [.prekeys, .gateway]), enabled: true))
// Multi-hop recipient (reachable but not directly connected).
#expect(!WifiBulkPolicy.shouldOffer(candidate(direct: false), enabled: true))
// No established Noise session to carry the offer.
#expect(!WifiBulkPolicy.shouldOffer(candidate(session: false), enabled: true))
// Payload beyond even the Wi-Fi ceiling.
#expect(!WifiBulkPolicy.shouldOffer(
candidate(payloadBytes: FileTransferLimits.maxWifiBulkPayloadBytes + 1),
enabled: true
))
}
@Test("Offer threshold is strictly greater than the minimum")
func offerThresholdBoundary() {
#expect(!WifiBulkPolicy.shouldOffer(
candidate(payloadBytes: TransportConfig.wifiBulkMinPayloadBytes),
enabled: true
))
#expect(WifiBulkPolicy.shouldOffer(
candidate(payloadBytes: TransportConfig.wifiBulkMinPayloadBytes + 1),
enabled: true
))
}
private func offer(fileSize: UInt64) -> WifiBulkOffer {
WifiBulkOffer(
transferID: Data(repeating: 1, count: WifiBulkWire.transferIDLength),
fileSize: fileSize,
payloadHash: Data(repeating: 2, count: WifiBulkWire.hashLength),
token: Data(repeating: 3, count: WifiBulkWire.tokenLength),
serviceName: "0011223344556677"
)
}
@Test("Receiver accepts an in-cap offer from a direct peer")
func receiverAccepts() {
#expect(WifiBulkPolicy.shouldAccept(
offer: offer(fileSize: 1_000_000),
senderIsDirectlyConnected: true,
activeIncomingTransfers: 0,
enabled: true
))
}
@Test("Receiver enforces its own size cap, not the sender's word")
func receiverSizeCap() {
#expect(!WifiBulkPolicy.shouldAccept(
offer: offer(fileSize: UInt64(FileTransferLimits.maxWifiBulkPayloadBytes) + 1),
senderIsDirectlyConnected: true,
activeIncomingTransfers: 0,
enabled: true
))
#expect(WifiBulkPolicy.shouldAccept(
offer: offer(fileSize: UInt64(FileTransferLimits.maxWifiBulkPayloadBytes)),
senderIsDirectlyConnected: true,
activeIncomingTransfers: 0,
enabled: true
))
#expect(!WifiBulkPolicy.shouldAccept(
offer: offer(fileSize: 0),
senderIsDirectlyConnected: true,
activeIncomingTransfers: 0,
enabled: true
))
}
@Test("Receiver declines when disabled, indirect, or saturated")
func receiverDeclines() {
#expect(!WifiBulkPolicy.shouldAccept(
offer: offer(fileSize: 1000),
senderIsDirectlyConnected: true,
activeIncomingTransfers: 0,
enabled: false
))
#expect(!WifiBulkPolicy.shouldAccept(
offer: offer(fileSize: 1000),
senderIsDirectlyConnected: false,
activeIncomingTransfers: 0,
enabled: true
))
#expect(!WifiBulkPolicy.shouldAccept(
offer: offer(fileSize: 1000),
senderIsDirectlyConnected: true,
activeIncomingTransfers: TransportConfig.wifiBulkMaxConcurrentIncoming,
enabled: true
))
}
@Test("This build advertises the wifiBulk capability when enabled")
func localCapabilityAdvertised() {
#expect(PeerCapabilities.localSupported.contains(.wifiBulk) == TransportConfig.wifiBulkEnabled)
}
@Test("File packets above the BLE cap encode/decode only with the Wi-Fi limit")
func filePacketWifiLimit() throws {
let content = Data(repeating: 0x5A, count: 2 * 1024 * 1024) // 2 MiB
let packet = BitchatFilePacket(fileName: "big.jpg", fileSize: UInt64(content.count), mimeType: "image/jpeg", content: content)
// BLE cap unchanged.
#expect(packet.encode() == nil)
let encoded = try #require(packet.encode(limit: FileTransferLimits.maxWifiBulkPayloadBytes))
#expect(BitchatFilePacket.decode(encoded) == nil) // BLE-cap decode still rejects
let decoded = try #require(BitchatFilePacket.decode(encoded, limit: FileTransferLimits.maxWifiBulkPayloadBytes))
#expect(decoded.content == content)
#expect(decoded.fileName == "big.jpg")
}
}
@@ -0,0 +1,246 @@
//
// WifiBulkTransferServiceTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
import Network
import Testing
@testable import bitchat
/// Thread-safe capture box for closures invoked on service queues.
final class WifiBulkTestBox<Value>: @unchecked Sendable {
private let lock = NSLock()
private var storage: [Value] = []
func append(_ value: Value) {
lock.lock()
storage.append(value)
lock.unlock()
}
var values: [Value] {
lock.lock()
defer { lock.unlock() }
return storage
}
var count: Int { values.count }
}
func wifiBulkWait(
timeout: TimeInterval = 5.0,
_ condition: @escaping () -> Bool
) async -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if condition() { return true }
try? await Task.sleep(nanoseconds: 20_000_000)
}
return condition()
}
/// Negotiation/fallback decisions exercised through the real service with the
/// network kept on loopback and Bonjour publication disabled.
@Suite("WifiBulk transfer service negotiation", .serialized)
struct WifiBulkTransferServiceTests {
private static let peer = PeerID(str: "aabbccddeeff0011")
private func makeConfig() -> WifiBulkTransferServiceConfig {
var config = WifiBulkTransferServiceConfig()
config.usePeerToPeer = false
config.publishBonjourService = false
config.offerTimeout = 0.25
config.transferWindow = 2.0
return config
}
private func makeEnvironment(
sendNoisePayload: @escaping (Data, PeerID) -> Bool,
deliver: @escaping (Data, PeerID, Int) -> Void = { _, _, _ in },
progressEvents: WifiBulkTestBox<String>? = nil
) -> WifiBulkTransferServiceEnvironment {
WifiBulkTransferServiceEnvironment(
sendNoisePayload: sendNoisePayload,
isPeerConnected: { _ in true },
deliverReceivedFile: deliver,
progressStart: { id, total in progressEvents?.append("start:\(id):\(total)") },
progressChunkSent: { id in progressEvents?.append("chunk:\(id)") },
progressReset: { id in progressEvents?.append("reset:\(id)") },
progressCancel: { id in progressEvents?.append("cancel:\(id)") }
)
}
private var payload: Data { Data(repeating: 0x42, count: 100_000) }
@Test("No established Noise session falls straight back to BLE")
func noSessionFallsBack() async {
let fallbacks = WifiBulkTestBox<Bool>()
let service = WifiBulkTransferService(
environment: makeEnvironment(sendNoisePayload: { _, _ in false }),
config: makeConfig()
)
service.sendFile(payload: payload, to: Self.peer, transferId: "t-nosession") {
fallbacks.append(true)
}
#expect(await wifiBulkWait { fallbacks.count == 1 })
#expect(service._test_activeOutgoingCount == 0)
}
@Test("Unanswered offer times out and falls back exactly once")
func offerTimeoutFallsBackOnce() async {
let fallbacks = WifiBulkTestBox<Bool>()
let progress = WifiBulkTestBox<String>()
let service = WifiBulkTransferService(
environment: makeEnvironment(sendNoisePayload: { _, _ in true }, progressEvents: progress),
config: makeConfig()
)
service.sendFile(payload: payload, to: Self.peer, transferId: "t-timeout") {
fallbacks.append(true)
}
#expect(await wifiBulkWait { fallbacks.count == 1 })
// Wait past the transfer window: the expiry must not double-fire.
try? await Task.sleep(nanoseconds: 2_300_000_000)
#expect(fallbacks.count == 1)
#expect(service._test_activeOutgoingCount == 0)
// Progress state was silently reset ahead of the BLE re-start.
#expect(progress.values.contains("reset:t-timeout"))
#expect(!progress.values.contains("cancel:t-timeout"))
}
@Test("Declined offer falls back exactly once, even on duplicate declines")
func declineFallsBackOnce() async {
let fallbacks = WifiBulkTestBox<Bool>()
let offers = WifiBulkTestBox<Data>()
var service: WifiBulkTransferService?
let environment = makeEnvironment(sendNoisePayload: { typed, peer in
guard typed.first == NoisePayloadType.bulkTransferOffer.rawValue,
let offer = WifiBulkOffer.decode(typed.dropFirst()) else { return true }
offers.append(offer.transferID)
guard let decline = WifiBulkResponse.decline(transferID: offer.transferID).encode() else { return true }
// Deliver the decline twice; the fallback must still fire once.
service?.handleResponsePayload(decline, from: peer)
service?.handleResponsePayload(decline, from: peer)
return true
})
let sut = WifiBulkTransferService(environment: environment, config: makeConfig())
service = sut
sut.sendFile(payload: payload, to: Self.peer, transferId: "t-decline") {
fallbacks.append(true)
}
#expect(await wifiBulkWait { fallbacks.count == 1 })
try? await Task.sleep(nanoseconds: 300_000_000)
#expect(fallbacks.count == 1)
#expect(sut._test_activeOutgoingCount == 0)
}
@Test("Responses from the wrong peer are ignored")
func wrongPeerResponseIgnored() async {
let fallbacks = WifiBulkTestBox<Bool>()
var service: WifiBulkTransferService?
let environment = makeEnvironment(sendNoisePayload: { typed, _ in
guard typed.first == NoisePayloadType.bulkTransferOffer.rawValue,
let offer = WifiBulkOffer.decode(typed.dropFirst()),
let decline = WifiBulkResponse.decline(transferID: offer.transferID).encode() else { return true }
// Decline arrives from an unrelated peer: must be ignored, so the
// transfer ends via offer timeout instead.
service?.handleResponsePayload(decline, from: PeerID(str: "1122334455667788"))
return true
})
let sut = WifiBulkTransferService(environment: environment, config: makeConfig())
service = sut
sut.sendFile(payload: payload, to: Self.peer, transferId: "t-wrongpeer") {
fallbacks.append(true)
}
// Not fallen back before the offer timeout window
try? await Task.sleep(nanoseconds: 100_000_000)
#expect(fallbacks.count == 0)
// but the timeout still cleans up.
#expect(await wifiBulkWait { fallbacks.count == 1 })
}
@Test("User cancel tears down without BLE fallback")
func userCancelDoesNotFallBack() async {
let fallbacks = WifiBulkTestBox<Bool>()
let progress = WifiBulkTestBox<String>()
let service = WifiBulkTransferService(
environment: makeEnvironment(sendNoisePayload: { _, _ in true }, progressEvents: progress),
config: makeConfig()
)
service.sendFile(payload: payload, to: Self.peer, transferId: "t-cancel") {
fallbacks.append(true)
}
#expect(await wifiBulkWait { service._test_activeOutgoingCount == 1 })
service.cancelTransfer(transferId: "t-cancel")
#expect(await wifiBulkWait { service._test_activeOutgoingCount == 0 })
try? await Task.sleep(nanoseconds: 400_000_000) // past the offer timeout
#expect(fallbacks.count == 0)
#expect(progress.values.contains("cancel:t-cancel"))
}
@Test("Receiver declines an offer that exceeds its size cap")
func receiverDeclinesOversizedOffer() async {
let responses = WifiBulkTestBox<Data>()
let service = WifiBulkTransferService(
environment: makeEnvironment(sendNoisePayload: { typed, _ in
if typed.first == NoisePayloadType.bulkTransferResponse.rawValue {
responses.append(Data(typed.dropFirst()))
}
return true
}),
config: makeConfig()
)
let offer = WifiBulkOffer(
transferID: Data(repeating: 7, count: WifiBulkWire.transferIDLength),
fileSize: UInt64(FileTransferLimits.maxWifiBulkPayloadBytes) + 1,
payloadHash: Data(repeating: 8, count: WifiBulkWire.hashLength),
token: Data(repeating: 9, count: WifiBulkWire.tokenLength),
serviceName: "0011223344556677"
)
if let encoded = offer.encode() {
service.handleOfferPayload(encoded, from: Self.peer)
}
#expect(await wifiBulkWait { responses.count == 1 })
let response = WifiBulkResponse.decode(responses.values[0])
#expect(response?.accepted == false)
#expect(response?.transferID == offer.transferID)
#expect(service._test_activeIncomingCount == 0)
}
@Test("Malformed offers are dropped without a response")
func malformedOfferDropped() async {
let responses = WifiBulkTestBox<Data>()
let service = WifiBulkTransferService(
environment: makeEnvironment(sendNoisePayload: { typed, _ in
responses.append(typed)
return true
}),
config: makeConfig()
)
service.handleOfferPayload(Data([0x01, 0x02, 0x03]), from: Self.peer)
try? await Task.sleep(nanoseconds: 200_000_000)
#expect(responses.count == 0)
#expect(service._test_activeIncomingCount == 0)
}
@Test("stop() tears down active transfers without falling back")
func stopTearsDownWithoutFallback() async {
let fallbacks = WifiBulkTestBox<Bool>()
let service = WifiBulkTransferService(
environment: makeEnvironment(sendNoisePayload: { _, _ in true }),
config: makeConfig()
)
service.sendFile(payload: payload, to: Self.peer, transferId: "t-stop") {
fallbacks.append(true)
}
#expect(await wifiBulkWait { service._test_activeOutgoingCount == 1 })
service.stop()
#expect(await wifiBulkWait { service._test_activeOutgoingCount == 0 })
try? await Task.sleep(nanoseconds: 400_000_000)
#expect(fallbacks.count == 0)
}
}