Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions bitchat/Services/BLE/BLEInboundWriteBuffer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ struct BLEInboundWriteBuffer {
case decoded(packet: BitchatPacket, metadata: BLEInboundWriteAppendMetadata)
case waiting(metadata: BLEInboundWriteAppendMetadata)
case oversized(metadata: BLEInboundWriteAppendMetadata)
case invalid(metadata: BLEInboundWriteAppendMetadata)
}

private var buffersByCentralID: [String: Data] = [:]
Expand All @@ -34,10 +35,36 @@ struct BLEInboundWriteBuffer {
var combined = buffersByCentralID[centralID] ?? Data()
var appendedBytes = 0
var offsets: [Int] = []
// New chunks must not overlap bytes already buffered for this central.
var lastEnd = combined.count

for chunk in chunks where !chunk.data.isEmpty {
offsets.append(chunk.offset)

// `.withoutResponse` writes always land at offset 0, so an
// offset-0 chunk arriving on a non-empty buffer is a fresh frame
// replacing stale partial bytes, not an overlap.
if chunk.offset == 0, !combined.isEmpty {
combined.removeAll()
lastEnd = 0
}

// Reject malformed writes before touching the buffer: a negative
// offset traps `Data.replaceSubrange`, and non-monotonic or
// overlapping offsets corrupt previously written bytes.
guard chunk.offset >= 0, chunk.offset >= lastEnd else {
let metadata = BLEInboundWriteAppendMetadata(
accumulatedBytes: combined.count,
appendedBytes: appendedBytes,
offsets: offsets,
packetType: combined.count >= 2 ? combined[1] : nil
)
buffersByCentralID.removeValue(forKey: centralID)
return .invalid(metadata: metadata)
}

let end = chunk.offset + chunk.data.count
lastEnd = end

if combined.count < end {
combined.append(Data(repeating: 0, count: end - combined.count))
Expand Down
5 changes: 5 additions & 0 deletions bitchat/Services/BLE/BLEService+LinkLayerPeripheralRole.swift
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,11 @@ extension BLEService: CBPeripheralManagerDelegate {
logAccumulatedCentralWrite(metadata, centralUUID: centralUUID)
SecureLogger.warning("⚠️ Dropping oversized pending write buffer (\(metadata.accumulatedBytes) bytes) for central \(centralUUID.prefix(8))…", category: .session)
logFailedSingleWriteIfNeeded(hasMultiple: hasMultiple, sortedRequests: sorted)

case let .invalid(metadata):
logAccumulatedCentralWrite(metadata, centralUUID: centralUUID)
SecureLogger.warning("⚠️ Dropping malformed pending write (offsets=\(metadata.offsets)) for central \(centralUUID.prefix(8))…", category: .session)
logFailedSingleWriteIfNeeded(hasMultiple: hasMultiple, sortedRequests: sorted)
}
}
}
Expand Down
129 changes: 129 additions & 0 deletions bitchatTests/Services/BLEInboundWriteBufferTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,135 @@ struct BLEInboundWriteBufferTests {
}
}

@Test
func appendRejectsNegativeOffsetAndClearsBuffer() throws {
var buffer = BLEInboundWriteBuffer()
_ = buffer.append(
chunks: [BLEInboundWriteChunk(offset: 0, data: Data(repeating: 0x01, count: 4))],
for: "central-1",
capBytes: 1024
)

let result = buffer.append(
chunks: [BLEInboundWriteChunk(offset: -1, data: Data([0x02]))],
for: "central-1",
capBytes: 1024
)

if case let .invalid(metadata) = result {
#expect(metadata.offsets == [-1])
} else {
Issue.record("Expected negative offset to be rejected")
}

// The poisoned buffer was cleared, so a clean full frame still decodes.
let packet = makePacket(timestamp: 0x123)
let frame = try #require(packet.toBinaryData(padding: false))
let decoded = buffer.append(
chunks: [BLEInboundWriteChunk(offset: 0, data: frame)],
for: "central-1",
capBytes: 1024
)

if case let .decoded(decodedPacket, _) = decoded {
#expect(decodedPacket.timestamp == packet.timestamp)
} else {
Issue.record("Expected clean frame to decode after invalid write reset the buffer")
}
}

@Test
func appendRejectsOverlappingChunks() {
var buffer = BLEInboundWriteBuffer()

let result = buffer.append(
chunks: [
BLEInboundWriteChunk(offset: 0, data: Data(repeating: 0x01, count: 8)),
BLEInboundWriteChunk(offset: 4, data: Data(repeating: 0x02, count: 4))
],
for: "central-1",
capBytes: 1024
)

if case let .invalid(metadata) = result {
#expect(metadata.offsets == [0, 4])
} else {
Issue.record("Expected overlapping chunks to be rejected")
}
}

@Test
func appendRejectsNonMonotonicChunks() {
var buffer = BLEInboundWriteBuffer()

let result = buffer.append(
chunks: [
BLEInboundWriteChunk(offset: 8, data: Data(repeating: 0x01, count: 4)),
BLEInboundWriteChunk(offset: 4, data: Data(repeating: 0x02, count: 4))
],
for: "central-1",
capBytes: 1024
)

if case let .invalid(metadata) = result {
#expect(metadata.offsets == [8, 4])
} else {
Issue.record("Expected non-monotonic chunks to be rejected")
}
}

@Test
func appendTreatsOffsetZeroOnStaleBufferAsRestart() throws {
var buffer = BLEInboundWriteBuffer()
let packet = makePacket(timestamp: 0x456)
let frame = try #require(packet.toBinaryData(padding: false))
let splitIndex = max(1, frame.count / 2)

// Stale partial frame left pending from an earlier write.
_ = buffer.append(
chunks: [BLEInboundWriteChunk(offset: 0, data: frame.prefix(splitIndex))],
for: "central-1",
capBytes: 1024
)

// A fresh `.withoutResponse` write lands at offset 0 and replaces it
// instead of being rejected as an overlap with the stale bytes.
let result = buffer.append(
chunks: [BLEInboundWriteChunk(offset: 0, data: frame)],
for: "central-1",
capBytes: 1024
)

if case let .decoded(decoded, metadata) = result {
#expect(decoded.timestamp == packet.timestamp)
#expect(metadata.offsets == [0])
} else {
Issue.record("Expected offset-0 write to restart the stale buffer")
}
}

@Test
func appendRejectsOverlapWithPreviouslyBufferedBytes() {
var buffer = BLEInboundWriteBuffer()
_ = buffer.append(
chunks: [BLEInboundWriteChunk(offset: 0, data: Data(repeating: 0x01, count: 8))],
for: "central-1",
capBytes: 1024
)

let result = buffer.append(
chunks: [BLEInboundWriteChunk(offset: 4, data: Data(repeating: 0x02, count: 4))],
for: "central-1",
capBytes: 1024
)

if case let .invalid(metadata) = result {
#expect(metadata.offsets == [4])
} else {
Issue.record("Expected overlap with buffered bytes to be rejected")
}
}

private func makePacket(timestamp: UInt64 = 0x0102030405) -> BitchatPacket {
BitchatPacket(
type: MessageType.message.rawValue,
Expand Down