Skip to content

Commit a1892a0

Browse files
committed
Harden peer connectivity framing
1 parent cbc236b commit a1892a0

8 files changed

Lines changed: 196 additions & 55 deletions

File tree

Package.resolved

Lines changed: 82 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,7 @@ let package = Package(
3434
.library(name: "PeerConnectivityBonjour", targets: ["PeerConnectivityBonjour"]),
3535
],
3636
dependencies: [
37-
// `embedded`-branch only: resolve swift-libp2p against the local working
38-
// tree (its `embedded` branch) so this package builds against the
39-
// Embedded-clean libp2p cores under development. This matches the
40-
// established cross-package wiring on the `embedded` branch.
41-
// RESTORE the URL ref (`from: "0.2.0"`) before release — a local-path ref
42-
// must never be tagged, or downstream dependency resolution fails.
43-
.package(path: "../swift-libp2p"),
37+
.package(url: "https://github.com/1amageek/swift-libp2p.git", from: "0.2.1"),
4438
.package(url: "https://github.com/apple/swift-nio.git", from: "2.91.0"),
4539
.package(url: "https://github.com/apple/swift-log.git", from: "1.8.0"),
4640
],
@@ -49,13 +43,10 @@ let package = Package(
4943
// No Foundation, no NIO, no `any`, no Mutex/ContinuousClock/key paths.
5044
// Owns the resource-transfer header framing (`"<name>\0<size>\0"`) over
5145
// `[UInt8]`; the async stream loop / file I/O / chunk transfer stay in
52-
// the `PeerConnectivityLibP2P` adapter. Depends on the libp2p
53-
// Embedded-clean core for `decodeUTF8Strict`.
46+
// the `PeerConnectivityLibP2P` adapter.
5447
.target(
5548
name: "PeerConnectivityCore",
56-
dependencies: [
57-
.product(name: "LibP2PCore", package: "swift-libp2p"),
58-
],
49+
dependencies: [],
5950
exclude: ["CONTEXT.md"],
6051
swiftSettings: coreSettings
6152
),

Sources/PeerConnectivity/EventBroadcaster.swift

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Synchronization
22

33
public final class PeerConnectivityEventBroadcaster<T: Sendable>: Sendable {
44
private let state: Mutex<BroadcastState>
5+
private let bufferingPolicy: AsyncStream<T>.Continuation.BufferingPolicy
56

67
private struct Entry: Sendable {
78
let id: UInt64
@@ -11,16 +12,24 @@ public final class PeerConnectivityEventBroadcaster<T: Sendable>: Sendable {
1112
private struct BroadcastState: Sendable {
1213
var entries: [Entry] = []
1314
var nextID: UInt64 = 0
15+
var isShutdown = false
1416
}
1517

16-
public init() {
18+
private enum SubscribeAction: Sendable {
19+
case registered(UInt64)
20+
case finishImmediately
21+
}
22+
23+
public init(bufferingPolicy: AsyncStream<T>.Continuation.BufferingPolicy = .bufferingNewest(1024)) {
24+
self.bufferingPolicy = bufferingPolicy
1725
self.state = Mutex(BroadcastState())
1826
}
1927

2028
deinit {
2129
let entries = state.withLock { state in
2230
let entries = state.entries
2331
state.entries.removeAll()
32+
state.isShutdown = true
2433
return entries
2534
}
2635
for entry in entries {
@@ -29,31 +38,41 @@ public final class PeerConnectivityEventBroadcaster<T: Sendable>: Sendable {
2938
}
3039

3140
public func subscribe() -> AsyncStream<T> {
32-
let (stream, continuation) = AsyncStream<T>.makeStream()
33-
let id = state.withLock { state -> UInt64 in
41+
let (stream, continuation) = AsyncStream<T>.makeStream(bufferingPolicy: bufferingPolicy)
42+
let action = state.withLock { state -> SubscribeAction in
43+
guard !state.isShutdown else { return .finishImmediately }
3444
let id = state.nextID
3545
state.nextID += 1
3646
state.entries.append(Entry(id: id, continuation: continuation))
37-
return id
47+
return .registered(id)
3848
}
3949

40-
continuation.onTermination = { [weak self] _ in
41-
self?.state.withLock { state in
42-
state.entries.removeAll { $0.id == id }
50+
switch action {
51+
case .registered(let id):
52+
continuation.onTermination = { [weak self] _ in
53+
self?.state.withLock { state in
54+
state.entries.removeAll { $0.id == id }
55+
}
4356
}
57+
case .finishImmediately:
58+
continuation.finish()
4459
}
4560
return stream
4661
}
4762

4863
public func emit(_ event: T) {
49-
let entries = state.withLock { $0.entries }
64+
let entries = state.withLock { state in
65+
state.isShutdown ? [] : state.entries
66+
}
5067
for entry in entries {
5168
entry.continuation.yield(event)
5269
}
5370
}
5471

5572
public func shutdown() {
5673
let entries = state.withLock { state -> [Entry] in
74+
guard !state.isShutdown else { return [] }
75+
state.isShutdown = true
5776
let entries = state.entries
5877
state.entries.removeAll()
5978
return entries

Sources/PeerConnectivityCore/CONTEXT.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,19 @@ can never be accepted as a complete header.
2323
- The trailing NUL after `<size>` is REQUIRED: a header missing it is rejected
2424
(`missingSizeSeparator`), so a truncated header is never accepted as complete.
2525
- The name is decoded lossy (U+FFFD substitution) — a malformed name never
26-
fails the transfer; the size token is decoded strictly (`decodeUTF8Strict`)
27-
and parsed as a non-negative base-10 integer, rejecting any non-digit,
28-
leading sign, empty token, or overflow (`invalidSize` / `emptySize`).
26+
fails the transfer; the size token is parsed directly as ASCII decimal,
27+
rejecting any non-digit, leading sign, empty token, or overflow
28+
(`invalidSize` / `emptySize`).
2929
- An empty name is rejected (`missingNameSeparator` requires `nameEnd > 0`).
3030
- `decodeMaterialized` additionally requires the remaining payload length to
3131
equal the declared size, failing closed with `payloadSizeMismatch` — sizes
3232
are never trusted to match silently.
3333

3434
## Embedded constraints (do not regress)
35-
- No Foundation, no NIO, no `any`, no `Mutex`, no `ContinuousClock`, no key
36-
paths. UTF-8 decoding reuses `LibP2PCore.decodeUTF8Strict`. This module is
37-
part of the dual-build (host + Embedded) Embedded-clean core.
35+
- No Foundation, no NIO, no libp2p dependency, no `any`, no `Mutex`, no
36+
`ContinuousClock`, no key paths. Size parsing is ASCII-decimal directly over
37+
bytes, and malformed/non-digit bytes fail closed as `invalidSize`. This module
38+
is part of the dual-build (host + Embedded) Embedded-clean core.
3839

3940
## Wire protocol notes
4041
- Header body: `"<name>\0<size>\0"`. `<size>` is the exact payload byte count

Sources/PeerConnectivityCore/ResourceFrameCodec.swift

Lines changed: 19 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
import LibP2PCore
2-
31
/// Resource-transfer header framing for PeerConnectivity (Embedded-clean).
42
///
53
/// Embedded-clean: no Foundation, no NIO, no `any`, no Mutex, no
6-
/// ContinuousClock, no key paths. Operates over `[UInt8]` and uses
7-
/// `LibP2PCore.decodeUTF8Strict` for strict UTF-8 decoding of the size token.
4+
/// ContinuousClock, no key paths. Operates over `[UInt8]` without pulling in
5+
/// the host libp2p facade.
86
///
97
/// A resource is sent over a libp2p stream as a sequence of length-prefixed
108
/// frames (the length prefix is owned by the muxer's
@@ -68,7 +66,7 @@ public enum ResourceFrameCodec {
6866
/// - Throws: `ResourceFrameError` describing the specific framing failure.
6967
public static func decodeHeader(_ bytes: [UInt8]) throws(ResourceFrameError) -> ResourceHeader {
7068
let layout = try parseHeaderLayout(bytes)
71-
let name = lossyUTF8(Array(bytes[0..<layout.nameEnd]))
69+
let name = lossyUTF8(bytes[0..<layout.nameEnd])
7270
return ResourceHeader(name: name, size: layout.size)
7371
}
7472

@@ -103,7 +101,7 @@ public enum ResourceFrameCodec {
103101
guard availablePayload == layout.size else {
104102
throw .payloadSizeMismatch(declared: layout.size, available: availablePayload)
105103
}
106-
let name = lossyUTF8(Array(bytes[0..<layout.nameEnd]))
104+
let name = lossyUTF8(bytes[0..<layout.nameEnd])
107105
return MaterializedHeader(
108106
header: ResourceHeader(name: name, size: layout.size),
109107
payloadStart: layout.payloadStart
@@ -127,12 +125,10 @@ public enum ResourceFrameCodec {
127125
let sizeEnd = firstIndex(of: separator, in: bytes, from: sizeStart) else {
128126
throw .missingSizeSeparator
129127
}
130-
let sizeBytes = Array(bytes[sizeStart..<sizeEnd])
131-
guard !sizeBytes.isEmpty else {
128+
guard sizeStart < sizeEnd else {
132129
throw .emptySize
133130
}
134-
guard let sizeString = decodeSizeToken(sizeBytes),
135-
let size = parseNonNegativeInt(sizeString) else {
131+
guard let size = parseNonNegativeInt(bytes, start: sizeStart, end: sizeEnd) else {
136132
throw .invalidSize
137133
}
138134
return HeaderLayout(nameEnd: nameEnd, payloadStart: sizeEnd + 1, size: size)
@@ -149,23 +145,21 @@ public enum ResourceFrameCodec {
149145
return nil
150146
}
151147

152-
private static func decodeSizeToken(_ bytes: [UInt8]) -> String? {
153-
// Strict UTF-8: a non-numeric / malformed size token is a framing error,
154-
// never substituted. Reuses the Embedded-clean helper from LibP2PCore.
155-
decodeUTF8Strict(bytes)
156-
}
157-
158-
/// Parses a non-negative base-10 integer from `string`, rejecting any
148+
/// Parses a non-negative base-10 integer from ASCII bytes, rejecting any
159149
/// non-digit character, leading sign, or overflow. Returns `nil` on failure.
160-
private static func parseNonNegativeInt(_ string: String) -> Int? {
150+
private static func parseNonNegativeInt(
151+
_ bytes: [UInt8],
152+
start: Int,
153+
end: Int
154+
) -> Int? {
161155
var value = 0
162-
var sawDigit = false
163-
for scalar in string.unicodeScalars {
164-
guard scalar.value >= 48, scalar.value <= 57 else {
156+
var index = start
157+
while index < end {
158+
let byte = bytes[index]
159+
guard byte >= 48, byte <= 57 else {
165160
return nil
166161
}
167-
sawDigit = true
168-
let digit = Int(scalar.value - 48)
162+
let digit = Int(byte - 48)
169163
let (multiplied, overflowMul) = value.multipliedReportingOverflow(by: 10)
170164
guard !overflowMul else {
171165
return nil
@@ -175,14 +169,12 @@ public enum ResourceFrameCodec {
175169
return nil
176170
}
177171
value = added
178-
}
179-
guard sawDigit else {
180-
return nil
172+
index += 1
181173
}
182174
return value
183175
}
184176

185-
private static func lossyUTF8(_ bytes: [UInt8]) -> String {
177+
private static func lossyUTF8<Bytes: Collection>(_ bytes: Bytes) -> String where Bytes.Element == UInt8 {
186178
String(decoding: bytes, as: UTF8.self)
187179
}
188180
}

Sources/PeerConnectivityLibP2P/LibP2PResourceCodec.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import PeerConnectivityCore
99
/// payload), using the muxer's `read/writeLengthPrefixedMessage` helpers:
1010
///
1111
/// ```
12-
/// [frame] header : "<name>\0<size>" (size is the decimal payload byte count)
12+
/// [frame] header : "<name>\0<size>\0" (size is the decimal payload byte count)
1313
/// [frame] chunk : raw payload bytes (one or more frames)
1414
/// [frame] chunk : ...
1515
/// ```

0 commit comments

Comments
 (0)