diff --git a/Sources/CSocket/ancillary.c b/Sources/CSocket/ancillary.c new file mode 100644 index 0000000..ab7cb28 --- /dev/null +++ b/Sources/CSocket/ancillary.c @@ -0,0 +1,135 @@ +// +// ancillary.c +// Socket +// +// SCM_RIGHTS file descriptor passing. See CSocketAncillary.h. +// + +#include "CSocketAncillary.h" + +#if defined(__linux__) || defined(__ANDROID__) || defined(__APPLE__) + +#include +#include +#include + +ssize_t c_socket_send_descriptors(int socket, + const void *buffer, + size_t length, + const int *descriptors, + size_t count, + int flags) +{ + if (count > C_SOCKET_MAX_DESCRIPTORS) { + errno = EINVAL; + return -1; + } + + // A message with no payload may be dropped before its ancillary data is seen, so the + // caller must always send at least one byte alongside the descriptors. + if (length == 0) { + errno = EINVAL; + return -1; + } + + struct iovec iov; + iov.iov_base = (void *)buffer; + iov.iov_len = length; + + struct msghdr message; + memset(&message, 0, sizeof(message)); + message.msg_iov = &iov; + message.msg_iovlen = 1; + + // Sized for the worst case so the buffer is a fixed, stack allocated size. + char control[CMSG_SPACE(sizeof(int) * C_SOCKET_MAX_DESCRIPTORS)]; + + if (count > 0) { + + memset(control, 0, sizeof(control)); + + message.msg_control = control; + message.msg_controllen = CMSG_SPACE(sizeof(int) * count); + + struct cmsghdr *header = CMSG_FIRSTHDR(&message); + header->cmsg_level = SOL_SOCKET; + header->cmsg_type = SCM_RIGHTS; + header->cmsg_len = CMSG_LEN(sizeof(int) * count); + + memcpy(CMSG_DATA(header), descriptors, sizeof(int) * count); + + // msg_controllen must match what was actually written. + message.msg_controllen = header->cmsg_len; + } + + return sendmsg(socket, &message, flags); +} + +ssize_t c_socket_receive_descriptors(int socket, + void *buffer, + size_t length, + int *descriptors, + size_t capacity, + size_t *received_count, + int *truncated, + int flags) +{ + *received_count = 0; + *truncated = 0; + + struct iovec iov; + iov.iov_base = buffer; + iov.iov_len = length; + + char control[CMSG_SPACE(sizeof(int) * C_SOCKET_MAX_DESCRIPTORS)]; + memset(control, 0, sizeof(control)); + + struct msghdr message; + memset(&message, 0, sizeof(message)); + message.msg_iov = &iov; + message.msg_iovlen = 1; + message.msg_control = control; + message.msg_controllen = sizeof(control); + + ssize_t result = recvmsg(socket, &message, flags); + + if (result < 0) { + return result; + } + + // The kernel had more ancillary data than fitted; any descriptors it did deliver are still + // handled below, and the caller is told the set is incomplete. + if (message.msg_flags & MSG_CTRUNC) { + *truncated = 1; + } + + for (struct cmsghdr *header = CMSG_FIRSTHDR(&message); + header != NULL; + header = CMSG_NXTHDR(&message, header)) { + + if (header->cmsg_level != SOL_SOCKET || header->cmsg_type != SCM_RIGHTS) { + continue; + } + + size_t payload = header->cmsg_len - CMSG_LEN(0); + size_t available = payload / sizeof(int); + + const int *incoming = (const int *)(const void *)CMSG_DATA(header); + + for (size_t index = 0; index < available; index++) { + + if (descriptors != NULL && *received_count < capacity) { + descriptors[*received_count] = incoming[index]; + (*received_count)++; + } else { + // Close rather than leak a descriptor the caller cannot accept. + close(incoming[index]); + *truncated = 1; + } + } + } + + return result; +} + +#endif diff --git a/Sources/CSocket/include/CSocketAncillary.h b/Sources/CSocket/include/CSocketAncillary.h new file mode 100644 index 0000000..8f6c3b2 --- /dev/null +++ b/Sources/CSocket/include/CSocketAncillary.h @@ -0,0 +1,66 @@ +// +// CSocketAncillary.h +// Socket +// +// Passing file descriptors over a Unix domain socket with SCM_RIGHTS. +// +// The CMSG_* accessors are C macros and so cannot be called from Swift. Rather than +// reimplement their alignment arithmetic, these shims perform the whole exchange in C. +// + +#ifndef CSocketAncillary_h +#define CSocketAncillary_h + +#if defined(__linux__) || defined(__ANDROID__) || defined(__APPLE__) + +#include +#include +#include + +/// Send a message carrying file descriptors as SCM_RIGHTS ancillary data. +/// +/// @param socket The socket to send on. Must be a Unix domain socket. +/// @param buffer The message payload. At least one byte must be sent, because a message +/// with no payload may be discarded before its ancillary data is delivered. +/// @param length The payload length in bytes. +/// @param descriptors The descriptors to send, or NULL when sending none. +/// @param count How many descriptors to send. +/// @param flags Flags for sendmsg(). +/// @return The number of payload bytes sent, or -1 with errno set. +ssize_t c_socket_send_descriptors(int socket, + const void *buffer, + size_t length, + const int *descriptors, + size_t count, + int flags); + +/// Receive a message and any SCM_RIGHTS file descriptors that accompany it. +/// +/// Descriptors beyond `capacity` are closed rather than leaked, and `truncated` reports it. +/// +/// @param socket The socket to receive from. +/// @param buffer Where to write the payload. +/// @param length The capacity of `buffer` in bytes. +/// @param descriptors Where to write received descriptors, or NULL to accept none. +/// @param capacity How many descriptors `descriptors` can hold. +/// @param received_count Set to the number of descriptors written. Must not be NULL. +/// @param truncated Set to 1 if descriptors had to be discarded. Must not be NULL. +/// @param flags Flags for recvmsg(). +/// @return The number of payload bytes received, 0 at end of stream, or -1 with errno set. +ssize_t c_socket_receive_descriptors(int socket, + void *buffer, + size_t length, + int *descriptors, + size_t capacity, + size_t *received_count, + int *truncated, + int flags); + +/// The maximum number of descriptors a single message may carry. +/// +/// Matches the kernel's SCM_MAX_FD, and bounds the ancillary buffer these shims allocate. +#define C_SOCKET_MAX_DESCRIPTORS 253 + +#endif + +#endif /* CSocketAncillary_h */ diff --git a/Sources/CSocket/include/module.modulemap b/Sources/CSocket/include/module.modulemap index 908b094..4b658da 100644 --- a/Sources/CSocket/include/module.modulemap +++ b/Sources/CSocket/include/module.modulemap @@ -3,5 +3,6 @@ module CSocket { header "CSystemWASI.h" header "CSystemWindows.h" header "CSystemAndroid.h" + header "CSocketAncillary.h" export * } diff --git a/Sources/Socket/AncillaryData.swift b/Sources/Socket/AncillaryData.swift new file mode 100644 index 0000000..08fd4d4 --- /dev/null +++ b/Sources/Socket/AncillaryData.swift @@ -0,0 +1,198 @@ +// +// AncillaryData.swift +// Socket +// +// Created by Alsey Coleman Miller. +// + +#if os(Linux) || os(Android) || canImport(Darwin) + +import Foundation +import SystemPackage + +#if canImport(Glibc) +import CSocket +import Glibc +#elseif canImport(Musl) +import CSocket +import Musl +#elseif canImport(Bionic) +import CSocket +import Bionic +#elseif canImport(Darwin) +import Darwin +#endif + +/// A message received together with any file descriptors that accompanied it. +public struct SocketMessage: Equatable, Hashable, Sendable { + + /// The message payload. + public let data: Data + + /// File descriptors received as `SCM_RIGHTS` ancillary data. + /// + /// - Important: These are owned by the receiver and must be closed when finished with. + public let fileDescriptors: [SocketDescriptor] + + /// Whether descriptors were discarded because there was no room for them. + /// + /// Discarded descriptors are closed rather than leaked. + public let isTruncated: Bool + + public init(data: Data, + fileDescriptors: [SocketDescriptor] = [], + isTruncated: Bool = false) { + + self.data = data + self.fileDescriptors = fileDescriptors + self.isTruncated = isTruncated + } +} + +public extension SocketDescriptor { + + /// The maximum number of file descriptors a single message may carry. + static var maximumAncillaryDescriptors: Int { Int(C_SOCKET_MAX_DESCRIPTORS) } + + /// Send a message carrying file descriptors as `SCM_RIGHTS` ancillary data. + /// + /// - Important: At least one byte of payload must be sent. A message with an empty payload + /// may be discarded before its ancillary data is delivered, silently losing the descriptors. + /// + /// - Returns: The number of payload bytes sent. + @_alwaysEmitIntoClient + func send( + _ data: T, + fileDescriptors: [SocketDescriptor], + flags: MessageFlags = [] + ) throws -> Int { + + let bytes = [UInt8](data) + + return try _send(bytes, fileDescriptors: fileDescriptors, flags: flags).get() + } + + @usableFromInline + internal func _send( + _ bytes: [UInt8], + fileDescriptors: [SocketDescriptor], + flags: MessageFlags + ) -> Result { + + guard bytes.isEmpty == false + else { return .failure(.invalidArgument) } + + guard fileDescriptors.count <= SocketDescriptor.maximumAncillaryDescriptors + else { return .failure(.invalidArgument) } + + let rawDescriptors = fileDescriptors.map { $0.rawValue } + + let result = bytes.withUnsafeBytes { buffer -> CInt in + rawDescriptors.withUnsafeBufferPointer { descriptors in + CInt(c_socket_send_descriptors( + self.rawValue, + buffer.baseAddress, + buffer.count, + descriptors.baseAddress, + descriptors.count, + flags.rawValue + )) + } + } + + return result == -1 ? .failure(Errno(rawValue: errno)) : .success(Int(result)) + } + + /// Receive a message along with any `SCM_RIGHTS` file descriptors that accompany it. + /// + /// - Parameter maximumDescriptors: How many descriptors to accept. Any beyond this are + /// closed rather than leaked, and the result is marked truncated. + @_alwaysEmitIntoClient + func receive( + _ length: Int, + maximumDescriptors: Int = SocketDescriptor.maximumAncillaryDescriptors, + flags: MessageFlags = [] + ) throws -> SocketMessage { + + return try _receive(length, maximumDescriptors: maximumDescriptors, flags: flags).get() + } + + @usableFromInline + internal func _receive( + _ length: Int, + maximumDescriptors: Int, + flags: MessageFlags + ) -> Result { + + guard length > 0 + else { return .failure(.invalidArgument) } + + let capacity = min(max(maximumDescriptors, 0), SocketDescriptor.maximumAncillaryDescriptors) + + var buffer = [UInt8](repeating: 0, count: length) + var rawDescriptors = [CInt](repeating: -1, count: max(capacity, 1)) + var receivedCount = 0 + var truncated: CInt = 0 + + let result = buffer.withUnsafeMutableBytes { bufferPointer -> Int in + rawDescriptors.withUnsafeMutableBufferPointer { descriptors in + c_socket_receive_descriptors( + self.rawValue, + bufferPointer.baseAddress, + bufferPointer.count, + capacity > 0 ? descriptors.baseAddress : nil, + capacity, + &receivedCount, + &truncated, + flags.rawValue + ) + } + } + + guard result >= 0 + else { return .failure(Errno(rawValue: errno)) } + + let descriptors = rawDescriptors + .prefix(receivedCount) + .map { SocketDescriptor(rawValue: $0) } + + let message = SocketMessage( + data: Data(buffer.prefix(result)), + fileDescriptors: Array(descriptors), + isTruncated: truncated != 0 + ) + + return .success(message) + } +} + +// MARK: - Socket + +public extension Socket { + + /// Send a message carrying file descriptors as `SCM_RIGHTS` ancillary data. + /// + /// - Important: At least one byte of payload must be sent; see + /// ``SystemPackage/SocketDescriptor/send(_:fileDescriptors:flags:)``. + @discardableResult + func sendMessage( + _ data: T, + fileDescriptors: [SocketDescriptor] + ) async throws -> Int { + + return try await manager.sendMessage(data, fileDescriptors: fileDescriptors, for: fileDescriptor) + } + + /// Receive a message along with any file descriptors that accompany it. + func receiveMessage( + _ length: Int, + maximumDescriptors: Int + ) async throws -> SocketMessage { + + return try await manager.receiveMessage(length, + maximumDescriptors: maximumDescriptors, + for: fileDescriptor) + } +} + +#endif diff --git a/Sources/Socket/SocketManager.swift b/Sources/Socket/SocketManager.swift index 66c8a8e..cf77c53 100644 --- a/Sources/Socket/SocketManager.swift +++ b/Sources/Socket/SocketManager.swift @@ -55,6 +55,22 @@ public protocol SocketManager: AnyObject, Sendable { for fileDescriptor: SocketDescriptor ) async throws -> Int + #if os(Linux) || os(Android) || canImport(Darwin) + /// Send a message carrying file descriptors as `SCM_RIGHTS` ancillary data. + func sendMessage( + _ data: T, + fileDescriptors: [SocketDescriptor], + for fileDescriptor: SocketDescriptor + ) async throws -> Int + + /// Receive a message along with any `SCM_RIGHTS` file descriptors that accompany it. + func receiveMessage( + _ length: Int, + maximumDescriptors: Int, + for fileDescriptor: SocketDescriptor + ) async throws -> SocketMessage + #endif + /// Accept new socket. func accept( for fileDescriptor: SocketDescriptor @@ -89,3 +105,27 @@ public protocol SocketManagerConfiguration: Sendable { func configureManager() } + + +#if os(Linux) || os(Android) || canImport(Darwin) +public extension SocketManager { + + /// File descriptor passing is optional; a manager that does not implement it reports so. + func sendMessage( + _ data: T, + fileDescriptors: [SocketDescriptor], + for fileDescriptor: SocketDescriptor + ) async throws -> Int { + throw Errno.notSupported + } + + /// File descriptor passing is optional; a manager that does not implement it reports so. + func receiveMessage( + _ length: Int, + maximumDescriptors: Int, + for fileDescriptor: SocketDescriptor + ) async throws -> SocketMessage { + throw Errno.notSupported + } +} +#endif diff --git a/Sources/Socket/SocketManager/AsyncSocketManager.swift b/Sources/Socket/SocketManager/AsyncSocketManager.swift index 2fe3ff0..1ffa878 100644 --- a/Sources/Socket/SocketManager/AsyncSocketManager.swift +++ b/Sources/Socket/SocketManager/AsyncSocketManager.swift @@ -165,6 +165,33 @@ internal actor AsyncSocketManager: SocketManager { return try await socket.receiveMessage(length, fromAddressOf: addressType) } + #if os(Linux) || os(Android) || canImport(Darwin) + /// Send a message carrying file descriptors as `SCM_RIGHTS` ancillary data. + nonisolated func sendMessage( + _ data: T, + fileDescriptors: [SocketDescriptor], + for fileDescriptor: SocketDescriptor + ) async throws -> Int { + // Copied to a Sendable buffer before crossing into the actor, because DataProtocol + // carries no Sendable guarantee. + let bytes = [UInt8](data) + let socket = try await wait(for: .write, fileDescriptor: fileDescriptor) + await log("Will send message with \(bytes.count) bytes and \(fileDescriptors.count) file descriptors to \(fileDescriptor)") + return try await socket.sendMessage(bytes, fileDescriptors: fileDescriptors) + } + + /// Receive a message along with any `SCM_RIGHTS` file descriptors that accompany it. + nonisolated func receiveMessage( + _ length: Int, + maximumDescriptors: Int, + for fileDescriptor: SocketDescriptor + ) async throws -> SocketMessage { + let socket = try await wait(for: .read, fileDescriptor: fileDescriptor) + await log("Will receive message with \(length) bytes and up to \(maximumDescriptors) file descriptors from \(fileDescriptor)") + return try await socket.receiveMessage(length, maximumDescriptors: maximumDescriptors) + } + #endif + nonisolated func listen(backlog: Int, for fileDescriptor: SocketDescriptor) async throws { let socket = try await self.socket(for: fileDescriptor) try await socket.listen(backlog: backlog) @@ -194,6 +221,7 @@ internal actor AsyncSocketManager: SocketManager { try await retry(sleep: state.configuration.monitorInterval) { fileDescriptor._connect(to: address, retryOnInterrupt: true) }.get() + await socket.markEstablished() socket.continuation.yield(.connection) } } @@ -319,11 +347,12 @@ private extension AsyncSocketManager { let hasEvents = state.pollDescriptors.contains(where: { $0.returnedEvents.isEmpty == false }) if hasEvents { for poll in state.pollDescriptors { - guard let state = state.sockets[poll.socket] else { - preconditionFailure() + // The socket may have been removed since the poll array was built, e.g. by a + // concurrent close. Skip it rather than trapping. + guard let socketState = state.sockets[poll.socket] else { continue } - let task = process(poll, socket: state) + let task = process(poll, socket: socketState) tasks.append(task) } } @@ -338,14 +367,23 @@ private extension AsyncSocketManager { if poll.returnedEvents.contains(.write) { await socket.event(.write, notification: .write) } + // These tear the socket down, so they must not be applied to a different socket + // that has since been given the same file descriptor number. if poll.returnedEvents.contains(.invalidRequest) { - error(.badFileDescriptor, for: poll.socket) + error(.badFileDescriptor, for: poll.socket, socket: socket) } if poll.returnedEvents.contains(.error) { - error(.connectionReset, for: poll.socket) + error(.connectionReset, for: poll.socket, socket: socket) } + // A socket that has not yet been connected polls as POLLHUP, so acting on it here + // would tear down a socket that is only moments away from being connected. if poll.returnedEvents.contains(.hangup) { - hangup(poll.socket) + let isEstablished = await socket.isEstablished + let isListening = await socket.isListening + let isMeaningful = isEstablished || isListening + if isMeaningful { + hangup(poll.socket, socket: socket) + } } } } @@ -358,10 +396,50 @@ private extension AsyncSocketManager { func hangup(_ fileDescriptor: SocketDescriptor) { remove(fileDescriptor) } + + /// Report an error for a socket, provided the descriptor still refers to it. + /// + /// Poll results are acted on from a task that runs after polling, by which time the + /// descriptor may have been closed and its number reused by a new socket. Without this + /// check the new socket would be torn down, and its first operation would fail with + /// `ESHUTDOWN` from `socket(for:)`. + func error(_ error: Errno, for fileDescriptor: SocketDescriptor, socket: SocketState) { + guard isCurrent(socket, for: fileDescriptor) else { return } + self.error(error, for: fileDescriptor) + } + + /// Hang up a socket, provided the descriptor still refers to it. + func hangup(_ fileDescriptor: SocketDescriptor, socket: SocketState) { + guard isCurrent(socket, for: fileDescriptor) else { return } + hangup(fileDescriptor) + } + + /// Whether the descriptor is still registered to this very socket, rather than to a newer + /// one that reused the number. + func isCurrent(_ socket: SocketState, for fileDescriptor: SocketDescriptor) -> Bool { + guard let current = state.sockets[fileDescriptor] else { return false } + return current === socket + } } extension AsyncSocketManager.SocketState { + #if os(Linux) || os(Android) || canImport(Darwin) + func sendMessage(_ data: [UInt8], fileDescriptors: [SocketDescriptor]) throws -> Int { + let byteCount = try fileDescriptor.send(data, fileDescriptors: fileDescriptors) + // notify + didWrite(byteCount) + return byteCount + } + + func receiveMessage(_ length: Int, maximumDescriptors: Int) throws -> SocketMessage { + let message = try fileDescriptor.receive(length, maximumDescriptors: maximumDescriptors) + // notify + didRead(message.data.count) + return message + } + #endif + func write(_ data: Data) throws -> Int { let byteCount = try data.withUnsafeBytes { try fileDescriptor.write($0) @@ -431,6 +509,7 @@ extension AsyncSocketManager.SocketState { func listen(backlog: Int) throws { try fileDescriptor.listen(backlog: backlog) isListening = true + isEstablished = true } func accept() throws -> SocketDescriptor { @@ -445,15 +524,22 @@ extension AsyncSocketManager.SocketState { fileprivate extension AsyncSocketManager.SocketState { func didRead(_ bytes: Int) { + isEstablished = true pendingEvents.remove(.read) continuation.yield(.didRead(bytes)) } func didWrite(_ bytes: Int) { + isEstablished = true pendingEvents.remove(.write) continuation.yield(.didWrite(bytes)) } + /// Record that the socket is connected, so a hangup on it is meaningful. + func markEstablished() { + isEstablished = true + } + func dequeueAll(_ error: Error) { // cancel all continuations for event in eventContinuation.keys { @@ -539,6 +625,12 @@ extension AsyncSocketManager { var isListening = false + /// Whether the socket has been connected, or has completed any I/O. + /// + /// A socket that has never been connected polls as POLLHUP on Linux, so a hangup can + /// only be believed once the socket has actually been established. + var isEstablished = false + init( fileDescriptor: SocketDescriptor, manager: AsyncSocketManager, diff --git a/Tests/SocketTests/AncillaryDataTests.swift b/Tests/SocketTests/AncillaryDataTests.swift new file mode 100644 index 0000000..0914c0e --- /dev/null +++ b/Tests/SocketTests/AncillaryDataTests.swift @@ -0,0 +1,246 @@ +// +// AncillaryDataTests.swift +// Socket +// + +#if os(Linux) || os(Android) || canImport(Darwin) + +import Foundation +import SystemPackage +import Testing +@testable import Socket + +#if canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#endif + +/// Tests for `SCM_RIGHTS` file descriptor passing. +/// +/// A passed descriptor is a genuinely new descriptor in the receiving process, so the proof is +/// that reading through it observes what the sender wrote through the original. +@Suite(.serialized) +struct AncillaryDataTests { + + /// A connected pair of Unix stream sockets, as `socketpair` provides. + private func makeSocketPair() throws -> (SocketDescriptor, SocketDescriptor) { + + var descriptors: [CInt] = [-1, -1] + + let result = descriptors.withUnsafeMutableBufferPointer { pointer in + socketpair(AF_UNIX, CInt(SOCK_STREAM.rawValue), 0, pointer.baseAddress) + } + + guard result == 0 + else { throw Errno(rawValue: errno) } + + return (SocketDescriptor(rawValue: descriptors[0]), + SocketDescriptor(rawValue: descriptors[1])) + } + + /// A temporary file containing `contents`, plus its path. + private func makeTemporaryFile(contents: String) throws -> (SocketDescriptor, String) { + + let path = "/tmp/socket-scm-test-\(UInt32.random(in: 0 ... .max))" + + try contents.write(toFile: path, atomically: true, encoding: .utf8) + + let descriptor = open(path, O_RDONLY) + + guard descriptor >= 0 + else { throw Errno(rawValue: errno) } + + return (SocketDescriptor(rawValue: descriptor), path) + } + + private func readAll(_ descriptor: SocketDescriptor) -> String { + + var buffer = [UInt8](repeating: 0, count: 256) + let count = read(descriptor.rawValue, &buffer, buffer.count) + + guard count > 0 else { return "" } + + return String(decoding: buffer[0 ..< count], as: UTF8.self) + } + + @Test func passesFileDescriptor() throws { + + let (sender, receiver) = try makeSocketPair() + defer { close(sender.rawValue); close(receiver.rawValue) } + + let (file, path) = try makeTemporaryFile(contents: "payload through a passed descriptor") + defer { close(file.rawValue); unlink(path) } + + let sent = try sender.send([UInt8]("hello".utf8), fileDescriptors: [file]) + #expect(sent == 5) + + let message = try receiver.receive(64) + + #expect(String(decoding: message.data, as: UTF8.self) == "hello") + #expect(message.fileDescriptors.count == 1) + #expect(message.isTruncated == false) + + let received = try #require(message.fileDescriptors.first) + defer { close(received.rawValue) } + + // A distinct descriptor number in this process, referring to the same open file. + #expect(received.rawValue != file.rawValue) + #expect(readAll(received) == "payload through a passed descriptor") + } + + @Test func passesSeveralFileDescriptors() throws { + + let (sender, receiver) = try makeSocketPair() + defer { close(sender.rawValue); close(receiver.rawValue) } + + var files = [SocketDescriptor]() + var paths = [String]() + + for index in 0 ..< 3 { + let (file, path) = try makeTemporaryFile(contents: "file \(index)") + files.append(file) + paths.append(path) + } + + defer { + files.forEach { close($0.rawValue) } + paths.forEach { unlink($0) } + } + + _ = try sender.send([UInt8]("x".utf8), fileDescriptors: files) + + let message = try receiver.receive(64) + #expect(message.fileDescriptors.count == 3) + + defer { message.fileDescriptors.forEach { close($0.rawValue) } } + + for (index, descriptor) in message.fileDescriptors.enumerated() { + #expect(readAll(descriptor) == "file \(index)", "Descriptor order must be preserved") + } + } + + @Test func sendsWithoutDescriptors() throws { + + let (sender, receiver) = try makeSocketPair() + defer { close(sender.rawValue); close(receiver.rawValue) } + + _ = try sender.send([UInt8]("plain".utf8), fileDescriptors: []) + + let message = try receiver.receive(64) + + #expect(String(decoding: message.data, as: UTF8.self) == "plain") + #expect(message.fileDescriptors.isEmpty) + #expect(message.isTruncated == false) + } + + /// Descriptors that do not fit must be closed rather than leaked, and reported. + @Test func closesDescriptorsThatDoNotFit() throws { + + let (sender, receiver) = try makeSocketPair() + defer { close(sender.rawValue); close(receiver.rawValue) } + + var files = [SocketDescriptor]() + var paths = [String]() + + for index in 0 ..< 3 { + let (file, path) = try makeTemporaryFile(contents: "file \(index)") + files.append(file) + paths.append(path) + } + + defer { + files.forEach { close($0.rawValue) } + paths.forEach { unlink($0) } + } + + _ = try sender.send([UInt8]("x".utf8), fileDescriptors: files) + + // Accept only one of the three. + let message = try receiver.receive(64, maximumDescriptors: 1) + + #expect(message.fileDescriptors.count == 1) + #expect(message.isTruncated, "The caller must be told descriptors were dropped") + + defer { message.fileDescriptors.forEach { close($0.rawValue) } } + + #expect(readAll(message.fileDescriptors[0]) == "file 0") + } + + @Test func acceptsNoDescriptors() throws { + + let (sender, receiver) = try makeSocketPair() + defer { close(sender.rawValue); close(receiver.rawValue) } + + let (file, path) = try makeTemporaryFile(contents: "unwanted") + defer { close(file.rawValue); unlink(path) } + + _ = try sender.send([UInt8]("x".utf8), fileDescriptors: [file]) + + let message = try receiver.receive(64, maximumDescriptors: 0) + + #expect(message.fileDescriptors.isEmpty) + #expect(message.isTruncated) + } + + /// An empty payload may be discarded before its ancillary data is seen, so it is rejected. + @Test func rejectsEmptyPayload() throws { + + let (sender, receiver) = try makeSocketPair() + defer { close(sender.rawValue); close(receiver.rawValue) } + + let (file, path) = try makeTemporaryFile(contents: "x") + defer { close(file.rawValue); unlink(path) } + + #expect(throws: Errno.invalidArgument) { + try sender.send([UInt8](), fileDescriptors: [file]) + } + } + + @Test func rejectsTooManyDescriptors() throws { + + let (sender, receiver) = try makeSocketPair() + defer { close(sender.rawValue); close(receiver.rawValue) } + + let (file, path) = try makeTemporaryFile(contents: "x") + defer { close(file.rawValue); unlink(path) } + + let tooMany = [SocketDescriptor]( + repeating: file, + count: SocketDescriptor.maximumAncillaryDescriptors + 1 + ) + + #expect(throws: Errno.invalidArgument) { + try sender.send([UInt8]("x".utf8), fileDescriptors: tooMany) + } + } + + /// The same exchange through the async `Socket` API rather than the raw descriptor. + @Test func passesThroughAsyncSocket() async throws { + + let (senderDescriptor, receiverDescriptor) = try makeSocketPair() + + let sender = await Socket(fileDescriptor: senderDescriptor) + let receiver = await Socket(fileDescriptor: receiverDescriptor) + + let (file, path) = try makeTemporaryFile(contents: "through the async api") + defer { close(file.rawValue); unlink(path) } + + _ = try await sender.sendMessage([UInt8]("hi".utf8), fileDescriptors: [file]) + + let message = try await receiver.receiveMessage(64, maximumDescriptors: 4) + + #expect(String(decoding: message.data, as: UTF8.self) == "hi") + #expect(message.fileDescriptors.count == 1) + + if let received = message.fileDescriptors.first { + #expect(readAll(received) == "through the async api") + close(received.rawValue) + } + + await sender.close() + await receiver.close() + } +} + +#endif