From 8a34763e400a790a51c67764aacbac94ce5f91c3 Mon Sep 17 00:00:00 2001 From: Danny Canter Date: Tue, 14 Oct 2025 15:59:56 -0700 Subject: [PATCH] Socket: Add fd receiving (#330) Via scm_rights. Useful for supporting spawning runc as an OCI runtime as that's how the pty is passed to the client. --- Sources/CShim/include/socket_helpers.h | 29 +++++ Sources/CShim/socket_helpers.c | 33 +++++ .../ContainerizationOS/Socket/Socket.swift | 79 ++++++++++++ .../ContainerizationOSTests/SocketTests.swift | 121 ++++++++++++++++++ 4 files changed, 262 insertions(+) create mode 100644 Sources/CShim/include/socket_helpers.h create mode 100644 Sources/CShim/socket_helpers.c create mode 100644 Tests/ContainerizationOSTests/SocketTests.swift diff --git a/Sources/CShim/include/socket_helpers.h b/Sources/CShim/include/socket_helpers.h new file mode 100644 index 00000000..93f6c004 --- /dev/null +++ b/Sources/CShim/include/socket_helpers.h @@ -0,0 +1,29 @@ +/* + * Copyright © 2025 Apple Inc. and the Containerization project authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef socket_helpers_h +#define socket_helpers_h + +#include +#include + +// Helper functions to access CMSG macros from Swift +struct cmsghdr* CZ_CMSG_FIRSTHDR(struct msghdr *msg); +void* CZ_CMSG_DATA(struct cmsghdr *cmsg); +size_t CZ_CMSG_SPACE(size_t length); +size_t CZ_CMSG_LEN(size_t length); + +#endif /* socket_helpers_h */ diff --git a/Sources/CShim/socket_helpers.c b/Sources/CShim/socket_helpers.c new file mode 100644 index 00000000..d9f3de8a --- /dev/null +++ b/Sources/CShim/socket_helpers.c @@ -0,0 +1,33 @@ +/* + * Copyright © 2025 Apple Inc. and the Containerization project authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "socket_helpers.h" + +struct cmsghdr* CZ_CMSG_FIRSTHDR(struct msghdr *msg) { + return CMSG_FIRSTHDR(msg); +} + +void* CZ_CMSG_DATA(struct cmsghdr *cmsg) { + return CMSG_DATA(cmsg); +} + +size_t CZ_CMSG_SPACE(size_t length) { + return CMSG_SPACE(length); +} + +size_t CZ_CMSG_LEN(size_t length) { + return CMSG_LEN(length); +} diff --git a/Sources/ContainerizationOS/Socket/Socket.swift b/Sources/ContainerizationOS/Socket/Socket.swift index 65937252..26a2270f 100644 --- a/Sources/ContainerizationOS/Socket/Socket.swift +++ b/Sources/ContainerizationOS/Socket/Socket.swift @@ -14,6 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import CShim import Foundation import Synchronization @@ -42,6 +43,7 @@ let sysListen = listen let sysAccept = accept let sysConnect = connect let sysIoctl: @convention(c) (CInt, CUnsignedLong, UnsafeMutableRawPointer) -> CInt = ioctl +let sysRecvmsg = recvmsg #endif /// Thread-safe socket wrapper. @@ -102,6 +104,20 @@ public final class Socket: Sendable { self.state = Mutex(state) } + /// Internal initializer for wrapping already-connected file descriptors (e.g., from socketpair) + /// Ideally we just get rid of the state machine in this class. Not sure how much value it provides.. + init(fd: Int32, type: SocketType, closeOnDeinit: Bool, connected: Bool) { + _queue = DispatchQueue(label: "com.apple.containerization.socket") + _closeOnDeinit = closeOnDeinit + let state = State( + socketState: connected ? .connected : .created, + handle: FileHandle(fileDescriptor: fd, closeOnDealloc: false), + type: type, + acceptSource: nil + ) + self.state = Mutex(state) + } + deinit { if _closeOnDeinit { try? close() @@ -288,6 +304,66 @@ extension Socket { ) } + /// Receive a file descriptor via SCM_RIGHTS control message. + /// This is commonly used for passing file descriptors between processes via Unix domain sockets. + public func receiveFileDescriptor() throws -> FileHandle { + let handle = try state.withLock { currentState in + guard currentState.socketState == .connected else { + throw SocketError.invalidOperationOnSocket("receiveFileDescriptor") + } + guard let handle = currentState.handle else { + throw SocketError.closed + } + return handle + } + + var msg = msghdr() + var iov = iovec() + var buf: UInt8 = 0 + + iov.iov_base = withUnsafeMutablePointer(to: &buf) { UnsafeMutableRawPointer($0) } + iov.iov_len = 1 + + msg.msg_iov = withUnsafeMutablePointer(to: &iov) { $0 } + msg.msg_iovlen = 1 + + var cmsgBuf = [UInt8](repeating: 0, count: Int(CZ_CMSG_SPACE(Int(MemoryLayout.size)))) + msg.msg_control = withUnsafeMutablePointer(to: &cmsgBuf[0]) { UnsafeMutableRawPointer($0) } + msg.msg_controllen = socklen_t(cmsgBuf.count) + + let recvResult = withUnsafeMutablePointer(to: &msg) { msgPtr in + sysRecvmsg(handle.fileDescriptor, msgPtr, 0) + } + + guard recvResult >= 0 else { + throw Socket.errnoToError(msg: "recvmsg failed") + } + + // Extract file descriptor from control message + let cmsgPtr = withUnsafeMutablePointer(to: &msg) { CZ_CMSG_FIRSTHDR($0) } + guard let cmsg = cmsgPtr else { + throw SocketError.invalidFileDescriptor + } + + guard cmsg.pointee.cmsg_level == SOL_SOCKET, + cmsg.pointee.cmsg_type == SCM_RIGHTS + else { + throw SocketError.invalidFileDescriptor + } + + guard let dataPtr = CZ_CMSG_DATA(cmsg) else { + throw SocketError.invalidFileDescriptor + } + + let fdPtr = dataPtr.assumingMemoryBound(to: Int32.self) + let fd = fdPtr.pointee + guard fd >= 0 else { + throw SocketError.invalidFileDescriptor + } + + return FileHandle(fileDescriptor: fd, closeOnDealloc: true) + } + public func read(buffer: inout Data) throws -> Int { let handle = try state.withLock { currentState in guard currentState.socketState == .connected else { @@ -396,6 +472,7 @@ public enum SocketError: Error, Equatable, CustomStringConvertible { case invalidOperationOnSocket(String) case missingBaseAddress case withErrno(_ msg: String, errno: Int32) + case invalidFileDescriptor public var description: String { switch self { @@ -409,6 +486,8 @@ public enum SocketError: Error, Equatable, CustomStringConvertible { return "socket: missing base address" case .withErrno(let msg, _): return "socket: error \(msg)" + case .invalidFileDescriptor: + return "socket: invalid file descriptor received" } } } diff --git a/Tests/ContainerizationOSTests/SocketTests.swift b/Tests/ContainerizationOSTests/SocketTests.swift new file mode 100644 index 00000000..b0e0902a --- /dev/null +++ b/Tests/ContainerizationOSTests/SocketTests.swift @@ -0,0 +1,121 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import CShim +import Foundation +import Testing + +@testable import ContainerizationOS + +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#endif + +@Suite("Socket SCM_RIGHTS tests") +final class SocketTests { + + /// Helper function to send a file descriptor via SCM_RIGHTS + private func sendFileDescriptor(socket: Socket, fd: Int32) throws { + var msg = msghdr() + var iov = iovec() + var buf: UInt8 = 0 + + iov.iov_base = withUnsafeMutablePointer(to: &buf) { UnsafeMutableRawPointer($0) } + iov.iov_len = 1 + + msg.msg_iov = withUnsafeMutablePointer(to: &iov) { $0 } + msg.msg_iovlen = 1 + + // Control message buffer for file descriptor + var cmsgBuf = [UInt8](repeating: 0, count: Int(CZ_CMSG_SPACE(Int(MemoryLayout.size)))) + + msg.msg_control = withUnsafeMutablePointer(to: &cmsgBuf[0]) { UnsafeMutableRawPointer($0) } + msg.msg_controllen = socklen_t(cmsgBuf.count) + + // Set up control message + let cmsgPtr = withUnsafeMutablePointer(to: &msg) { CZ_CMSG_FIRSTHDR($0) } + guard let cmsg = cmsgPtr else { + throw SocketError.invalidFileDescriptor + } + + cmsg.pointee.cmsg_level = SOL_SOCKET + cmsg.pointee.cmsg_type = SCM_RIGHTS + cmsg.pointee.cmsg_len = socklen_t(CZ_CMSG_LEN(Int(MemoryLayout.size))) + + guard let dataPtr = CZ_CMSG_DATA(cmsg) else { + throw SocketError.invalidFileDescriptor + } + + dataPtr.assumingMemoryBound(to: Int32.self).pointee = fd + + let sendResult = withUnsafeMutablePointer(to: &msg) { msgPtr in + sendmsg(socket.fileDescriptor, msgPtr, 0) + } + + guard sendResult >= 0 else { + throw SocketError.withErrno("sendmsg failed", errno: errno) + } + } + + @Test + func testSCMRightsFileDescriptorPassing() throws { + // Create a socketpair for testing + var fds: [Int32] = [0, 0] + let result = socketpair(AF_UNIX, SOCK_STREAM, 0, &fds) + try #require(result == 0, "socketpair should succeed") + + defer { + close(fds[0]) + close(fds[1]) + } + + // Use a dummy UnixType since we won't be using it for bind/connect/listen + let socketType = try UnixType(path: "/tmp/dummy") + let sendSocket = Socket(fd: fds[0], type: socketType, closeOnDeinit: false, connected: true) + let recvSocket = Socket(fd: fds[1], type: socketType, closeOnDeinit: false, connected: true) + + // Create a temporary file to send its descriptor + let fileManager = FileManager.default + let tempDir = fileManager.uniqueTemporaryDirectory() + defer { try? fileManager.removeItem(at: tempDir) } + + let testFilePath = tempDir.appending(path: "test.txt") + let testContent = "Hello, SCM_RIGHTS!" + try testContent.write(to: testFilePath, atomically: true, encoding: .utf8) + + let testFileHandle = try FileHandle(forReadingFrom: testFilePath) + defer { try? testFileHandle.close() } + + let originalFD = testFileHandle.fileDescriptor + + try sendFileDescriptor(socket: sendSocket, fd: originalFD) + let receivedFileHandle = try recvSocket.receiveFileDescriptor() + defer { try? receivedFileHandle.close() } + + try #require(receivedFileHandle.fileDescriptor != originalFD, "Received FD should be different") + try #require(receivedFileHandle.fileDescriptor >= 0, "Received FD should be valid") + + let data = try receivedFileHandle.readToEnd() + try #require(data != nil, "Should be able to read from received FD") + + let receivedContent = String(data: data!, encoding: .utf8) + #expect(receivedContent == testContent, "Content should match original file") + } +}