mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 04:25:21 +00:00
Wi-Fi bulk transport: AWDL data plane for large media with BLE fallback
BLE stays the control plane: a sender with a queued file > 64 KiB to a directly connected peer advertising the wifiBulk capability sends a bulkTransferOffer (0x04) inside the established Noise session — transferID, file size, payload SHA-256, a fresh 32-byte token, and a random per-transfer Bonjour instance name. The receiver answers bulkTransferResponse (0x05) with its own token half, then both sides meet on a per-transfer _bitchat-bulk._tcp channel over peer-to-peer Wi-Fi (AWDL). The TCP stream is secured independently of TLS: both tokens traveled inside Noise, so only the two peers can derive the ChaChaPoly channel key (HKDF-SHA256, domain "bitchat-bulk-v1", transferID as salt). Frames are length-prefixed sealed boxes with structured direction+counter nonces; the first frame must prove knowledge of the key or the client is disconnected, and the final hash is verified against the offer before delivery. Decline, timeout, or any mid-transfer error falls back to BLE fragmentation exactly once, driving the same TransferProgressManager stream so the UI is unchanged. Wi-Fi-negotiated transfers may carry up to 8 MiB (new FileTransferLimits.maxWifiBulkPayloadBytes, enforced by the receiver from the accepted offer); the BLE path keeps its existing caps. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user