From 66cc3c9b773b05c56c8657629565c2e042946887 Mon Sep 17 00:00:00 2001 From: Tommy Pauly Date: Thu, 6 Aug 2026 17:11:12 -0700 Subject: [PATCH 1/3] Build out demux protocol --- .../Protocols/DemuxProtocol.swift | 426 ++++++++++++++++++ 1 file changed, 426 insertions(+) create mode 100644 Sources/SwiftNetwork/Protocols/DemuxProtocol.swift diff --git a/Sources/SwiftNetwork/Protocols/DemuxProtocol.swift b/Sources/SwiftNetwork/Protocols/DemuxProtocol.swift new file mode 100644 index 0000000..7cf5250 --- /dev/null +++ b/Sources/SwiftNetwork/Protocols/DemuxProtocol.swift @@ -0,0 +1,426 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if canImport(Glibc) +import Glibc +internal import Logging +#elseif canImport(Musl) +import Musl +internal import Logging +#elseif canImport(os) +internal import os +#endif + +#if canImport(BasicContainers) +import BasicContainers +internal import DequeModule +#endif + +#if canImport(Synchronization) +internal import Synchronization +#endif + +@_spi(ProtocolProvider) +@available(Network 0.1.0, *) +public enum DemuxError: Error, CustomStringConvertible { + case patternTooLong + case invalidMask + + public var description: String { + switch self { + case .patternTooLong: return "Pattern Too Long" + case .invalidMask: return "Invalid Mask" + } + } +} + +@_spi(Essentials) +@available(Network 0.1.0, *) +public struct DemuxPattern: Sendable, Hashable { + static let maxPatternLength = 30 + let patternRange: Range // Range of bytes in frame that the pattern must match + let pattern: [30 of UInt8] // Content of the pattern + let mask: [30 of UInt8] // Mask for the pattern + + public static func == (lhs: borrowing DemuxPattern, rhs: borrowing DemuxPattern) -> Bool { + guard lhs.patternRange == rhs.patternRange else { return false } + for i in 0..() + + init() {} + + init?(from serializedBytes: [UInt8]) { + // Ignore content + } + + public func serialize() -> [UInt8]? { + var hasPatterns = false + for demuxPattern in demuxPatterns { + if !demuxPattern.isEmpty { + hasPatterns = true + break + } + } + return Serializer.serialize { write in + write.uint8(hasPatterns ? 1 : 0) + } + } + public var serializeInParameters: Bool { + true + } + public func deepCopy() -> DemuxOptions { + self + } + public func isEqual(to other: DemuxOptions, for: ProtocolCompareMode) -> Bool { + self == other + } + + public mutating func addPattern(_ pattern: RawSpan, at offset: Int, mask: RawSpan? = nil) throws(DemuxError) { + demuxPatterns.append(try DemuxPattern(pattern, at: offset, mask: mask)) + } + } + + // TODO: Add a way to dynamically add patterns to an existing upper protocol + public struct DemuxMetadata: PerProtocolMetadata { + var isStatic: Bool = false + + init() {} + public func isEqual(to other: DemuxMetadata, for: ProtocolCompareMode) -> Bool { + self == other + } + } + + public final class DemuxInstance: OutboundDatagramHandler, InboundDatagramHandler, LoggableProtocol, ProtocolInstanceContainer { + var defaultUpper = InboundDatagramLinkage() + var defaultInboundFrames = FrameArray() + + struct DemuxEntry: ~Copyable { + var upper: InboundDatagramLinkage + var inboundFrames = FrameArray() + var demuxPatterns = Deque() + } + var demuxEntries = NetworkUniqueArray() + + var lower = OutboundDatagramLinkage() + var asUpper: LowerProtocol.PairedLinkage { .init(reference: reference) } + var asLower: UpperProtocol.PairedLinkage { .init(reference: reference) } + + public private(set) var context: NetworkContext + init(context: NetworkContext) { self.context = context } + public var reference: ProtocolInstanceReference { ProtocolInstanceReference(custom: self) } + public var log = NetworkLoggerState() + public var eventManager = ProtocolEventManager() + + internal func validate( + upper upperProtocol: ProtocolInstanceReference, + _ label: String + ) throws(ProtocolInstanceError) { + #if DEBUG + if upperProtocol == defaultUpper.reference { return } + for i in 0..(_ from: ProtocolInstanceReference, remote: Endpoint?, local: Endpoint?, parameters: Parameters?, path: PathProperties?) throws(NetworkError) -> Linkage where Linkage : LowerProtocolLinkage { + return try attachUpperDatagramProtocol(from, remote: remote, local: local, parameters: parameters, path: path) as! Linkage + } + + public func attachLowerProtocol(_ lowerProtocol: ProtocolInstanceReference, remote: Endpoint?, local: Endpoint?, parameters: Parameters?, path: PathProperties?) throws(NetworkError) { + guard lower.isDetached else { + throw NetworkError.posix(EALREADY) + } + self.lower = try lowerProtocol.attachUpperProtocol( + reference, + remote: remote, + local: local, + parameters: parameters, + path: path + ) + } + +#endif + + public func attachUpperDatagramProtocol(_ from: ProtocolInstanceReference, remote: Endpoint?, local: Endpoint?, parameters: Parameters?, path: PathProperties?) throws(NetworkError) -> OutboundDatagramLinkage { + if defaultUpper.isDetached { + // Set up default + defaultUpper = UpperProtocol(reference: from) +#if !NETWORK_EMBEDDED + if let parameters { + if let options = parameters.protocolOptions(for: self.reference) { + self.log.logPrefix = options.logIDString ?? "" + } + } +#endif + } else if defaultUpper.reference != from { +#if !NETWORK_EMBEDDED + if let parameters { + if let demuxOptions: ProtocolOptions = parameters.protocolOptions(for: self.reference) { + demuxEntries.append(DemuxEntry(upper: UpperProtocol(reference: from), + demuxPatterns: demuxOptions.perProtocolOptions!.demuxPatterns)) + } + } +#endif + } + + return asLower + } + + public func attachLowerDatagramProtocol(_ lowerProtocol: ProtocolInstanceReference, remote: Endpoint?, local: Endpoint?, parameters: Parameters?, path: PathProperties?) throws(NetworkError) { + guard lower.isDetached else { + throw NetworkError.posix(EALREADY) + } + self.lower = try lowerProtocol.attachUpperDatagramProtocol( + reference, + remote: remote, + local: local, + parameters: parameters, + path: path + ) + } + + public func receiveDatagrams(_ from: ProtocolInstanceReference, maximumDatagramCount: Int) throws(NetworkError) -> FrameArray? { + // TODO: Do this + return nil + } + + public func getDatagramsToSend(_ from: ProtocolInstanceReference, maximumDatagramCount: Int, minimumDatagramSize: Int) throws(NetworkError) -> FrameArray? { + // TODO: Do this + return nil + } + + public func sendDatagrams(_ from: ProtocolInstanceReference, datagrams: consuming FrameArray) throws(NetworkError) { + // TODO: Do this + } + + public func detach(_ from: ProtocolInstanceReference) throws(NetworkError) { + do { try validate(upper: from, #function) } catch { throw NetworkError.posix(EINVAL) } + var shouldTeardown: Bool + if from == defaultUpper.reference { + shouldTeardown = true + } else { + for i in 0..(_ from: ProtocolInstanceReference) -> ProtocolMetadata

