From 8337bfcdb19ea2a7c4f9d240bfe5b6a396741872 Mon Sep 17 00:00:00 2001 From: Ian Gordon Date: Thu, 30 Jul 2026 07:11:18 -0400 Subject: [PATCH] Fix pktinfo send cmsg_level/cmsg_type per ip(7) / RFC 3542 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Send-side IP_PKTINFO/IPV6_PKTINFO control messages used SOL_SOCKET as the cmsg_level and the protocol number as the cmsg_type, inconsistent with the library's own receive path. Set IPPROTO_IP/IP_PKTINFO and IPPROTO_IPV6/IPV6_PKTINFO as required by ip(7) and RFC 3542 ยง6. Add deterministic tests asserting the exact level/type pairs against platform constants (verified to fail against the previous values), plus loopback round-trip smoke tests. Co-Authored-By: Claude Fable 5 --- FlyingSocks/Sources/Socket.swift | 22 +++++-- FlyingSocks/Tests/AsyncSocketTests.swift | 83 ++++++++++++++++++++++++ FlyingSocks/Tests/SocketTests.swift | 43 ++++++++++++ 3 files changed, 142 insertions(+), 6 deletions(-) diff --git a/FlyingSocks/Sources/Socket.swift b/FlyingSocks/Sources/Socket.swift index 45dd43d..2e63ff8 100644 --- a/FlyingSocks/Sources/Socket.swift +++ b/FlyingSocks/Sources/Socket.swift @@ -679,7 +679,7 @@ private extension SocketAddress { } #if !canImport(WinSDK) -fileprivate extension Socket { +extension Socket { // https://github.com/swiftlang/swift-evolution/blob/main/proposals/0138-unsaferawbufferpointer.md private static func withControlMessage( control: UnsafeRawPointer, @@ -713,7 +713,7 @@ fileprivate extension Socket { } } - static func getPacketInfoControl( + fileprivate static func getPacketInfoControl( msghdr: msghdr ) -> (UInt32?, sockaddr_storage?) { var interfaceIndex: UInt32? @@ -745,6 +745,8 @@ fileprivate extension Socket { return (interfaceIndex, interfaceIndex != nil ? localAddress : nil) } + // Internal (not fileprivate) so SocketTests can assert the control-message + // level/type contract without a live socket. static func withPacketInfoControl( family: sa_family_t, interfaceIndex: UInt32?, @@ -756,8 +758,13 @@ fileprivate extension Socket { let buffer = ManagedBuffer.create(minimumCapacity: 1) { buffer in buffer.withUnsafeMutablePointers { header, element in header.pointee.cmsg_len = ControlMessageHeaderLengthType(MemoryLayout.size + MemoryLayout.size) - header.pointee.cmsg_level = SOL_SOCKET - header.pointee.cmsg_type = Socket.ipproto_ip + // An IP_PKTINFO ancillary message is at the IPPROTO_IP + // cmsg level โ€” ip(7): "Pass an IP_PKTINFO ancillary + // message ... sent/retrieved only as a control message + // with a packet using recvmsg(2) or sendmsg(2)". Must + // match getPacketInfoControl above. + header.pointee.cmsg_level = Socket.ipproto_ip + header.pointee.cmsg_type = Socket.ip_pktinfo element.pointee.ipi_ifindex = IPv4InterfaceIndexType(interfaceIndex ?? 0) if let address { var address = address @@ -779,8 +786,11 @@ fileprivate extension Socket { let buffer = ManagedBuffer.create(minimumCapacity: 1) { buffer in buffer.withUnsafeMutablePointers { header, element in header.pointee.cmsg_len = ControlMessageHeaderLengthType(MemoryLayout.size + MemoryLayout.size) - header.pointee.cmsg_level = SOL_SOCKET - header.pointee.cmsg_type = Socket.ipproto_ipv6 + // RFC 3542 ยง6: "the socket option and cmsghdr level will + // be IPPROTO_IPV6, the type will be IPV6_PKTINFO". Must + // match getPacketInfoControl above. + header.pointee.cmsg_level = Socket.ipproto_ipv6 + header.pointee.cmsg_type = Socket.ipv6_pktinfo element.pointee.ipi6_ifindex = IPv6InterfaceIndexType(interfaceIndex ?? 0) if let address { var address = address diff --git a/FlyingSocks/Tests/AsyncSocketTests.swift b/FlyingSocks/Tests/AsyncSocketTests.swift index 20f82c6..2d667a1 100644 --- a/FlyingSocks/Tests/AsyncSocketTests.swift +++ b/FlyingSocks/Tests/AsyncSocketTests.swift @@ -324,6 +324,80 @@ struct AsyncSocketTests { } ) } + + @Test + func sendMessage_WithPacketInfo_RoundTripsDatagram() async throws { + // TVT-1064 smoke test: sendmsg(2) accepts the corrected pktinfo + // control message (Linux rejects a malformed level/type with EINVAL). + // The values asserted below are receive-side pktinfo generated by the + // kernel โ€” on loopback they match regardless of whether the sender's + // request was honored, so the exact cmsg level/type contract is + // asserted in SocketTests.withPacketInfoControl_IP4_SetsPktInfoLevelAndType. + // The localAddress argument exercises the source-address branch of + // withPacketInfoControl; whether it is honored is TVT-1132. + let (server, port) = try await AsyncSocket.makeLoopbackDatagramIP4() + + async let received: AsyncSocket.Message = server.receive(atMost: 100) + + let client = try await AsyncSocket.makeLoopbackDatagramIP4().0 + try await client.send(message: AsyncSocket.Message( + peerAddress: sockaddr_in.inet(ip4: "127.0.0.1", port: port), + payload: Data("Fish ๐Ÿก".utf8), + localAddress: sockaddr_in.inet(ip4: "127.0.0.1", port: 0) + )) + + let message = try await received + #expect(try message.payloadString == "Fish ๐Ÿก") + #expect(message.interfaceIndex != nil) + let storage = try #require(message.localAddress).makeStorage() + let sin = withUnsafeBytes(of: storage) { $0.load(as: sockaddr_in.self) } + #expect( + try sin.sin_addr.s_addr == sockaddr_in.inet(ip4: "127.0.0.1", port: 0).sin_addr.s_addr + ) + } + + @Test + func sendMessage_WithPacketInfoIPv6_RoundTripsDatagram() async throws { + // TVT-1064 smoke test: sendmsg(2) accepts the corrected pktinfo + // control message. The values asserted below are receive-side pktinfo + // generated by the kernel โ€” on loopback they match regardless of + // whether the sender's request was honored, so the exact cmsg + // level/type contract is asserted in + // SocketTests.withPacketInfoControl_IP6_SetsPktInfoLevelAndType. + let (server, port) = try await AsyncSocket.makeLoopbackDatagram() + + async let received: AsyncSocket.Message = server.receive(atMost: 100) + + // Select the outgoing interface via ipi6_ifindex โ€” RFC 3542 ยง6.1. + // A ::1 source address is not used here: on Darwin, sendmsg(2) then + // fails with EADDRNOTAVAIL (observed during TVT-1064 execution). + #if canImport(Darwin) + let loopbackIndex = if_nametoindex("lo0") + #else + let loopbackIndex = if_nametoindex("lo") + #endif + // if_nametoindex(3): "If the specified interface does not exist, it + // returns 0." + try #require(loopbackIndex != 0) + let client = try await AsyncSocket.makeLoopbackDatagram().0 + try await client.send(message: AsyncSocket.Message( + peerAddress: sockaddr_in6.loopback(port: port), + payload: Data("Chips ๐ŸŸ".utf8), + interfaceIndex: UInt32(loopbackIndex) + )) + + let message = try await received + #expect(try message.payloadString == "Chips ๐ŸŸ") + #expect(message.interfaceIndex != nil) + let storage = try #require(message.localAddress).makeStorage() + let sin6 = withUnsafeBytes(of: storage) { $0.load(as: sockaddr_in6.self) } + let loopback = sockaddr_in6.loopback(port: 0).sin6_addr + #expect( + withUnsafeBytes(of: sin6.sin6_addr) { received in + withUnsafeBytes(of: loopback) { received.elementsEqual($0) } + } + ) + } #endif } @@ -365,6 +439,15 @@ extension AsyncSocket { return (socket, port) } + static func makeLoopbackDatagramIP4() async throws -> (AsyncSocket, port: UInt16) { + let socket = try await AsyncSocket.make(domain: AF_INET, type: .datagram) + try socket.socket.bind(to: .inet(ip4: "127.0.0.1", port: 0)) + guard case let .ip4(_, port: port) = try socket.socket.sockname() else { + fatalError() + } + return (socket, port) + } + #if !canImport(WinSDK) static func makeDatagramPair() async throws -> (AsyncSocket, AsyncSocket, sockaddr_un) { let socketPair = try await makePair(pool: .client, type: .datagram) diff --git a/FlyingSocks/Tests/SocketTests.swift b/FlyingSocks/Tests/SocketTests.swift index e696f2b..bab2737 100644 --- a/FlyingSocks/Tests/SocketTests.swift +++ b/FlyingSocks/Tests/SocketTests.swift @@ -377,6 +377,49 @@ struct SocketTests { try socket.getValue(for: .packetInfoIPv6) == true ) } + + #if !canImport(WinSDK) + @Test + func withPacketInfoControl_IP4_SetsPktInfoLevelAndType() { + // TVT-1064 regression: cmsg_level must identify the originating + // protocol and cmsg_type the protocol-specific option โ€” not + // SOL_SOCKET with a protocol number in the type field. macOS SDK + // : "#define IP_PKTINFO 26 /* get pktinfo on recv + // socket, set src on sent dgram */"; ip(7) on Linux. + let (level, type) = Socket.withPacketInfoControl( + family: sa_family_t(AF_INET), + interfaceIndex: nil, + address: nil + ) { header, _ in + (header?.pointee.cmsg_level, header?.pointee.cmsg_type) + } + #expect(level == Int32(IPPROTO_IP)) + #expect(type == Int32(IP_PKTINFO)) + } + + @Test + func withPacketInfoControl_IP6_SetsPktInfoLevelAndType() { + // TVT-1064 regression: RFC 3542 ยง6 โ€” "the socket option and cmsghdr + // level will be IPPROTO_IPV6, the type will be IPV6_PKTINFO". + // The non-nil address exercises the source-address copy branch. + let (level, type) = Socket.withPacketInfoControl( + family: sa_family_t(AF_INET6), + interfaceIndex: nil, + address: sockaddr_in6.loopback(port: 0) + ) { header, _ in + (header?.pointee.cmsg_level, header?.pointee.cmsg_type) + } + #expect(level == Int32(IPPROTO_IPV6)) + #if canImport(Darwin) + // macOS SDK : "#define IPV6_3542PKTINFO 46 + // /* in6_pktinfo; send if, src addr */". IPV6_PKTINFO is gated + // behind __APPLE_USE_RFC_3542, which Swift cannot define. + #expect(type == 46) + #else + #expect(type == Int32(IPV6_PKTINFO)) + #endif + } + #endif } extension Socket.Flags {