mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 03:45:20 +00:00
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>
247 lines
10 KiB
Swift
247 lines
10 KiB
Swift
//
|
|
// 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)
|
|
}
|
|
}
|