? where P : NetworkProtocol { + do { try validate(upper: from, #function) } catch { return nil } + return lower.invokeGetMetadata(self.reference) + } + + public func getMetrics(_ from: ProtocolInstanceReference, requestedNetworkMetric: RequestedNetworkMetrics) -> NetworkMetrics? { + return lower.invokeGetMetrics( + self.reference, + requestedNetworkMetric: requestedNetworkMetric + ) + } + + // Events from lower + + public func handleConnectedEvent(_ from: ProtocolInstanceReference) { + do { try validate(lower: from, #function) } catch { return } + if canCallConnect(requested: false) { + defaultUpper.deliverConnectedEvent(self.reference) + } + } + + public func handleDisconnectedEvent(_ from: ProtocolInstanceReference, error: NetworkError?) { + do { try validate(lower: from, #function) } catch { return } + for i in 0.. DemuxOptions? { DemuxOptions() } + public func newPerProtocolOptions(from existing: DemuxOptions) -> DemuxOptions { existing } + public func newPerProtocolOptions(from serializedBytes: [UInt8]) -> DemuxOptions? { + DemuxOptions(from: serializedBytes) + } + public func newPerProtocolMetadata() -> DemuxMetadata? { DemuxMetadata() } + public func newProtocolInstance(context: NetworkContext) -> ProtocolInstanceReference? { + DemuxInstance(context: context).reference + } + + static let identifier = ProtocolIdentifier(name: "demux", level: .link, mapping: .oneToOne) + static let definition = ProtocolDefinition(identifier: identifier) + + static public func options() -> ProtocolOptions { + DemuxProtocol.definition.protocolOptions() + } + + static public func instance(context: NetworkContext) -> ProtocolInstanceReference { + DemuxProtocol().newProtocolInstance(context: context)! + } +} + +@_spi(Essentials) +@available(Network 0.1.0, *) +extension ProtocolOptions { + public func addPattern(_ pattern: RawSpan, at offset: Int, mask: RawSpan? = nil) throws(DemuxError) { + try perProtocolOptions!.addPattern(pattern, at: offset, mask: mask) + } +} From acba39cbe20ffe860e276a34ddc1c6cc69699ac8 Mon Sep 17 00:00:00 2001 From: Tommy Pauly Date: Mon, 10 Aug 2026 11:56:36 -0700 Subject: [PATCH 2/3] Inbound demux logic --- .../Protocols/DemuxProtocol.swift | 115 ++++++++++++++++-- 1 file changed, 108 insertions(+), 7 deletions(-) diff --git a/Sources/SwiftNetwork/Protocols/DemuxProtocol.swift b/Sources/SwiftNetwork/Protocols/DemuxProtocol.swift index 7cf5250..d8d1877 100644 --- a/Sources/SwiftNetwork/Protocols/DemuxProtocol.swift +++ b/Sources/SwiftNetwork/Protocols/DemuxProtocol.swift @@ -96,6 +96,21 @@ public struct DemuxPattern: Sendable, Hashable { self.pattern = tempPattern self.mask = tempMask } + + func matchesFrame(_ frame: borrowing Frame) -> Bool { + guard !patternRange.isEmpty else { return false } + guard let bytes = frame.bytes else { return false } + guard bytes.byteCount >= patternRange.upperBound else { return false } + let startOffset = patternRange.lowerBound + let patternLength = patternRange.count + for byteIndex in 0.. FrameArray? { - // TODO: Do this + func addInboundDatagram(_ datagram: consuming Frame) -> Int? { + for entryIndex in 0.. FrameArray? { - // TODO: Do this + func serviceInboundFrames(_ from: ProtocolInstanceReference, maximumDatagramCount: Int, requestingIndex: inout Int?) -> FrameArray? { + if from == defaultUpper.reference { + // Look for pending frames for default + if !defaultInboundFrames.isEmpty { + return defaultInboundFrames.drainArray(maximumFrameCount: maximumDatagramCount) + } + } else { + for i in 0.. FrameArray? { + do { try validate(upper: from, #function) } catch { throw NetworkError.posix(EINVAL) } + var requestingIndex: Int? = nil + + let returnArray = serviceInboundFrames(from, maximumDatagramCount: maximumDatagramCount, requestingIndex: &requestingIndex) + if let returnArray { + return returnArray + } + + guard !demuxEntries.isEmpty else { + // No patterns, just go direct + return try lower.invokeReceiveDatagrams(self.reference, maximumDatagramCount: maximumDatagramCount) + } + + // Read datagrams out and categorize them based on patterns + let inboundDatagrams = try lower.invokeReceiveDatagrams(self.reference, maximumDatagramCount: maximumDatagramCount) + guard var inboundDatagrams, !inboundDatagrams.isEmpty else { + return nil + } + + var signalInboundDataAvailableToPatterns = false + var signalInboundDataAvailableToDefault = false + + while let datagram = inboundDatagrams.popFirst() { + let matchingIndex = self.addInboundDatagram(datagram) + if matchingIndex != requestingIndex { + if matchingIndex != nil { + signalInboundDataAvailableToPatterns = true + } else { + signalInboundDataAvailableToDefault = true + } + } + } + + if signalInboundDataAvailableToPatterns { + for i in 0.. FrameArray? { + do { try validate(upper: from, #function) } catch { throw NetworkError.posix(EINVAL) } + return try lower.invokeGetDatagramsToSend( + self.reference, + maximumDatagramCount: maximumDatagramCount, + minimumDatagramSize: minimumDatagramSize + ) + } + public func sendDatagrams(_ from: ProtocolInstanceReference, datagrams: consuming FrameArray) throws(NetworkError) { - // TODO: Do this + do { try validate(upper: from, #function) } catch { throw NetworkError.posix(EINVAL) } + try lower.invokeSendDatagrams(self.reference, datagrams: datagrams) } public func detach(_ from: ProtocolInstanceReference) throws(NetworkError) { @@ -307,11 +408,11 @@ public struct DemuxProtocol: NetworkProtocol { if lower.isConnected { if canCallConnect(requested: true) { - // TODO: What? + defaultUpper.deliverConnectedEvent(self.reference) } } else { connectRequested() - // TODO: Pass along connect + lower.invokeConnect(self.reference) } } else { // Just reply connected to the non-default cases From 729b83a4a2328c97502ff938788e3efcbf5445c0 Mon Sep 17 00:00:00 2001 From: Tommy Pauly Date: Mon, 10 Aug 2026 23:43:47 -0700 Subject: [PATCH 3/3] Add demux tests --- .../Protocols/DemuxProtocol.swift | 142 ++++-- .../SwiftNetworkDemuxTests.swift | 438 ++++++++++++++++++ 2 files changed, 544 insertions(+), 36 deletions(-) create mode 100644 Tests/SwiftNetworkTests/SwiftNetworkDemuxTests.swift diff --git a/Sources/SwiftNetwork/Protocols/DemuxProtocol.swift b/Sources/SwiftNetwork/Protocols/DemuxProtocol.swift index d8d1877..a543464 100644 --- a/Sources/SwiftNetwork/Protocols/DemuxProtocol.swift +++ b/Sources/SwiftNetwork/Protocols/DemuxProtocol.swift @@ -49,15 +49,16 @@ public enum DemuxError: Error, CustomStringConvertible { @available(Network 0.1.0, *) public struct DemuxPattern: Sendable, Hashable { static let maxPatternLength = 30 - let patternRange: Range // Range of bytes in frame that the pattern must match - let pattern: [30 of UInt8] // Content of the pattern - let mask: [30 of UInt8] // Mask for the pattern + let patternRange: Range // Range of bytes in frame that the pattern must match + let pattern: [30 of UInt8] // Content of the pattern + let mask: [30 of UInt8] // Mask for the pattern public static func == (lhs: borrowing DemuxPattern, rhs: borrowing DemuxPattern) -> Bool { guard lhs.patternRange == rhs.patternRange else { return false } for i in 0..(_ from: ProtocolInstanceReference, remote: Endpoint?, local: Endpoint?, parameters: Parameters?, path: PathProperties?) throws(NetworkError) -> Linkage where Linkage : LowerProtocolLinkage { - return try attachUpperDatagramProtocol(from, remote: remote, local: local, parameters: parameters, path: path) as! Linkage - } - - public func attachLowerProtocol(_ lowerProtocol: ProtocolInstanceReference, remote: Endpoint?, local: Endpoint?, parameters: Parameters?, path: PathProperties?) throws(NetworkError) { + #if !NETWORK_EMBEDDED + public func attachUpperProtocol( + _ from: ProtocolInstanceReference, + remote: Endpoint?, + local: Endpoint?, + parameters: Parameters?, + path: PathProperties? + ) throws(NetworkError) -> Linkage where Linkage: LowerProtocolLinkage { + try attachUpperDatagramProtocol(from, remote: remote, local: local, parameters: parameters, path: path) + as! Linkage + } + + public func attachLowerProtocol( + _ lowerProtocol: ProtocolInstanceReference, + remote: Endpoint?, + local: Endpoint?, + parameters: Parameters?, + path: PathProperties? + ) throws(NetworkError) { guard lower.isDetached else { throw NetworkError.posix(EALREADY) } @@ -232,34 +248,52 @@ public struct DemuxProtocol: NetworkProtocol { ) } -#endif + #endif - public func attachUpperDatagramProtocol(_ from: ProtocolInstanceReference, remote: Endpoint?, local: Endpoint?, parameters: Parameters?, path: PathProperties?) throws(NetworkError) -> OutboundDatagramLinkage { + public func attachUpperDatagramProtocol( + _ from: ProtocolInstanceReference, + remote: Endpoint?, + local: Endpoint?, + parameters: Parameters?, + path: PathProperties? + ) throws(NetworkError) -> OutboundDatagramLinkage { if defaultUpper.isDetached { // Set up default defaultUpper = UpperProtocol(reference: from) -#if !NETWORK_EMBEDDED + #if !NETWORK_EMBEDDED if let parameters { if let options = parameters.protocolOptions(for: self.reference) { self.log.logPrefix = options.logIDString ?? "" } } -#endif + #endif } else if defaultUpper.reference != from { -#if !NETWORK_EMBEDDED + #if !NETWORK_EMBEDDED if let parameters { - if let demuxOptions: ProtocolOptions = parameters.protocolOptions(for: self.reference) { - demuxEntries.append(DemuxEntry(upper: UpperProtocol(reference: from), - demuxPatterns: demuxOptions.perProtocolOptions!.demuxPatterns)) + if let demuxOptions: ProtocolOptions = parameters.protocolOptions( + for: self.reference + ) { + demuxEntries.append( + DemuxEntry( + upper: UpperProtocol(reference: from), + demuxPatterns: demuxOptions.perProtocolOptions!.demuxPatterns + ) + ) } } -#endif + #endif } return asLower } - public func attachLowerDatagramProtocol(_ lowerProtocol: ProtocolInstanceReference, remote: Endpoint?, local: Endpoint?, parameters: Parameters?, path: PathProperties?) throws(NetworkError) { + public func attachLowerDatagramProtocol( + _ lowerProtocol: ProtocolInstanceReference, + remote: Endpoint?, + local: Endpoint?, + parameters: Parameters?, + path: PathProperties? + ) throws(NetworkError) { guard lower.isDetached else { throw NetworkError.posix(EALREADY) } @@ -288,7 +322,11 @@ public struct DemuxProtocol: NetworkProtocol { return nil } - func serviceInboundFrames(_ from: ProtocolInstanceReference, maximumDatagramCount: Int, requestingIndex: inout Int?) -> FrameArray? { + func serviceInboundFrames( + _ from: ProtocolInstanceReference, + maximumDatagramCount: Int, + requestingIndex: inout Int? + ) -> FrameArray? { if from == defaultUpper.reference { // Look for pending frames for default if !defaultInboundFrames.isEmpty { @@ -308,11 +346,18 @@ public struct DemuxProtocol: NetworkProtocol { return nil } - public func receiveDatagrams(_ from: ProtocolInstanceReference, maximumDatagramCount: Int) throws(NetworkError) -> FrameArray? { + public func receiveDatagrams( + _ from: ProtocolInstanceReference, + maximumDatagramCount: Int + ) throws(NetworkError) -> FrameArray? { do { try validate(upper: from, #function) } catch { throw NetworkError.posix(EINVAL) } var requestingIndex: Int? = nil - let returnArray = serviceInboundFrames(from, maximumDatagramCount: maximumDatagramCount, requestingIndex: &requestingIndex) + let returnArray = serviceInboundFrames( + from, + maximumDatagramCount: maximumDatagramCount, + requestingIndex: &requestingIndex + ) if let returnArray { return returnArray } @@ -323,7 +368,10 @@ public struct DemuxProtocol: NetworkProtocol { } // Read datagrams out and categorize them based on patterns - let inboundDatagrams = try lower.invokeReceiveDatagrams(self.reference, maximumDatagramCount: maximumDatagramCount) + let inboundDatagrams = try lower.invokeReceiveDatagrams( + self.reference, + maximumDatagramCount: maximumDatagramCount + ) guard var inboundDatagrams, !inboundDatagrams.isEmpty else { return nil } @@ -355,10 +403,18 @@ public struct DemuxProtocol: NetworkProtocol { } // Return frames for the requesting index - return serviceInboundFrames(from, maximumDatagramCount: maximumDatagramCount, requestingIndex: &requestingIndex) + return serviceInboundFrames( + from, + maximumDatagramCount: maximumDatagramCount, + requestingIndex: &requestingIndex + ) } - public func getDatagramsToSend(_ from: ProtocolInstanceReference, maximumDatagramCount: Int, minimumDatagramSize: Int) throws(NetworkError) -> FrameArray? { + public func getDatagramsToSend( + _ from: ProtocolInstanceReference, + maximumDatagramCount: Int, + minimumDatagramSize: Int + ) throws(NetworkError) -> FrameArray? { do { try validate(upper: from, #function) } catch { throw NetworkError.posix(EINVAL) } return try lower.invokeGetDatagramsToSend( self.reference, @@ -367,7 +423,10 @@ public struct DemuxProtocol: NetworkProtocol { ) } - public func sendDatagrams(_ from: ProtocolInstanceReference, datagrams: consuming FrameArray) throws(NetworkError) { + public func sendDatagrams( + _ from: ProtocolInstanceReference, + datagrams: consuming FrameArray + ) throws(NetworkError) { do { try validate(upper: from, #function) } catch { throw NetworkError.posix(EINVAL) } try lower.invokeSendDatagrams(self.reference, datagrams: datagrams) } @@ -438,13 +497,16 @@ public struct DemuxProtocol: NetworkProtocol { lower.invokeApplicationEvent(from, event: event) } - public func getMetadata

(_ from: ProtocolInstanceReference) -> ProtocolMetadata

? where P : NetworkProtocol { + public func getMetadata

(_ from: ProtocolInstanceReference) -> ProtocolMetadata

? where P: NetworkProtocol { do { try validate(upper: from, #function) } catch { return nil } return lower.invokeGetMetadata(self.reference) } - public func getMetrics(_ from: ProtocolInstanceReference, requestedNetworkMetric: RequestedNetworkMetrics) -> NetworkMetrics? { - return lower.invokeGetMetrics( + public func getMetrics( + _ from: ProtocolInstanceReference, + requestedNetworkMetric: RequestedNetworkMetrics + ) -> NetworkMetrics? { + lower.invokeGetMetrics( self.reference, requestedNetworkMetric: requestedNetworkMetric ) @@ -488,9 +550,17 @@ public struct DemuxProtocol: NetworkProtocol { public func handleNetworkProtocolEvent(_ from: ProtocolInstanceReference, event: NetworkProtocolEvent) { // Don't validate lower, can pass through - defaultUpper.deliverNetworkProtocolEvent(originalReference: from, selfReference: self.reference, event: event) + defaultUpper.deliverNetworkProtocolEvent( + originalReference: from, + selfReference: self.reference, + event: event + ) for i in 0.. [UInt8] { + // Precompute the bytes that will be written into the pattern region, so the + // Serializer result-builder body below stays a straight-line composition. + let patternRegionBytes: [UInt8] + if let pattern, let mask = pattern.mask { + let variantByte: UInt8 = 0xA5 &+ UInt8(sequence & 0xff) + patternRegionBytes = zip(pattern.pattern, mask).map { byte, m in + (byte & m) | (variantByte & ~m) + } + } else if let pattern { + patternRegionBytes = pattern.pattern + } else { + patternRegionBytes = [] + } + + return Serializer.serialize { write in + if let pattern { + write.buffer([UInt8](repeating: 0, count: pattern.offset)) + write.buffer(patternRegionBytes) + } else { + write.buffer(Self.defaultPayloadPrefix) + } + write.uint8(UInt8(flowIndex & 0xff)) + write.uint8(UInt8(sequence & 0xff)) + write.uint8(0xA5) + write.uint8(0x5A) + } + } + + // Wrap a payload in a UDP header for the given ports. Uses checksum=0 which the + // receiver accepts because we set `ignoreInboundChecksum` on UDP options. + static func makeUDPPacket(payload: [UInt8], sourcePort: UInt16, destPort: UInt16) -> [UInt8] { + Serializer.serialize { write in + write.uint16NetworkByteOrder(sourcePort) + write.uint16NetworkByteOrder(destPort) + write.uint16NetworkByteOrder(UInt16(payload.count + 8)) + write.uint16NetworkByteOrder(UInt16(0)) + write.buffer(payload) + } + } + + // Array of arrays of demux patterns; each inner array is one upper handler to be added + func testDemux( + demuxedFlows: [[DemuxPatternInput]], + datagramsPerFlow: Int = 3 + ) { + + let context = NetworkContext.implicitContext + + let expectation = XCTestExpectation() + + context.async { + defer { expectation.fulfill() } + + var parameters = Parameters() + parameters.context = context + + let localEndpoint = Endpoint(address: IPv4Address(Self.localIPv4Address)!, port: 1234) + let remoteEndpoint = Endpoint(address: IPv4Address(Self.remoteIPv4Address)!, port: 8080) + + let path = PathProperties(parameters: parameters) + + let udp = UDPProtocol.instance(context: context) + let udpOptions = UDPProtocol.options() + udpOptions.noMetadata = true + // Accept the checksum=0 packets we inject on inbound so we don't need to compute one. + udpOptions.ignoreInboundChecksum = true + udpOptions.setLogID(prefix: "D", parent: "1", protocolLogIDNumber: 1) + udpOptions.setProtocolInstance(udp) + parameters.defaultStack.transport = .udp(udpOptions) + + let demux = DemuxProtocol.instance(context: context) + + let demuxLinkage = OutboundDatagramLinkage(reference: demux) + + let upperHarness = DatagramUpperHarness( + identifier: "Default", + local: localEndpoint, + remote: remoteEndpoint, + parameters: parameters, + path: path, + context: context, + lowerProtocol: demuxLinkage + ) + XCTAssertNotNil(upperHarness, "Failed to attach default upper harness") + guard let upperHarness else { + return + } + + let lowerHarness = DatagramLowerHarness(context: context) + + try! demux.attachLowerDatagramProtocol( + udp, + remote: remoteEndpoint, + local: localEndpoint, + parameters: parameters, + path: path + ) + + try! udp.attachLowerDatagramProtocol( + lowerHarness.reference, + remote: remoteEndpoint, + local: localEndpoint, + parameters: parameters, + path: path + ) + + upperHarness.invokeConnect() + + // Tracks each pattern-based upper harness together with the patterns that + // control which inbound packets it receives from the demux. + var patternHarnesses: [(harness: DatagramUpperHarness, patterns: [DemuxPatternInput])] = [] + + for demuxedFlow in demuxedFlows { + var demuxParameters = Parameters() + demuxParameters.context = context + + guard !demuxedFlow.isEmpty else { continue } + + let demuxOptions = DemuxProtocol.options() + for patternInput in demuxedFlow { + try! demuxOptions.addPattern( + patternInput.pattern.span.bytes, + at: patternInput.offset, + mask: patternInput.mask?.span.bytes + ) + } + demuxOptions.setProtocolInstance(demux) + + demuxParameters.defaultStack.append(applicationProtocol: .custom(demuxOptions)) + + let demuxUpperHarness = DatagramUpperHarness( + identifier: "Demux", + local: localEndpoint, + remote: remoteEndpoint, + parameters: demuxParameters, + path: path, + context: context, + lowerProtocol: demuxLinkage + ) + XCTAssertNotNil(demuxUpperHarness, "Failed to attach demux upper harness") + guard let demuxUpperHarness else { + return + } + + demuxUpperHarness.invokeConnect() + + patternHarnesses.append((harness: demuxUpperHarness, patterns: demuxedFlow)) + } + + // Send datagrams from each attached upper. Every outbound datagram should + // pass through the demux and UDP to appear on the lower harness in the + // same order we wrote them. + var expectedOutboundPayloads: [[UInt8]] = [] + for sequence in 0..= 8, "Outbound packet at index \(index) too short") + let payload = Array(outbound.dropFirst(8)) + XCTAssertEqual(payload, expectedPayload, "Outbound payload mismatch at index \(index)") + } + XCTAssertFalse(lowerHarness.hasOutboundPackets, "Unexpected extra outbound packets") + + // Inject inbound datagrams for each flow. Default first, then each pattern + // flow so that FIFO drain from the lower harness lets each upper harness + // pull exactly the packets that belong to it. + var expectedInboundFlows: [(harness: DatagramUpperHarness, payloads: [[UInt8]])] = [] + + var defaultInboundPayloads: [[UInt8]] = [] + for sequence in 0.. 30 bytes). + func testDemuxMaxLengthPattern() { + let pattern = (0..<30).map { UInt8($0) } + testDemux(demuxedFlows: [[DemuxPatternInput(pattern: pattern, offset: 0)]]) + } + + // Three flows each with a pattern at a distinct non-zero offset. + func testDemuxOffsetVariations() { + testDemux(demuxedFlows: [ + [DemuxPatternInput(pattern: [0x11, 0x22, 0x33], offset: 5)], + [DemuxPatternInput(pattern: [0x44, 0x55, 0x66], offset: 12)], + [DemuxPatternInput(pattern: [0x77, 0x88, 0x99], offset: 1)], + ]) + } + + // A single upper protocol that matches two different patterns at two different offsets. + func testDemuxMultiplePatternsInFlow() { + testDemux(demuxedFlows: [ + [ + DemuxPatternInput(pattern: [0x10, 0x20, 0x30, 0x40], offset: 0), + DemuxPatternInput(pattern: [0xAA, 0xBB], offset: 12), + ] + ]) + } + + // Three upper protocols each with their own distinct pattern. + func testDemuxMultipleFlows() { + testDemux(demuxedFlows: [ + [DemuxPatternInput(pattern: [0x01, 0x02, 0x03], offset: 0)], + [DemuxPatternInput(pattern: [0x11, 0x12, 0x13], offset: 0)], + [DemuxPatternInput(pattern: [0x21, 0x22, 0x23], offset: 0)], + ]) + } + + // Pattern with a mask: only bytes 0 and 2 must match, bytes 1 and 3 are wild. + func testDemuxMaskedPattern() { + testDemux(demuxedFlows: [ + [ + DemuxPatternInput( + pattern: [0x11, 0x22, 0x33, 0x44], + offset: 0, + mask: [0xFF, 0x00, 0xFF, 0x00] + ) + ] + ]) + } + + // Combination of features: multiple flows, multiple patterns per flow, mixed offsets, + // and both masked and unmasked patterns living side-by-side. + func testDemuxComplexMix() { + testDemux(demuxedFlows: [ + [ + DemuxPatternInput(pattern: [0x01, 0x02, 0x03, 0x04], offset: 0), + DemuxPatternInput(pattern: [0xAA, 0xBB], offset: 10), + ], + [ + DemuxPatternInput( + pattern: [0x11, 0x22, 0x33, 0x44], + offset: 4, + mask: [0xFF, 0x00, 0xFF, 0x00] + ) + ], + [ + DemuxPatternInput(pattern: [0x55, 0x66, 0x77], offset: 2), + DemuxPatternInput( + pattern: [0xC0, 0xD0], + offset: 15, + mask: [0xFF, 0x00] + ), + ], + ]) + } + + func testDemuxPatternTooLong() { + let demuxOptions = DemuxProtocol.options() + // Patterns of more than 30 bytes are rejected by the API. + let tooLongPattern = [UInt8](repeating: 0xAB, count: 31) + do { + try demuxOptions.addPattern(tooLongPattern.span.bytes, at: 0) + XCTFail("Expected patternTooLong to be thrown") + } catch { + if case .patternTooLong = error { + // Expected + } else { + XCTFail("Unexpected error: \(error)") + } + } + } + + func testDemuxInvalidMask() { + let demuxOptions = DemuxProtocol.options() + // A mask whose length does not match the pattern length must be rejected. + let pattern: [UInt8] = [0x01, 0x02, 0x03, 0x04] + let shortMask: [UInt8] = [0xFF, 0xFF] + do { + try demuxOptions.addPattern(pattern.span.bytes, at: 0, mask: shortMask.span.bytes) + XCTFail("Expected invalidMask to be thrown for a mask that is too short") + } catch { + if case .invalidMask = error { + // Expected + } else { + XCTFail("Unexpected error: \(error)") + } + } + + // Also verify that a mask that is too long is rejected. + let longMask: [UInt8] = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF] + do { + try demuxOptions.addPattern(pattern.span.bytes, at: 0, mask: longMask.span.bytes) + XCTFail("Expected invalidMask to be thrown for a mask that is too long") + } catch { + if case .invalidMask = error { + // Expected + } else { + XCTFail("Unexpected error: \(error)") + } + } + } + +} + +#endif