From a2a62f73875970d49877db70182309962ea50cc4 Mon Sep 17 00:00:00 2001 From: Danny Canter Date: Thu, 5 Mar 2026 01:02:33 -0800 Subject: [PATCH] LinuxContainer: Support copying directories Now that the static linux SDK we use has libarchive linked against it, we can finally live our dream (/s) of supporting copying directories in/out a little easier. This was.. kind of annoying. I think the very simple route of writing archive to guest/host -> streaming over grpc is simplest, but on the guest end we have a couple problems: 1. The VMs rootfs is read only today always, which is a good thing to me and I don't want to change if we don't have to. 2. Writing the archive to the containers rootfs temporarily could work, but it's a bit weird, and the user can make the containers rootfs readonly which would screw that plan. 3. We could write it to /run or /tmp, but they're tmpfs and dealing with the headache of the user possibly tarring an enormous dir is one I don't want to care about. So, that leaves us with the (truthfully better to me) approach of trying to write the tar data directly to the host and skipping grpc which kinda forces us to have a temp spot. Because of that, I made it such that we pass a port number from host<->guest, and transfer the actual data (either the single file or tarred dir contents) over the vsock port instead. The stream is kinda clunky, but it just serves as a means to exchange metadata and a "we're done" signifier. --- Package.swift | 1 + Sources/Containerization/LinuxContainer.swift | 217 +++++- .../SandboxContext/SandboxContext.grpc.swift | 220 ++---- .../SandboxContext/SandboxContext.pb.swift | 422 ++++------- .../SandboxContext/SandboxContext.proto | 75 +- .../VirtualMachineAgent.swift | 42 -- Sources/Containerization/Vminitd.swift | 120 +-- .../ArchiveWriter.swift | 132 ++-- Sources/Integration/ContainerTests.swift | 687 ++++++++++++++++++ Sources/Integration/Suite.swift | 10 + .../ArchiveTests.swift | 383 ++++++++++ vminitd/Package.swift | 1 + vminitd/Sources/vminitd/AgentCommand.swift | 4 +- vminitd/Sources/vminitd/Server+GRPC.swift | 301 +++++--- vminitd/Sources/vminitd/Server.swift | 4 +- 15 files changed, 1818 insertions(+), 801 deletions(-) diff --git a/Package.swift b/Package.swift index fe86d844..40997523 100644 --- a/Package.swift +++ b/Package.swift @@ -60,6 +60,7 @@ let package = Package( .product(name: "GRPC", package: "grpc-swift"), .product(name: "SystemPackage", package: "swift-system"), .product(name: "_NIOFileSystem", package: "swift-nio"), + "ContainerizationArchive", "ContainerizationOCI", "ContainerizationOS", "ContainerizationIO", diff --git a/Sources/Containerization/LinuxContainer.swift b/Sources/Containerization/LinuxContainer.swift index 3add6123..9033a206 100644 --- a/Sources/Containerization/LinuxContainer.swift +++ b/Sources/Containerization/LinuxContainer.swift @@ -15,6 +15,7 @@ //===----------------------------------------------------------------------===// #if os(macOS) +import ContainerizationArchive import ContainerizationError import ContainerizationExtras import ContainerizationOCI @@ -123,6 +124,9 @@ public final class LinuxContainer: Container, Sendable { // the host. private let guestVsockPorts: Atomic + // Queue for copy IO. + private let copyQueue = DispatchQueue(label: "com.apple.containerization.copy") + private enum State: Sendable { /// The container class has been created but no live resources are running. case initialized @@ -1043,52 +1047,219 @@ extension LinuxContainer { /// Default chunk size for file transfers (1MiB). public static let defaultCopyChunkSize = 1024 * 1024 - /// Copy a file from the host into the container. + /// Copy a file or directory from the host into the container. + /// + /// Data transfer happens over a dedicated vsock connection. For directories, + /// the source is archived as tar+gzip and streamed directly through vsock + /// without intermediate temp files. public func copyIn( from source: URL, to destination: URL, mode: UInt32 = 0o644, createParents: Bool = true, - chunkSize: Int = defaultCopyChunkSize, - progress: ProgressHandler? = nil + chunkSize: Int = defaultCopyChunkSize ) async throws { try await self.state.withLock { let state = try $0.startedState("copyIn") + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: source.path, isDirectory: &isDirectory) else { + throw ContainerizationError(.notFound, message: "copyIn: source not found '\(source.path)'") + } + let isArchive = isDirectory.boolValue + let guestPath = URL(filePath: self.root).appending(path: destination.path) - try await state.vm.withAgent { agent in - try await agent.copyIn( - from: source, - to: guestPath, - mode: mode, - createParents: createParents, - chunkSize: chunkSize, - progress: progress - ) + let port = self.hostVsockPorts.wrappingAdd(1, ordering: .relaxed).oldValue + let listener = try state.vm.listen(port) + + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await state.vm.withAgent { agent in + guard let vminitd = agent as? Vminitd else { + throw ContainerizationError(.unsupported, message: "copyIn requires Vminitd agent") + } + try await vminitd.copy( + direction: .copyIn, + guestPath: guestPath, + vsockPort: port, + mode: mode, + createParents: createParents, + isArchive: isArchive + ) + } + } + + group.addTask { + guard let conn = await listener.first(where: { _ in true }) else { + throw ContainerizationError(.internalError, message: "copyIn: vsock connection not established") + } + try listener.finish() + + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + self.copyQueue.async { + do { + defer { conn.closeFile() } + + if isArchive { + let writer = try ArchiveWriter(configuration: .init(format: .pax, filter: .gzip)) + try writer.open(fileDescriptor: conn.fileDescriptor) + try writer.archiveDirectory(source) + try writer.finishEncoding() + } else { + let srcFd = open(source.path, O_RDONLY) + guard srcFd != -1 else { + throw ContainerizationError( + .internalError, + message: "copyIn: failed to open '\(source.path)': \(String(cString: strerror(errno)))" + ) + } + defer { close(srcFd) } + + var buf = [UInt8](repeating: 0, count: chunkSize) + while true { + let n = read(srcFd, &buf, buf.count) + if n == 0 { break } + guard n > 0 else { + throw ContainerizationError( + .internalError, + message: "copyIn: read error: \(String(cString: strerror(errno)))" + ) + } + var written = 0 + while written < n { + let w = buf.withUnsafeBytes { ptr in + write(conn.fileDescriptor, ptr.baseAddress! + written, n - written) + } + guard w > 0 else { + throw ContainerizationError( + .internalError, + message: "copyIn: vsock write error: \(String(cString: strerror(errno)))" + ) + } + written += w + } + } + } + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } + } + } + + try await group.waitForAll() } } } - /// Copy a file from the container to the host. + /// Copy a file or directory from the container to the host. + /// + /// Data transfer happens over a dedicated vsock connection. For directories, + /// the guest archives the source as tar+gzip and streams it directly through + /// vsock. The host extracts the archive without intermediate temp files. public func copyOut( from source: URL, to destination: URL, createParents: Bool = true, - chunkSize: Int = defaultCopyChunkSize, - progress: ProgressHandler? = nil + chunkSize: Int = defaultCopyChunkSize ) async throws { try await self.state.withLock { let state = try $0.startedState("copyOut") + if createParents { + let parentDir = destination.deletingLastPathComponent() + try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true) + } + let guestPath = URL(filePath: self.root).appending(path: source.path) - try await state.vm.withAgent { agent in - try await agent.copyOut( - from: guestPath, - to: destination, - createParents: createParents, - chunkSize: chunkSize, - progress: progress - ) + let port = self.hostVsockPorts.wrappingAdd(1, ordering: .relaxed).oldValue + let listener = try state.vm.listen(port) + + let (metadataStream, metadataCont) = AsyncStream.makeStream(of: Vminitd.CopyMetadata.self) + + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await state.vm.withAgent { agent in + guard let vminitd = agent as? Vminitd else { + throw ContainerizationError(.unsupported, message: "copyOut requires Vminitd agent") + } + try await vminitd.copy( + direction: .copyOut, + guestPath: guestPath, + vsockPort: port, + onMetadata: { meta in + metadataCont.yield(meta) + metadataCont.finish() + } + ) + } + } + + group.addTask { + guard let metadata = await metadataStream.first(where: { _ in true }) else { + throw ContainerizationError(.internalError, message: "copyOut: no metadata received") + } + + guard let conn = await listener.first(where: { _ in true }) else { + throw ContainerizationError(.internalError, message: "copyOut: vsock connection not established") + } + try listener.finish() + + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + self.copyQueue.async { + do { + defer { conn.closeFile() } + + if metadata.isArchive { + try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) + let fh = FileHandle(fileDescriptor: dup(conn.fileDescriptor), closeOnDealloc: true) + let reader = try ArchiveReader(format: .pax, filter: .gzip, fileHandle: fh) + _ = try reader.extractContents(to: destination) + } else { + let destFd = open(destination.path, O_WRONLY | O_CREAT | O_TRUNC, 0o644) + guard destFd != -1 else { + throw ContainerizationError( + .internalError, + message: "copyOut: failed to open '\(destination.path)': \(String(cString: strerror(errno)))" + ) + } + defer { close(destFd) } + + var buf = [UInt8](repeating: 0, count: chunkSize) + while true { + let n = read(conn.fileDescriptor, &buf, buf.count) + if n == 0 { break } + guard n > 0 else { + throw ContainerizationError( + .internalError, + message: "copyOut: vsock read error: \(String(cString: strerror(errno)))" + ) + } + var written = 0 + while written < n { + let w = buf.withUnsafeBytes { ptr in + write(destFd, ptr.baseAddress! + written, n - written) + } + guard w > 0 else { + throw ContainerizationError( + .internalError, + message: "copyOut: write error: \(String(cString: strerror(errno)))" + ) + } + written += w + } + } + } + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } + } + } + + try await group.waitForAll() } } } diff --git a/Sources/Containerization/SandboxContext/SandboxContext.grpc.swift b/Sources/Containerization/SandboxContext/SandboxContext.grpc.swift index 39b2bd69..1600090f 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.grpc.swift +++ b/Sources/Containerization/SandboxContext/SandboxContext.grpc.swift @@ -79,15 +79,11 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextClientProtoc callOptions: CallOptions? ) -> UnaryCall - func copyIn( - callOptions: CallOptions? - ) -> ClientStreamingCall - - func copyOut( - _ request: Com_Apple_Containerization_Sandbox_V3_CopyOutRequest, + func copy( + _ request: Com_Apple_Containerization_Sandbox_V3_CopyRequest, callOptions: CallOptions?, - handler: @escaping (Com_Apple_Containerization_Sandbox_V3_CopyOutChunk) -> Void - ) -> ServerStreamingCall + handler: @escaping (Com_Apple_Containerization_Sandbox_V3_CopyResponse) -> Void + ) -> ServerStreamingCall func createProcess( _ request: Com_Apple_Containerization_Sandbox_V3_CreateProcessRequest, @@ -347,41 +343,25 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContextClientProtocol { ) } - /// Copy a file from the host into the guest. - /// - /// Callers should use the `send` method on the returned object to send messages - /// to the server. The caller should send an `.end` after the final message has been sent. - /// - /// - Parameters: - /// - callOptions: Call options. - /// - Returns: A `ClientStreamingCall` with futures for the metadata, status and response. - public func copyIn( - callOptions: CallOptions? = nil - ) -> ClientStreamingCall { - return self.makeClientStreamingCall( - path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyIn.path, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeCopyInInterceptors() ?? [] - ) - } - - /// Copy a file from the guest to the host. + /// Copy a file or directory between the host and guest. + /// Data transfer happens over a dedicated vsock connection; + /// the gRPC stream is used only for control/metadata. /// /// - Parameters: - /// - request: Request to send to CopyOut. + /// - request: Request to send to Copy. /// - callOptions: Call options. /// - handler: A closure called when each response is received from the server. /// - Returns: A `ServerStreamingCall` with futures for the metadata and status. - public func copyOut( - _ request: Com_Apple_Containerization_Sandbox_V3_CopyOutRequest, + public func copy( + _ request: Com_Apple_Containerization_Sandbox_V3_CopyRequest, callOptions: CallOptions? = nil, - handler: @escaping (Com_Apple_Containerization_Sandbox_V3_CopyOutChunk) -> Void - ) -> ServerStreamingCall { + handler: @escaping (Com_Apple_Containerization_Sandbox_V3_CopyResponse) -> Void + ) -> ServerStreamingCall { return self.makeServerStreamingCall( - path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyOut.path, + path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copy.path, request: request, callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeCopyOutInterceptors() ?? [], + interceptors: self.interceptors?.makeCopyInterceptors() ?? [], handler: handler ) } @@ -820,14 +800,10 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncClientP callOptions: CallOptions? ) -> GRPCAsyncUnaryCall - func makeCopyInCall( + func makeCopyCall( + _ request: Com_Apple_Containerization_Sandbox_V3_CopyRequest, callOptions: CallOptions? - ) -> GRPCAsyncClientStreamingCall - - func makeCopyOutCall( - _ request: Com_Apple_Containerization_Sandbox_V3_CopyOutRequest, - callOptions: CallOptions? - ) -> GRPCAsyncServerStreamingCall + ) -> GRPCAsyncServerStreamingCall func makeCreateProcessCall( _ request: Com_Apple_Containerization_Sandbox_V3_CreateProcessRequest, @@ -1038,25 +1014,15 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncClientProtoco ) } - public func makeCopyInCall( - callOptions: CallOptions? = nil - ) -> GRPCAsyncClientStreamingCall { - return self.makeAsyncClientStreamingCall( - path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyIn.path, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeCopyInInterceptors() ?? [] - ) - } - - public func makeCopyOutCall( - _ request: Com_Apple_Containerization_Sandbox_V3_CopyOutRequest, + public func makeCopyCall( + _ request: Com_Apple_Containerization_Sandbox_V3_CopyRequest, callOptions: CallOptions? = nil - ) -> GRPCAsyncServerStreamingCall { + ) -> GRPCAsyncServerStreamingCall { return self.makeAsyncServerStreamingCall( - path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyOut.path, + path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copy.path, request: request, callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeCopyOutInterceptors() ?? [] + interceptors: self.interceptors?.makeCopyInterceptors() ?? [] ) } @@ -1387,39 +1353,15 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncClientProtoco ) } - public func copyIn( - _ requests: RequestStream, - callOptions: CallOptions? = nil - ) async throws -> Com_Apple_Containerization_Sandbox_V3_CopyInResponse where RequestStream: Sequence, RequestStream.Element == Com_Apple_Containerization_Sandbox_V3_CopyInChunk { - return try await self.performAsyncClientStreamingCall( - path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyIn.path, - requests: requests, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeCopyInInterceptors() ?? [] - ) - } - - public func copyIn( - _ requests: RequestStream, + public func copy( + _ request: Com_Apple_Containerization_Sandbox_V3_CopyRequest, callOptions: CallOptions? = nil - ) async throws -> Com_Apple_Containerization_Sandbox_V3_CopyInResponse where RequestStream: AsyncSequence & Sendable, RequestStream.Element == Com_Apple_Containerization_Sandbox_V3_CopyInChunk { - return try await self.performAsyncClientStreamingCall( - path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyIn.path, - requests: requests, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeCopyInInterceptors() ?? [] - ) - } - - public func copyOut( - _ request: Com_Apple_Containerization_Sandbox_V3_CopyOutRequest, - callOptions: CallOptions? = nil - ) -> GRPCAsyncResponseStream { + ) -> GRPCAsyncResponseStream { return self.performAsyncServerStreamingCall( - path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyOut.path, + path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copy.path, request: request, callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeCopyOutInterceptors() ?? [] + interceptors: self.interceptors?.makeCopyInterceptors() ?? [] ) } @@ -1686,11 +1628,8 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextClientInterc /// - Returns: Interceptors to use when invoking 'writeFile'. func makeWriteFileInterceptors() -> [ClientInterceptor] - /// - Returns: Interceptors to use when invoking 'copyIn'. - func makeCopyInInterceptors() -> [ClientInterceptor] - - /// - Returns: Interceptors to use when invoking 'copyOut'. - func makeCopyOutInterceptors() -> [ClientInterceptor] + /// - Returns: Interceptors to use when invoking 'copy'. + func makeCopyInterceptors() -> [ClientInterceptor] /// - Returns: Interceptors to use when invoking 'createProcess'. func makeCreateProcessInterceptors() -> [ClientInterceptor] @@ -1761,8 +1700,7 @@ public enum Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata { Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.setTime, Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.setupEmulator, Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.writeFile, - Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyIn, - Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyOut, + Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copy, Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.createProcess, Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.deleteProcess, Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.startProcess, @@ -1839,15 +1777,9 @@ public enum Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata { type: GRPCCallType.unary ) - public static let copyIn = GRPCMethodDescriptor( - name: "CopyIn", - path: "/com.apple.containerization.sandbox.v3.SandboxContext/CopyIn", - type: GRPCCallType.clientStreaming - ) - - public static let copyOut = GRPCMethodDescriptor( - name: "CopyOut", - path: "/com.apple.containerization.sandbox.v3.SandboxContext/CopyOut", + public static let copy = GRPCMethodDescriptor( + name: "Copy", + path: "/com.apple.containerization.sandbox.v3.SandboxContext/Copy", type: GRPCCallType.serverStreaming ) @@ -1994,11 +1926,10 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextProvider: Ca /// Write data to an existing or new file. func writeFile(request: Com_Apple_Containerization_Sandbox_V3_WriteFileRequest, context: StatusOnlyCallContext) -> EventLoopFuture - /// Copy a file from the host into the guest. - func copyIn(context: UnaryResponseCallContext) -> EventLoopFuture<(StreamEvent) -> Void> - - /// Copy a file from the guest to the host. - func copyOut(request: Com_Apple_Containerization_Sandbox_V3_CopyOutRequest, context: StreamingResponseCallContext) -> EventLoopFuture + /// Copy a file or directory between the host and guest. + /// Data transfer happens over a dedicated vsock connection; + /// the gRPC stream is used only for control/metadata. + func copy(request: Com_Apple_Containerization_Sandbox_V3_CopyRequest, context: StreamingResponseCallContext) -> EventLoopFuture /// Create a new process inside the container. func createProcess(request: Com_Apple_Containerization_Sandbox_V3_CreateProcessRequest, context: StatusOnlyCallContext) -> EventLoopFuture @@ -2149,22 +2080,13 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContextProvider { userFunction: self.writeFile(request:context:) ) - case "CopyIn": - return ClientStreamingServerHandler( - context: context, - requestDeserializer: ProtobufDeserializer(), - responseSerializer: ProtobufSerializer(), - interceptors: self.interceptors?.makeCopyInInterceptors() ?? [], - observerFactory: self.copyIn(context:) - ) - - case "CopyOut": + case "Copy": return ServerStreamingServerHandler( context: context, - requestDeserializer: ProtobufDeserializer(), - responseSerializer: ProtobufSerializer(), - interceptors: self.interceptors?.makeCopyOutInterceptors() ?? [], - userFunction: self.copyOut(request:context:) + requestDeserializer: ProtobufDeserializer(), + responseSerializer: ProtobufSerializer(), + interceptors: self.interceptors?.makeCopyInterceptors() ?? [], + userFunction: self.copy(request:context:) ) case "CreateProcess": @@ -2397,16 +2319,12 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncProvide context: GRPCAsyncServerCallContext ) async throws -> Com_Apple_Containerization_Sandbox_V3_WriteFileResponse - /// Copy a file from the host into the guest. - func copyIn( - requestStream: GRPCAsyncRequestStream, - context: GRPCAsyncServerCallContext - ) async throws -> Com_Apple_Containerization_Sandbox_V3_CopyInResponse - - /// Copy a file from the guest to the host. - func copyOut( - request: Com_Apple_Containerization_Sandbox_V3_CopyOutRequest, - responseStream: GRPCAsyncResponseStreamWriter, + /// Copy a file or directory between the host and guest. + /// Data transfer happens over a dedicated vsock connection; + /// the gRPC stream is used only for control/metadata. + func copy( + request: Com_Apple_Containerization_Sandbox_V3_CopyRequest, + responseStream: GRPCAsyncResponseStreamWriter, context: GRPCAsyncServerCallContext ) async throws @@ -2620,22 +2538,13 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncProvider { wrapping: { try await self.writeFile(request: $0, context: $1) } ) - case "CopyIn": + case "Copy": return GRPCAsyncServerHandler( context: context, - requestDeserializer: ProtobufDeserializer(), - responseSerializer: ProtobufSerializer(), - interceptors: self.interceptors?.makeCopyInInterceptors() ?? [], - wrapping: { try await self.copyIn(requestStream: $0, context: $1) } - ) - - case "CopyOut": - return GRPCAsyncServerHandler( - context: context, - requestDeserializer: ProtobufDeserializer(), - responseSerializer: ProtobufSerializer(), - interceptors: self.interceptors?.makeCopyOutInterceptors() ?? [], - wrapping: { try await self.copyOut(request: $0, responseStream: $1, context: $2) } + requestDeserializer: ProtobufDeserializer(), + responseSerializer: ProtobufSerializer(), + interceptors: self.interceptors?.makeCopyInterceptors() ?? [], + wrapping: { try await self.copy(request: $0, responseStream: $1, context: $2) } ) case "CreateProcess": @@ -2844,13 +2753,9 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextServerInterc /// Defaults to calling `self.makeInterceptors()`. func makeWriteFileInterceptors() -> [ServerInterceptor] - /// - Returns: Interceptors to use when handling 'copyIn'. - /// Defaults to calling `self.makeInterceptors()`. - func makeCopyInInterceptors() -> [ServerInterceptor] - - /// - Returns: Interceptors to use when handling 'copyOut'. + /// - Returns: Interceptors to use when handling 'copy'. /// Defaults to calling `self.makeInterceptors()`. - func makeCopyOutInterceptors() -> [ServerInterceptor] + func makeCopyInterceptors() -> [ServerInterceptor] /// - Returns: Interceptors to use when handling 'createProcess'. /// Defaults to calling `self.makeInterceptors()`. @@ -2939,8 +2844,7 @@ public enum Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata { Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.setTime, Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.setupEmulator, Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.writeFile, - Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.copyIn, - Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.copyOut, + Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.copy, Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.createProcess, Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.deleteProcess, Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.startProcess, @@ -3017,15 +2921,9 @@ public enum Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata { type: GRPCCallType.unary ) - public static let copyIn = GRPCMethodDescriptor( - name: "CopyIn", - path: "/com.apple.containerization.sandbox.v3.SandboxContext/CopyIn", - type: GRPCCallType.clientStreaming - ) - - public static let copyOut = GRPCMethodDescriptor( - name: "CopyOut", - path: "/com.apple.containerization.sandbox.v3.SandboxContext/CopyOut", + public static let copy = GRPCMethodDescriptor( + name: "Copy", + path: "/com.apple.containerization.sandbox.v3.SandboxContext/Copy", type: GRPCCallType.serverStreaming ) diff --git a/Sources/Containerization/SandboxContext/SandboxContext.pb.swift b/Sources/Containerization/SandboxContext/SandboxContext.pb.swift index 600331a0..87d22643 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.pb.swift +++ b/Sources/Containerization/SandboxContext/SandboxContext.pb.swift @@ -811,137 +811,132 @@ public struct Com_Apple_Containerization_Sandbox_V3_WriteFileResponse: Sendable public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_CopyInChunk: @unchecked Sendable { +public struct Com_Apple_Containerization_Sandbox_V3_CopyRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. - public var content: Com_Apple_Containerization_Sandbox_V3_CopyInChunk.OneOf_Content? = nil + /// Direction of the copy operation. + public var direction: Com_Apple_Containerization_Sandbox_V3_CopyRequest.Direction = .copyIn - /// Initialization message (must be first). - public var init_p: Com_Apple_Containerization_Sandbox_V3_CopyInInit { - get { - if case .init_p(let v)? = content {return v} - return Com_Apple_Containerization_Sandbox_V3_CopyInInit() - } - set {content = .init_p(newValue)} - } + /// Path in the guest (destination for COPY_IN, source for COPY_OUT). + public var path: String = String() - /// File data chunk. - public var data: Data { - get { - if case .data(let v)? = content {return v} - return Data() - } - set {content = .data(newValue)} - } + /// File mode for single-file COPY_IN (defaults to 0644 if not set). + public var mode: UInt32 = 0 + + /// Create parent directories if they don't exist. + public var createParents: Bool = false + + /// Vsock port the host is listening on for data transfer. + public var vsockPort: UInt32 = 0 + + /// For COPY_IN: indicates the data arriving on vsock is a tar+gzip archive. + public var isArchive: Bool = false public var unknownFields = SwiftProtobuf.UnknownStorage() - public enum OneOf_Content: Equatable, @unchecked Sendable { - /// Initialization message (must be first). - case init_p(Com_Apple_Containerization_Sandbox_V3_CopyInInit) - /// File data chunk. - case data(Data) + public enum Direction: SwiftProtobuf.Enum, Swift.CaseIterable { + public typealias RawValue = Int - } + /// Copy from host into guest. + case copyIn // = 0 - public init() {} -} + /// Copy from guest to host. + case copyOut // = 1 + case UNRECOGNIZED(Int) -public struct Com_Apple_Containerization_Sandbox_V3_CopyInInit: Sendable { - // SwiftProtobuf.Message conformance is added in an extension below. See the - // `Message` and `Message+*Additions` files in the SwiftProtobuf library for - // methods supported on all messages. + public init() { + self = .copyIn + } - /// Destination path in the guest. - public var path: String = String() + public init?(rawValue: Int) { + switch rawValue { + case 0: self = .copyIn + case 1: self = .copyOut + default: self = .UNRECOGNIZED(rawValue) + } + } - /// File mode (defaults to 0644 if not set). - public var mode: UInt32 = 0 + public var rawValue: Int { + switch self { + case .copyIn: return 0 + case .copyOut: return 1 + case .UNRECOGNIZED(let i): return i + } + } - /// Create parent directories if they don't exist. - public var createParents: Bool = false + // The compiler won't synthesize support with the UNRECOGNIZED case. + public static let allCases: [Com_Apple_Containerization_Sandbox_V3_CopyRequest.Direction] = [ + .copyIn, + .copyOut, + ] - public var unknownFields = SwiftProtobuf.UnknownStorage() + } public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_CopyInResponse: Sendable { +public struct Com_Apple_Containerization_Sandbox_V3_CopyResponse: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. - public var unknownFields = SwiftProtobuf.UnknownStorage() + /// What this response represents. + public var status: Com_Apple_Containerization_Sandbox_V3_CopyResponse.Status = .metadata - public init() {} -} + /// For COPY_OUT METADATA: indicates the data on vsock will be a tar+gzip archive. + public var isArchive: Bool = false -public struct Com_Apple_Containerization_Sandbox_V3_CopyOutRequest: Sendable { - // SwiftProtobuf.Message conformance is added in an extension below. See the - // `Message` and `Message+*Additions` files in the SwiftProtobuf library for - // methods supported on all messages. + /// For COPY_OUT METADATA: total size in bytes (0 if unknown, e.g. for archives). + public var totalSize: UInt64 = 0 - /// Source path in the guest. - public var path: String = String() + /// Non-empty if an error occurred. + public var error: String = String() public var unknownFields = SwiftProtobuf.UnknownStorage() - public init() {} -} + public enum Status: SwiftProtobuf.Enum, Swift.CaseIterable { + public typealias RawValue = Int -public struct Com_Apple_Containerization_Sandbox_V3_CopyOutChunk: @unchecked Sendable { - // SwiftProtobuf.Message conformance is added in an extension below. See the - // `Message` and `Message+*Additions` files in the SwiftProtobuf library for - // methods supported on all messages. + /// Transfer metadata (first message for COPY_OUT: is_archive, total_size). + case metadata // = 0 - public var content: Com_Apple_Containerization_Sandbox_V3_CopyOutChunk.OneOf_Content? = nil + /// Data transfer completed successfully. + case complete // = 1 + case UNRECOGNIZED(Int) - /// Initialization message with metadata (first chunk). - public var init_p: Com_Apple_Containerization_Sandbox_V3_CopyOutInit { - get { - if case .init_p(let v)? = content {return v} - return Com_Apple_Containerization_Sandbox_V3_CopyOutInit() + public init() { + self = .metadata } - set {content = .init_p(newValue)} - } - /// File data chunk. - public var data: Data { - get { - if case .data(let v)? = content {return v} - return Data() + public init?(rawValue: Int) { + switch rawValue { + case 0: self = .metadata + case 1: self = .complete + default: self = .UNRECOGNIZED(rawValue) + } } - set {content = .data(newValue)} - } - public var unknownFields = SwiftProtobuf.UnknownStorage() + public var rawValue: Int { + switch self { + case .metadata: return 0 + case .complete: return 1 + case .UNRECOGNIZED(let i): return i + } + } - public enum OneOf_Content: Equatable, @unchecked Sendable { - /// Initialization message with metadata (first chunk). - case init_p(Com_Apple_Containerization_Sandbox_V3_CopyOutInit) - /// File data chunk. - case data(Data) + // The compiler won't synthesize support with the UNRECOGNIZED case. + public static let allCases: [Com_Apple_Containerization_Sandbox_V3_CopyResponse.Status] = [ + .metadata, + .complete, + ] } public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_CopyOutInit: Sendable { - // SwiftProtobuf.Message conformance is added in an extension below. See the - // `Message` and `Message+*Additions` files in the SwiftProtobuf library for - // methods supported on all messages. - - /// Total file size in bytes. - public var totalSize: UInt64 = 0 - - public var unknownFields = SwiftProtobuf.UnknownStorage() - - public init() {} -} - public struct Com_Apple_Containerization_Sandbox_V3_IpLinkSetRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for @@ -2808,11 +2803,15 @@ extension Com_Apple_Containerization_Sandbox_V3_WriteFileResponse: SwiftProtobuf } } -extension Com_Apple_Containerization_Sandbox_V3_CopyInChunk: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = _protobuf_package + ".CopyInChunk" +extension Com_Apple_Containerization_Sandbox_V3_CopyRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".CopyRequest" public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "init"), - 2: .same(proto: "data"), + 1: .same(proto: "direction"), + 2: .same(proto: "path"), + 3: .same(proto: "mode"), + 4: .standard(proto: "create_parents"), + 5: .standard(proto: "vsock_port"), + 6: .standard(proto: "is_archive"), ] public mutating func decodeMessage(decoder: inout D) throws { @@ -2821,158 +2820,65 @@ extension Com_Apple_Containerization_Sandbox_V3_CopyInChunk: SwiftProtobuf.Messa // allocates stack space for every case branch when no optimizations are // enabled. https://github.com/apple/swift-protobuf/issues/1034 switch fieldNumber { - case 1: try { - var v: Com_Apple_Containerization_Sandbox_V3_CopyInInit? - var hadOneofValue = false - if let current = self.content { - hadOneofValue = true - if case .init_p(let m) = current {v = m} - } - try decoder.decodeSingularMessageField(value: &v) - if let v = v { - if hadOneofValue {try decoder.handleConflictingOneOf()} - self.content = .init_p(v) - } - }() - case 2: try { - var v: Data? - try decoder.decodeSingularBytesField(value: &v) - if let v = v { - if self.content != nil {try decoder.handleConflictingOneOf()} - self.content = .data(v) - } - }() + case 1: try { try decoder.decodeSingularEnumField(value: &self.direction) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.path) }() + case 3: try { try decoder.decodeSingularUInt32Field(value: &self.mode) }() + case 4: try { try decoder.decodeSingularBoolField(value: &self.createParents) }() + case 5: try { try decoder.decodeSingularUInt32Field(value: &self.vsockPort) }() + case 6: try { try decoder.decodeSingularBoolField(value: &self.isArchive) }() default: break } } } public func traverse(visitor: inout V) throws { - // The use of inline closures is to circumvent an issue where the compiler - // allocates stack space for every if/case branch local when no optimizations - // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and - // https://github.com/apple/swift-protobuf/issues/1182 - switch self.content { - case .init_p?: try { - guard case .init_p(let v)? = self.content else { preconditionFailure() } - try visitor.visitSingularMessageField(value: v, fieldNumber: 1) - }() - case .data?: try { - guard case .data(let v)? = self.content else { preconditionFailure() } - try visitor.visitSingularBytesField(value: v, fieldNumber: 2) - }() - case nil: break - } - try unknownFields.traverse(visitor: &visitor) - } - - public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_CopyInChunk, rhs: Com_Apple_Containerization_Sandbox_V3_CopyInChunk) -> Bool { - if lhs.content != rhs.content {return false} - if lhs.unknownFields != rhs.unknownFields {return false} - return true - } -} - -extension Com_Apple_Containerization_Sandbox_V3_CopyInInit: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = _protobuf_package + ".CopyInInit" - public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "path"), - 2: .same(proto: "mode"), - 3: .standard(proto: "create_parents"), - ] - - public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - // The use of inline closures is to circumvent an issue where the compiler - // allocates stack space for every case branch when no optimizations are - // enabled. https://github.com/apple/swift-protobuf/issues/1034 - switch fieldNumber { - case 1: try { try decoder.decodeSingularStringField(value: &self.path) }() - case 2: try { try decoder.decodeSingularUInt32Field(value: &self.mode) }() - case 3: try { try decoder.decodeSingularBoolField(value: &self.createParents) }() - default: break - } + if self.direction != .copyIn { + try visitor.visitSingularEnumField(value: self.direction, fieldNumber: 1) } - } - - public func traverse(visitor: inout V) throws { if !self.path.isEmpty { - try visitor.visitSingularStringField(value: self.path, fieldNumber: 1) + try visitor.visitSingularStringField(value: self.path, fieldNumber: 2) } if self.mode != 0 { - try visitor.visitSingularUInt32Field(value: self.mode, fieldNumber: 2) + try visitor.visitSingularUInt32Field(value: self.mode, fieldNumber: 3) } if self.createParents != false { - try visitor.visitSingularBoolField(value: self.createParents, fieldNumber: 3) + try visitor.visitSingularBoolField(value: self.createParents, fieldNumber: 4) + } + if self.vsockPort != 0 { + try visitor.visitSingularUInt32Field(value: self.vsockPort, fieldNumber: 5) + } + if self.isArchive != false { + try visitor.visitSingularBoolField(value: self.isArchive, fieldNumber: 6) } try unknownFields.traverse(visitor: &visitor) } - public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_CopyInInit, rhs: Com_Apple_Containerization_Sandbox_V3_CopyInInit) -> Bool { + public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_CopyRequest, rhs: Com_Apple_Containerization_Sandbox_V3_CopyRequest) -> Bool { + if lhs.direction != rhs.direction {return false} if lhs.path != rhs.path {return false} if lhs.mode != rhs.mode {return false} if lhs.createParents != rhs.createParents {return false} + if lhs.vsockPort != rhs.vsockPort {return false} + if lhs.isArchive != rhs.isArchive {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } } -extension Com_Apple_Containerization_Sandbox_V3_CopyInResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = _protobuf_package + ".CopyInResponse" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap() - - public mutating func decodeMessage(decoder: inout D) throws { - // Load everything into unknown fields - while try decoder.nextFieldNumber() != nil {} - } - - public func traverse(visitor: inout V) throws { - try unknownFields.traverse(visitor: &visitor) - } - - public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_CopyInResponse, rhs: Com_Apple_Containerization_Sandbox_V3_CopyInResponse) -> Bool { - if lhs.unknownFields != rhs.unknownFields {return false} - return true - } -} - -extension Com_Apple_Containerization_Sandbox_V3_CopyOutRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = _protobuf_package + ".CopyOutRequest" +extension Com_Apple_Containerization_Sandbox_V3_CopyRequest.Direction: SwiftProtobuf._ProtoNameProviding { public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "path"), + 0: .same(proto: "COPY_IN"), + 1: .same(proto: "COPY_OUT"), ] - - public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - // The use of inline closures is to circumvent an issue where the compiler - // allocates stack space for every case branch when no optimizations are - // enabled. https://github.com/apple/swift-protobuf/issues/1034 - switch fieldNumber { - case 1: try { try decoder.decodeSingularStringField(value: &self.path) }() - default: break - } - } - } - - public func traverse(visitor: inout V) throws { - if !self.path.isEmpty { - try visitor.visitSingularStringField(value: self.path, fieldNumber: 1) - } - try unknownFields.traverse(visitor: &visitor) - } - - public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_CopyOutRequest, rhs: Com_Apple_Containerization_Sandbox_V3_CopyOutRequest) -> Bool { - if lhs.path != rhs.path {return false} - if lhs.unknownFields != rhs.unknownFields {return false} - return true - } } -extension Com_Apple_Containerization_Sandbox_V3_CopyOutChunk: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = _protobuf_package + ".CopyOutChunk" +extension Com_Apple_Containerization_Sandbox_V3_CopyResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".CopyResponse" public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "init"), - 2: .same(proto: "data"), + 1: .same(proto: "status"), + 2: .standard(proto: "is_archive"), + 3: .standard(proto: "total_size"), + 4: .same(proto: "error"), ] public mutating func decodeMessage(decoder: inout D) throws { @@ -2981,90 +2887,48 @@ extension Com_Apple_Containerization_Sandbox_V3_CopyOutChunk: SwiftProtobuf.Mess // allocates stack space for every case branch when no optimizations are // enabled. https://github.com/apple/swift-protobuf/issues/1034 switch fieldNumber { - case 1: try { - var v: Com_Apple_Containerization_Sandbox_V3_CopyOutInit? - var hadOneofValue = false - if let current = self.content { - hadOneofValue = true - if case .init_p(let m) = current {v = m} - } - try decoder.decodeSingularMessageField(value: &v) - if let v = v { - if hadOneofValue {try decoder.handleConflictingOneOf()} - self.content = .init_p(v) - } - }() - case 2: try { - var v: Data? - try decoder.decodeSingularBytesField(value: &v) - if let v = v { - if self.content != nil {try decoder.handleConflictingOneOf()} - self.content = .data(v) - } - }() + case 1: try { try decoder.decodeSingularEnumField(value: &self.status) }() + case 2: try { try decoder.decodeSingularBoolField(value: &self.isArchive) }() + case 3: try { try decoder.decodeSingularUInt64Field(value: &self.totalSize) }() + case 4: try { try decoder.decodeSingularStringField(value: &self.error) }() default: break } } } public func traverse(visitor: inout V) throws { - // The use of inline closures is to circumvent an issue where the compiler - // allocates stack space for every if/case branch local when no optimizations - // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and - // https://github.com/apple/swift-protobuf/issues/1182 - switch self.content { - case .init_p?: try { - guard case .init_p(let v)? = self.content else { preconditionFailure() } - try visitor.visitSingularMessageField(value: v, fieldNumber: 1) - }() - case .data?: try { - guard case .data(let v)? = self.content else { preconditionFailure() } - try visitor.visitSingularBytesField(value: v, fieldNumber: 2) - }() - case nil: break + if self.status != .metadata { + try visitor.visitSingularEnumField(value: self.status, fieldNumber: 1) } - try unknownFields.traverse(visitor: &visitor) - } - - public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_CopyOutChunk, rhs: Com_Apple_Containerization_Sandbox_V3_CopyOutChunk) -> Bool { - if lhs.content != rhs.content {return false} - if lhs.unknownFields != rhs.unknownFields {return false} - return true - } -} - -extension Com_Apple_Containerization_Sandbox_V3_CopyOutInit: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = _protobuf_package + ".CopyOutInit" - public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .standard(proto: "total_size"), - ] - - public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - // The use of inline closures is to circumvent an issue where the compiler - // allocates stack space for every case branch when no optimizations are - // enabled. https://github.com/apple/swift-protobuf/issues/1034 - switch fieldNumber { - case 1: try { try decoder.decodeSingularUInt64Field(value: &self.totalSize) }() - default: break - } + if self.isArchive != false { + try visitor.visitSingularBoolField(value: self.isArchive, fieldNumber: 2) } - } - - public func traverse(visitor: inout V) throws { if self.totalSize != 0 { - try visitor.visitSingularUInt64Field(value: self.totalSize, fieldNumber: 1) + try visitor.visitSingularUInt64Field(value: self.totalSize, fieldNumber: 3) + } + if !self.error.isEmpty { + try visitor.visitSingularStringField(value: self.error, fieldNumber: 4) } try unknownFields.traverse(visitor: &visitor) } - public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_CopyOutInit, rhs: Com_Apple_Containerization_Sandbox_V3_CopyOutInit) -> Bool { + public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_CopyResponse, rhs: Com_Apple_Containerization_Sandbox_V3_CopyResponse) -> Bool { + if lhs.status != rhs.status {return false} + if lhs.isArchive != rhs.isArchive {return false} if lhs.totalSize != rhs.totalSize {return false} + if lhs.error != rhs.error {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } } +extension Com_Apple_Containerization_Sandbox_V3_CopyResponse.Status: SwiftProtobuf._ProtoNameProviding { + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 0: .same(proto: "METADATA"), + 1: .same(proto: "COMPLETE"), + ] +} + extension Com_Apple_Containerization_Sandbox_V3_IpLinkSetRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".IpLinkSetRequest" public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ diff --git a/Sources/Containerization/SandboxContext/SandboxContext.proto b/Sources/Containerization/SandboxContext/SandboxContext.proto index 6e2965ef..5250ced7 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.proto +++ b/Sources/Containerization/SandboxContext/SandboxContext.proto @@ -24,10 +24,10 @@ service SandboxContext { rpc SetupEmulator(SetupEmulatorRequest) returns (SetupEmulatorResponse); // Write data to an existing or new file. rpc WriteFile(WriteFileRequest) returns (WriteFileResponse); - // Copy a file from the host into the guest. - rpc CopyIn(stream CopyInChunk) returns (CopyInResponse); - // Copy a file from the guest to the host. - rpc CopyOut(CopyOutRequest) returns (stream CopyOutChunk); + // Copy a file or directory between the host and guest. + // Data transfer happens over a dedicated vsock connection; + // the gRPC stream is used only for control/metadata. + rpc Copy(CopyRequest) returns (stream CopyResponse); // Create a new process inside the container. rpc CreateProcess(CreateProcessRequest) returns (CreateProcessResponse); @@ -229,43 +229,42 @@ message WriteFileRequest { message WriteFileResponse {} -message CopyInChunk { - oneof content { - // Initialization message (must be first). - CopyInInit init = 1; - // File data chunk. - bytes data = 2; +message CopyRequest { + enum Direction { + // Copy from host into guest. + COPY_IN = 0; + // Copy from guest to host. + COPY_OUT = 1; } -} - -message CopyInInit { - // Destination path in the guest. - string path = 1; - // File mode (defaults to 0644 if not set). - uint32 mode = 2; + // Direction of the copy operation. + Direction direction = 1; + // Path in the guest (destination for COPY_IN, source for COPY_OUT). + string path = 2; + // File mode for single-file COPY_IN (defaults to 0644 if not set). + uint32 mode = 3; // Create parent directories if they don't exist. - bool create_parents = 3; -} - -message CopyInResponse {} - -message CopyOutRequest { - // Source path in the guest. - string path = 1; -} - -message CopyOutChunk { - oneof content { - // Initialization message with metadata (first chunk). - CopyOutInit init = 1; - // File data chunk. - bytes data = 2; + bool create_parents = 4; + // Vsock port the host is listening on for data transfer. + uint32 vsock_port = 5; + // For COPY_IN: indicates the data arriving on vsock is a tar+gzip archive. + bool is_archive = 6; +} + +message CopyResponse { + enum Status { + // Transfer metadata (first message for COPY_OUT: is_archive, total_size). + METADATA = 0; + // Data transfer completed successfully. + COMPLETE = 1; } -} - -message CopyOutInit { - // Total file size in bytes. - uint64 total_size = 1; + // What this response represents. + Status status = 1; + // For COPY_OUT METADATA: indicates the data on vsock will be a tar+gzip archive. + bool is_archive = 2; + // For COPY_OUT METADATA: total size in bytes (0 if unknown, e.g. for archives). + uint64 total_size = 3; + // Non-empty if an error occurred. + string error = 4; } message IpLinkSetRequest { diff --git a/Sources/Containerization/VirtualMachineAgent.swift b/Sources/Containerization/VirtualMachineAgent.swift index b7ce1086..b1b0c406 100644 --- a/Sources/Containerization/VirtualMachineAgent.swift +++ b/Sources/Containerization/VirtualMachineAgent.swift @@ -46,27 +46,6 @@ public protocol VirtualMachineAgent: Sendable { func sync() async throws func writeFile(path: String, data: Data, flags: WriteFileFlags, mode: UInt32) async throws - // File transfer - - /// Copy a file from the host into the guest. - func copyIn( - from source: URL, - to destination: URL, - mode: UInt32, - createParents: Bool, - chunkSize: Int, - progress: ProgressHandler? - ) async throws - - /// Copy a file from the guest to the host. - func copyOut( - from source: URL, - to destination: URL, - createParents: Bool, - chunkSize: Int, - progress: ProgressHandler? - ) async throws - // Process lifecycle func createProcess( id: String, @@ -118,25 +97,4 @@ extension VirtualMachineAgent { public func sync() async throws { throw ContainerizationError(.unsupported, message: "sync") } - - public func copyIn( - from source: URL, - to destination: URL, - mode: UInt32, - createParents: Bool, - chunkSize: Int, - progress: ProgressHandler? - ) async throws { - throw ContainerizationError(.unsupported, message: "copyIn") - } - - public func copyOut( - from source: URL, - to destination: URL, - createParents: Bool, - chunkSize: Int, - progress: ProgressHandler? - ) async throws { - throw ContainerizationError(.unsupported, message: "copyOut") - } } diff --git a/Sources/Containerization/Vminitd.swift b/Sources/Containerization/Vminitd.swift index 0ac47b7a..2a177d66 100644 --- a/Sources/Containerization/Vminitd.swift +++ b/Sources/Containerization/Vminitd.swift @@ -439,92 +439,52 @@ extension Vminitd { return response.result } - /// Copy a file from the host into the guest. - public func copyIn( - from source: URL, - to destination: URL, - mode: UInt32, - createParents: Bool, - chunkSize: Int, - progress: ProgressHandler? + /// Metadata received from the guest during a copy operation. + public struct CopyMetadata: Sendable { + /// Whether the data on the vsock channel is a tar+gzip archive. + public let isArchive: Bool + /// Total size in bytes (0 if unknown, e.g. for archives). + public let totalSize: UInt64 + } + + /// Unified copy control plane. Sends a CopyRequest over gRPC and processes + /// the response stream. Data transfer happens over a separate vsock connection + /// managed by the caller. + /// + /// For COPY_OUT, the `onMetadata` callback is invoked when the guest sends + /// metadata (is_archive, total_size) before data transfer begins. + /// For COPY_IN, `onMetadata` is not called. + public func copy( + direction: Com_Apple_Containerization_Sandbox_V3_CopyRequest.Direction, + guestPath: URL, + vsockPort: UInt32, + mode: UInt32 = 0, + createParents: Bool = false, + isArchive: Bool = false, + onMetadata: @Sendable (CopyMetadata) -> Void = { _ in } ) async throws { - let fileHandle = try FileHandle(forReadingFrom: source) - defer { try? fileHandle.close() } - - let attrs = try FileManager.default.attributesOfItem(atPath: source.path) - guard let fileSize = attrs[.size] as? Int64 else { - throw ContainerizationError( - .invalidArgument, - message: "copyIn: failed to get file size for '\(source.path)'" - ) + let request = Com_Apple_Containerization_Sandbox_V3_CopyRequest.with { + $0.direction = direction + $0.path = guestPath.path + $0.mode = mode + $0.createParents = createParents + $0.vsockPort = vsockPort + $0.isArchive = isArchive } - await progress?([ProgressEvent(event: "add-total-size", value: fileSize)]) - - let call = client.makeCopyInCall() + let stream = client.copy(request) - try await call.requestStream.send( - .with { - $0.content = .init_p( - .with { - $0.path = destination.path - $0.mode = mode - $0.createParents = createParents - }) + for try await response in stream { + if !response.error.isEmpty { + throw ContainerizationError(.internalError, message: "copy: \(response.error)") } - ) - - var totalSent: Int64 = 0 - while true { - guard let data = try fileHandle.read(upToCount: chunkSize), !data.isEmpty else { - break - } - try await call.requestStream.send(.with { $0.content = .data(data) }) - totalSent += Int64(data.count) - await progress?([ProgressEvent(event: "add-size", value: Int64(data.count))]) - } - - call.requestStream.finish() - _ = try await call.response - } - - /// Copy a file from the guest to the host. - public func copyOut( - from source: URL, - to destination: URL, - createParents: Bool, - chunkSize: Int, - progress: ProgressHandler? - ) async throws { - let request = Com_Apple_Containerization_Sandbox_V3_CopyOutRequest.with { - $0.path = source.path - } - - if createParents { - let parentDir = destination.deletingLastPathComponent() - try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true) - } - - let fd = open(destination.path, O_WRONLY | O_CREAT | O_TRUNC, 0o644) - guard fd != -1 else { - throw ContainerizationError( - .internalError, - message: "copyOut: failed to open '\(destination.path)': \(String(cString: strerror(errno)))" - ) - } - let fileHandle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) - defer { try? fileHandle.close() } - - let stream = client.copyOut(request) - for try await chunk in stream { - switch chunk.content { - case .init_p(let initMsg): - await progress?([ProgressEvent(event: "add-total-size", value: Int64(initMsg.totalSize))]) - case .data(let data): - try fileHandle.write(contentsOf: data) - await progress?([ProgressEvent(event: "add-size", value: Int64(data.count))]) - case .none: + switch response.status { + case .metadata: + onMetadata(CopyMetadata(isArchive: response.isArchive, totalSize: response.totalSize)) + case .complete: break + case .UNRECOGNIZED(let value): + throw ContainerizationError(.internalError, message: "copy: unrecognized response status \(value)") } } } diff --git a/Sources/ContainerizationArchive/ArchiveWriter.swift b/Sources/ContainerizationArchive/ArchiveWriter.swift index a79a105b..08e65fb6 100644 --- a/Sources/ContainerizationArchive/ArchiveWriter.swift +++ b/Sources/ContainerizationArchive/ArchiveWriter.swift @@ -16,6 +16,7 @@ import CArchive import Foundation +import SystemPackage /// A class responsible for writing archives in various formats. public final class ArchiveWriter { @@ -271,76 +272,86 @@ extension ArchiveWriter { /// Note: Symlinks are added to the archive if both the source and target for the symlink are both contained in the top level directory. public func archiveDirectory(_ dir: URL) throws { let fm = FileManager.default - var resourceKeys = Set([ - .fileSizeKey, .fileResourceTypeKey, - .creationDateKey, .contentAccessDateKey, .contentModificationDateKey, - ]) - #if os(macOS) - resourceKeys.insert(.fileSecurityKey) - #endif + let dirPath = FilePath(dir.path) - guard let directoryEnumerator = fm.enumerator(at: dir, includingPropertiesForKeys: Array(resourceKeys), options: .producesRelativePathURLs) else { + guard let enumerator = fm.enumerator(atPath: dirPath.string) else { throw POSIXError(.ENOTDIR) } - for case let fileURL as URL in directoryEnumerator { - var mode = mode_t() - var uid = uid_t() - var gid = gid_t() - let resourceValues = try fileURL.resourceValues(forKeys: resourceKeys) - guard let type = resourceValues.fileResourceType else { - throw ArchiveError.failedToGetProperty(fileURL.path(), .fileResourceTypeKey) - } - let allowedTypes: [URLFileResourceType] = [.directory, .regular, .symbolicLink] - guard allowedTypes.contains(type) else { - continue - } - var size: Int64 = 0 - let entry = WriteEntry() - if type == .regular { - guard let _size = resourceValues.fileSize else { - throw ArchiveError.failedToGetProperty(fileURL.path(), .fileSizeKey) - } - size = Int64(_size) - } else if type == .symbolicLink { - let target = fileURL.resolvingSymlinksInPath().absoluteString - let root = dir.absoluteString - guard target.hasPrefix(root) else { - continue - } - let linkTarget = target.dropFirst(root.count + 1) - entry.symlinkTarget = String(linkTarget) - } - guard let created = resourceValues.creationDate else { - throw ArchiveError.failedToGetProperty(fileURL.path(), .creationDateKey) - } - guard let access = resourceValues.contentAccessDate else { - throw ArchiveError.failedToGetProperty(fileURL.path(), .contentAccessDateKey) + // Emit a leading "./" entry for the root directory, matching GNU/BSD tar behavior. + var rootStat = stat() + guard lstat(dirPath.string, &rootStat) == 0 else { + let err = POSIXErrorCode(rawValue: errno) ?? .EINVAL + throw ArchiveError.failedToExtractArchive("lstat failed for '\(dirPath)': \(POSIXError(err))") + } + let rootEntry = WriteEntry() + rootEntry.path = "./" + rootEntry.size = 0 + rootEntry.fileType = .directory + rootEntry.owner = rootStat.st_uid + rootEntry.group = rootStat.st_gid + rootEntry.permissions = rootStat.st_mode + #if os(macOS) + rootEntry.creationDate = Date(timeIntervalSince1970: Double(rootStat.st_ctimespec.tv_sec)) + rootEntry.contentAccessDate = Date(timeIntervalSince1970: Double(rootStat.st_atimespec.tv_sec)) + rootEntry.modificationDate = Date(timeIntervalSince1970: Double(rootStat.st_mtimespec.tv_sec)) + #else + rootEntry.creationDate = Date(timeIntervalSince1970: Double(rootStat.st_ctim.tv_sec)) + rootEntry.contentAccessDate = Date(timeIntervalSince1970: Double(rootStat.st_atim.tv_sec)) + rootEntry.modificationDate = Date(timeIntervalSince1970: Double(rootStat.st_mtim.tv_sec)) + #endif + try self.writeHeader(entry: rootEntry) + + for case let relativePath as String in enumerator { + let fullPath = dirPath.appending(relativePath) + + var statInfo = stat() + guard lstat(fullPath.string, &statInfo) == 0 else { + let errNo = errno + let err = POSIXErrorCode(rawValue: errNo) ?? .EINVAL + throw ArchiveError.failedToExtractArchive("lstat failed for '\(fullPath)': \(POSIXError(err))") } - guard let modified = resourceValues.contentModificationDate else { - throw ArchiveError.failedToGetProperty(fileURL.path(), .contentModificationDateKey) + + let mode = statInfo.st_mode + let uid = statInfo.st_uid + let gid = statInfo.st_gid + var size: Int64 = 0 + let type: URLFileResourceType + + if (mode & S_IFMT) == S_IFREG { + type = .regular + size = Int64(statInfo.st_size) + } else if (mode & S_IFMT) == S_IFDIR { + type = .directory + } else if (mode & S_IFMT) == S_IFLNK { + type = .symbolicLink + } else { + continue } #if os(macOS) - guard let perms = resourceValues.fileSecurity else { - throw ArchiveError.failedToGetProperty(fileURL.path(), .fileSecurityKey) - } - CFFileSecurityGetMode(perms, &mode) - CFFileSecurityGetOwner(perms, &uid) - CFFileSecurityGetGroup(perms, &gid) + let created = Date(timeIntervalSince1970: Double(statInfo.st_ctimespec.tv_sec)) + let access = Date(timeIntervalSince1970: Double(statInfo.st_atimespec.tv_sec)) + let modified = Date(timeIntervalSince1970: Double(statInfo.st_mtimespec.tv_sec)) #else - let path = fileURL.path() - var statInfo = stat() - guard lstat(path, &statInfo) == 0 else { - let err = POSIXErrorCode(rawValue: errno)! - throw POSIXError(err) - } - mode = statInfo.st_mode - uid = statInfo.st_uid - gid = statInfo.st_gid + let created = Date(timeIntervalSince1970: Double(statInfo.st_ctim.tv_sec)) + let access = Date(timeIntervalSince1970: Double(statInfo.st_atim.tv_sec)) + let modified = Date(timeIntervalSince1970: Double(statInfo.st_mtim.tv_sec)) #endif - entry.path = fileURL.relativePath + let entry = WriteEntry() + if type == .symbolicLink { + let targetPath = try fm.destinationOfSymbolicLink(atPath: fullPath.string) + // Resolve the target relative to the symlink's parent, not the archive root. + let symlinkParent = fullPath.removingLastComponent() + let resolvedFull = symlinkParent.appending(targetPath).lexicallyNormalized() + guard resolvedFull.starts(with: dirPath) else { + continue + } + entry.symlinkTarget = targetPath + } + + entry.path = relativePath entry.size = size entry.creationDate = created entry.modificationDate = modified @@ -350,8 +361,7 @@ extension ArchiveWriter { entry.owner = uid entry.permissions = mode if type == .regular { - let p = dir.appending(path: fileURL.relativePath) - let data = try Data(contentsOf: p, options: .uncached) + let data = try Data(contentsOf: URL(fileURLWithPath: fullPath.string), options: .uncached) try self.writeEntry(entry: entry, data: data) } else { try self.writeHeader(entry: entry) diff --git a/Sources/Integration/ContainerTests.swift b/Sources/Integration/ContainerTests.swift index f0e26665..cf6af0dd 100644 --- a/Sources/Integration/ContainerTests.swift +++ b/Sources/Integration/ContainerTests.swift @@ -1812,6 +1812,693 @@ extension IntegrationSuite { } } + func testCopyInDirectory() async throws { + let id = "test-copy-in-dir" + + let bs = try await bootstrap(id) + + // Create a temp directory with files, a subdirectory, and a symlink. + let hostDir = FileManager.default.uniqueTemporaryDirectory(create: true) + .appendingPathComponent("test-dir") + try FileManager.default.createDirectory(at: hostDir, withIntermediateDirectories: true) + try "file1 content".write(to: hostDir.appendingPathComponent("file1.txt"), atomically: true, encoding: .utf8) + + let subDir = hostDir.appendingPathComponent("subdir") + try FileManager.default.createDirectory(at: subDir, withIntermediateDirectories: true) + try "file2 content".write(to: subDir.appendingPathComponent("file2.txt"), atomically: true, encoding: .utf8) + + try FileManager.default.createSymbolicLink( + at: hostDir.appendingPathComponent("link.txt"), + withDestinationURL: hostDir.appendingPathComponent("file1.txt") + ) + + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.arguments = ["sleep", "100"] + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + + // Copy the directory into the container. + try await container.copyIn( + from: hostDir, + to: URL(filePath: "/tmp/copied-dir") + ) + + // Verify file1.txt exists with correct content. + let buffer1 = BufferWriter() + let exec1 = try await container.exec("verify-file1") { config in + config.arguments = ["cat", "/tmp/copied-dir/file1.txt"] + config.stdout = buffer1 + } + try await exec1.start() + let status1 = try await exec1.wait() + try await exec1.delete() + + guard status1.exitCode == 0 else { + throw IntegrationError.assert(msg: "cat file1.txt failed with status \(status1)") + } + guard String(data: buffer1.data, encoding: .utf8) == "file1 content" else { + throw IntegrationError.assert(msg: "file1.txt content mismatch") + } + + // Verify subdir/file2.txt exists with correct content. + let buffer2 = BufferWriter() + let exec2 = try await container.exec("verify-file2") { config in + config.arguments = ["cat", "/tmp/copied-dir/subdir/file2.txt"] + config.stdout = buffer2 + } + try await exec2.start() + let status2 = try await exec2.wait() + try await exec2.delete() + + guard status2.exitCode == 0 else { + throw IntegrationError.assert(msg: "cat subdir/file2.txt failed with status \(status2)") + } + guard String(data: buffer2.data, encoding: .utf8) == "file2 content" else { + throw IntegrationError.assert(msg: "subdir/file2.txt content mismatch") + } + + try await container.kill(SIGKILL) + try await container.wait() + try await container.stop() + } catch { + try? await container.stop() + throw error + } + } + + func testCopyOutDirectory() async throws { + let id = "test-copy-out-dir" + + let bs = try await bootstrap(id) + + let hostDestination = FileManager.default.uniqueTemporaryDirectory(create: true) + .appendingPathComponent("copied-out-dir") + + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.arguments = ["sleep", "100"] + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + + // Create a directory structure inside the container. + let exec = try await container.exec("create-dir") { config in + config.arguments = [ + "sh", "-c", + "mkdir -p /tmp/guest-dir/subdir && echo -n 'guest file1' > /tmp/guest-dir/file1.txt && echo -n 'guest file2' > /tmp/guest-dir/subdir/file2.txt", + ] + } + try await exec.start() + let status = try await exec.wait() + try await exec.delete() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "failed to create directory in guest, status \(status)") + } + + // Copy the directory out of the container. + try await container.copyOut( + from: URL(filePath: "/tmp/guest-dir"), + to: hostDestination + ) + + // Verify file1.txt was copied correctly. + let file1Content = try String(contentsOf: hostDestination.appendingPathComponent("file1.txt"), encoding: .utf8) + guard file1Content == "guest file1" else { + throw IntegrationError.assert( + msg: "file1.txt content mismatch: expected 'guest file1', got '\(file1Content)'") + } + + // Verify subdir/file2.txt was copied correctly. + let file2Content = try String( + contentsOf: hostDestination.appendingPathComponent("subdir").appendingPathComponent("file2.txt"), + encoding: .utf8 + ) + guard file2Content == "guest file2" else { + throw IntegrationError.assert( + msg: "subdir/file2.txt content mismatch: expected 'guest file2', got '\(file2Content)'") + } + + try await container.kill(SIGKILL) + try await container.wait() + try await container.stop() + } catch { + try? await container.stop() + throw error + } + } + + func testCopyEmptyFile() async throws { + let id = "test-copy-empty-file" + + let bs = try await bootstrap(id) + + let hostFile = FileManager.default.uniqueTemporaryDirectory(create: true) + .appendingPathComponent("empty.txt") + try Data().write(to: hostFile) + + let hostDestination = FileManager.default.uniqueTemporaryDirectory(create: true) + .appendingPathComponent("empty-out.txt") + + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.arguments = ["sleep", "100"] + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + + // Copy empty file in. + try await container.copyIn( + from: hostFile, + to: URL(filePath: "/tmp/empty.txt") + ) + + // Verify it exists and is empty in the guest. + let buffer = BufferWriter() + let exec = try await container.exec("verify-empty") { config in + config.arguments = ["stat", "-c", "%s", "/tmp/empty.txt"] + config.stdout = buffer + } + try await exec.start() + let status = try await exec.wait() + try await exec.delete() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "stat failed with status \(status)") + } + let sizeStr = String(data: buffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + guard sizeStr == "0" else { + throw IntegrationError.assert(msg: "empty file should have size 0, got '\(sizeStr ?? "nil")'") + } + + // Copy it back out. + try await container.copyOut( + from: URL(filePath: "/tmp/empty.txt"), + to: hostDestination + ) + + let copiedData = try Data(contentsOf: hostDestination) + guard copiedData.isEmpty else { + throw IntegrationError.assert(msg: "round-tripped empty file should be empty, got \(copiedData.count) bytes") + } + + try await container.kill(SIGKILL) + try await container.wait() + try await container.stop() + } catch { + try? await container.stop() + throw error + } + } + + func testCopyEmptyDirectory() async throws { + let id = "test-copy-empty-dir" + + let bs = try await bootstrap(id) + + let hostDir = FileManager.default.uniqueTemporaryDirectory(create: true) + .appendingPathComponent("empty-dir") + try FileManager.default.createDirectory(at: hostDir, withIntermediateDirectories: true) + + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.arguments = ["sleep", "100"] + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + + // Copy empty directory in. + try await container.copyIn( + from: hostDir, + to: URL(filePath: "/tmp/empty-dir") + ) + + // Verify it exists and is a directory. + let buffer = BufferWriter() + let exec = try await container.exec("verify-empty-dir") { config in + config.arguments = ["sh", "-c", "test -d /tmp/empty-dir && ls -a /tmp/empty-dir | wc -l"] + config.stdout = buffer + } + try await exec.start() + let status = try await exec.wait() + try await exec.delete() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "empty dir check failed with status \(status)") + } + + // ls -a shows . and .. so count should be 2 for an empty dir. + let countStr = String(data: buffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + guard countStr == "2" else { + throw IntegrationError.assert(msg: "empty dir should have 2 entries (. and ..), got '\(countStr ?? "nil")'") + } + + try await container.kill(SIGKILL) + try await container.wait() + try await container.stop() + } catch { + try? await container.stop() + throw error + } + } + + func testCopyBinaryFile() async throws { + let id = "test-copy-binary" + + let bs = try await bootstrap(id) + + // Create a file with all 256 byte values to test binary safety. + let hostFile = FileManager.default.uniqueTemporaryDirectory(create: true) + .appendingPathComponent("binary.bin") + var binaryData = Data(count: 256 * 64) + for i in 0.. ../a/file.txt (relative, stays inside) + try FileManager.default.createSymbolicLink( + atPath: subB.appendingPathComponent("link.txt").path, + withDestinationPath: "../a/file.txt" + ) + + let archiveURL = testDir.appendingPathComponent("test.tar.gz") + let writer = try ArchiveWriter(format: .pax, filter: .gzip, file: archiveURL) + try writer.archiveDirectory(sourceDir) + try writer.finishEncoding() + + let extractDir = testDir.appendingPathComponent("extract") + let reader = try ArchiveReader(file: archiveURL) + let rejected = try reader.extractContents(to: extractDir) + + #expect(rejected.isEmpty) + + let linkDest = try FileManager.default.destinationOfSymbolicLink( + atPath: extractDir.appendingPathComponent("b/link.txt").path) + #expect(linkDest == "../a/file.txt") + + // Verify the symlink resolves correctly + let content = try String(contentsOf: extractDir.appendingPathComponent("b/link.txt"), encoding: .utf8) + #expect(content == "in a") + } } private let surveyBundleBase64Encoded = """ diff --git a/vminitd/Package.swift b/vminitd/Package.swift index 8722193e..11039778 100644 --- a/vminitd/Package.swift +++ b/vminitd/Package.swift @@ -51,6 +51,7 @@ let package = Package( .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "Logging", package: "swift-log"), .product(name: "Containerization", package: "containerization"), + .product(name: "ContainerizationArchive", package: "containerization"), .product(name: "ContainerizationNetlink", package: "containerization"), .product(name: "ContainerizationIO", package: "containerization"), .product(name: "ContainerizationOS", package: "containerization"), diff --git a/vminitd/Sources/vminitd/AgentCommand.swift b/vminitd/Sources/vminitd/AgentCommand.swift index d30c77e5..008deafa 100644 --- a/vminitd/Sources/vminitd/AgentCommand.swift +++ b/vminitd/Sources/vminitd/AgentCommand.swift @@ -139,7 +139,9 @@ struct AgentCommand: AsyncParsableCommand { t.start() let eg = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) - let server = Initd(log: log, group: eg) + let blockingPool = NIOThreadPool(numberOfThreads: System.coreCount) + blockingPool.start() + let server = Initd(log: log, group: eg, blockingPool: blockingPool) do { log.info("serving vminitd API") diff --git a/vminitd/Sources/vminitd/Server+GRPC.swift b/vminitd/Sources/vminitd/Server+GRPC.swift index f7b8112e..405ae42b 100644 --- a/vminitd/Sources/vminitd/Server+GRPC.swift +++ b/vminitd/Sources/vminitd/Server+GRPC.swift @@ -16,6 +16,7 @@ import Cgroup import Containerization +import ContainerizationArchive import ContainerizationError import ContainerizationExtras import ContainerizationNetlink @@ -357,149 +358,219 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncProvid // Chunk size for streaming file transfers (1MB). private static let copyChunkSize = 1024 * 1024 - func copyIn( - requestStream: GRPCAsyncRequestStream, + func copy( + request: Com_Apple_Containerization_Sandbox_V3_CopyRequest, + responseStream: GRPCAsyncResponseStreamWriter, context: GRPC.GRPCAsyncServerCallContext - ) async throws -> Com_Apple_Containerization_Sandbox_V3_CopyInResponse { - var fileHandle: FileHandle? - var path: String = "" - var totalBytes: Int = 0 + ) async throws { + let path = request.path + let vsockPort = request.vsockPort - do { - for try await chunk in requestStream { - switch chunk.content { - case .init_p(let initMsg): - path = initMsg.path - log.debug( - "copyIn", - metadata: [ - "path": "\(path)", - "mode": "\(initMsg.mode)", - "createParents": "\(initMsg.createParents)", - ]) - - if initMsg.createParents { - let fileURL = URL(fileURLWithPath: path) - let parentDir = fileURL.deletingLastPathComponent() - try FileManager.default.createDirectory( - at: parentDir, - withIntermediateDirectories: true - ) - } + log.debug( + "copy", + metadata: [ + "direction": "\(request.direction)", + "path": "\(path)", + "vsockPort": "\(vsockPort)", + "isArchive": "\(request.isArchive)", + "mode": "\(request.mode)", + "createParents": "\(request.createParents)", + ]) - let mode = initMsg.mode > 0 ? mode_t(initMsg.mode) : mode_t(0o644) - let fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, mode) - guard fd != -1 else { - throw GRPCStatus( - code: .internalError, - message: "copyIn: failed to open file '\(path)': \(swiftErrno("open"))" - ) - } - fileHandle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) - case .data(let bytes): - guard let fh = fileHandle else { - throw GRPCStatus( - code: .failedPrecondition, - message: "copyIn: received data before init message" - ) - } - if !bytes.isEmpty { - try fh.write(contentsOf: bytes) - totalBytes += bytes.count - } - case .none: - break - } + do { + switch request.direction { + case .copyIn: + try await handleCopyIn(request: request, responseStream: responseStream) + case .copyOut: + try await handleCopyOut(request: request, responseStream: responseStream) + case .UNRECOGNIZED(let value): + throw GRPCStatus(code: .invalidArgument, message: "copy: unrecognized direction \(value)") } - - log.debug( - "copyIn complete", - metadata: [ - "path": "\(path)", - "totalBytes": "\(totalBytes)", - ]) - - return .init() } catch { log.error( - "copyIn", + "copy", metadata: [ + "direction": "\(request.direction)", "path": "\(path)", "error": "\(error)", ]) if error is GRPCStatus { throw error } - throw GRPCStatus( - code: .internalError, - message: "copyIn: \(error)" - ) + throw GRPCStatus(code: .internalError, message: "copy: \(error)") } } - func copyOut( - request: Com_Apple_Containerization_Sandbox_V3_CopyOutRequest, - responseStream: GRPCAsyncResponseStreamWriter, - context: GRPC.GRPCAsyncServerCallContext + /// Handle a COPY_IN request: connect to host vsock port, read data, write to guest filesystem. + private func handleCopyIn( + request: Com_Apple_Containerization_Sandbox_V3_CopyRequest, + responseStream: GRPCAsyncResponseStreamWriter ) async throws { let path = request.path - log.debug( - "copyOut", - metadata: [ - "path": "\(path)" - ]) - - do { - let fileURL = URL(fileURLWithPath: path) - let attrs = try FileManager.default.attributesOfItem(atPath: path) - guard let fileSize = attrs[.size] as? UInt64 else { - throw GRPCStatus( - code: .internalError, - message: "copyOut: failed to get file size for '\(path)'" - ) - } + let isArchive = request.isArchive - let fileHandle = try FileHandle(forReadingFrom: fileURL) - defer { try? fileHandle.close() } + if request.createParents { + let parentDir = URL(fileURLWithPath: path).deletingLastPathComponent() + try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true) + } - // Send init message with total size. - try await responseStream.send( - .with { - $0.content = .init_p(.with { $0.totalSize = fileSize }) + // Connect to the host's vsock port for data transfer. + let vsockType = VsockType(port: request.vsockPort, cid: VsockType.hostCID) + let sock = try Socket(type: vsockType, closeOnDeinit: false) + try sock.connect() + let sockFd = sock.fileDescriptor + + // Dispatch blocking I/O onto the thread pool. + let rejected: [String] = try await blockingPool.runIfActive { [self] in + defer { try? sock.close() } + + guard isArchive else { + let mode = request.mode > 0 ? mode_t(request.mode) : mode_t(0o644) + let fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, mode) + guard fd != -1 else { + throw GRPCStatus( + code: .internalError, + message: "copy: failed to open file '\(path)': \(swiftErrno("open"))" + ) } - ) + defer { close(fd) } - var totalSent: UInt64 = 0 - while true { - guard let data = try fileHandle.read(upToCount: Self.copyChunkSize), !data.isEmpty else { - break + var buf = [UInt8](repeating: 0, count: Self.copyChunkSize) + while true { + let n = read(sockFd, &buf, buf.count) + if n == 0 { break } + guard n > 0 else { + throw GRPCStatus( + code: .internalError, + message: "copy: vsock read error: \(swiftErrno("read"))" + ) + } + var written = 0 + while written < n { + let w = buf.withUnsafeBytes { ptr in + write(fd, ptr.baseAddress! + written, n - written) + } + guard w > 0 else { + throw GRPCStatus( + code: .internalError, + message: "copy: write error: \(swiftErrno("write"))" + ) + } + written += w + } } + return [] + } + let destURL = URL(fileURLWithPath: path) + try FileManager.default.createDirectory(at: destURL, withIntermediateDirectories: true) + + let fileHandle = FileHandle(fileDescriptor: sockFd, closeOnDealloc: false) + let reader = try ArchiveReader(format: .pax, filter: .gzip, fileHandle: fileHandle) + return try reader.extractContents(to: destURL) + } - try await responseStream.send(.with { $0.content = .data(data) }) - totalSent += UInt64(data.count) + if !rejected.isEmpty { + log.info("copy: archive extracted", metadata: ["path": "\(path)", "rejectedCount": "\(rejected.count)"]) + for rejectedPath in rejected { + log.error("copy: rejected archive path", metadata: ["path": "\(rejectedPath)"]) } + } - log.debug( - "copyOut complete", - metadata: [ - "path": "\(path)", - "totalBytes": "\(totalSent)", - ]) - } catch { - log.error( - "copyOut", - metadata: [ - "path": "\(path)", - "error": "\(error)", - ]) - if error is GRPCStatus { - throw error + log.debug("copy: copyIn complete", metadata: ["path": "\(path)", "isArchive": "\(isArchive)"]) + + // Send completion response. + try await responseStream.send(.with { $0.status = .complete }) + } + + /// Handle a COPY_OUT request: stat path, send metadata, connect to host vsock port, write data. + private func handleCopyOut( + request: Com_Apple_Containerization_Sandbox_V3_CopyRequest, + responseStream: GRPCAsyncResponseStreamWriter + ) async throws { + let path = request.path + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) else { + throw GRPCStatus(code: .notFound, message: "copy: path not found '\(path)'") + } + let isArchive = isDirectory.boolValue + + // Determine total size for single files. + var totalSize: UInt64 = 0 + if !isArchive { + let attrs = try FileManager.default.attributesOfItem(atPath: path) + if let size = attrs[.size] as? UInt64 { + totalSize = size } - throw GRPCStatus( - code: .internalError, - message: "copyOut: \(error)" - ) } + + // Send metadata response BEFORE connecting to vsock, so host knows what to expect. + try await responseStream.send( + .with { + $0.status = .metadata + $0.isArchive = isArchive + $0.totalSize = totalSize + }) + + // Connect to the host's vsock port and dispatch blocking I/O onto the thread pool. + let vsockType = VsockType(port: request.vsockPort, cid: VsockType.hostCID) + let sock = try Socket(type: vsockType, closeOnDeinit: false) + try sock.connect() + + try await blockingPool.runIfActive { [self] in + defer { try? sock.close() } + + if isArchive { + let fileURL = URL(fileURLWithPath: path) + let writer = try ArchiveWriter(configuration: .init(format: .pax, filter: .gzip)) + try writer.open(fileDescriptor: sock.fileDescriptor) + try writer.archiveDirectory(fileURL) + try writer.finishEncoding() + } else { + let srcFd = open(path, O_RDONLY) + guard srcFd != -1 else { + throw GRPCStatus( + code: .internalError, + message: "copy: failed to open '\(path)': \(swiftErrno("open"))" + ) + } + defer { close(srcFd) } + + var buf = [UInt8](repeating: 0, count: Self.copyChunkSize) + while true { + let n = read(srcFd, &buf, buf.count) + if n == 0 { break } + guard n > 0 else { + throw GRPCStatus( + code: .internalError, + message: "copy: read error: \(swiftErrno("read"))" + ) + } + var written = 0 + while written < n { + let w = buf.withUnsafeBytes { ptr in + write(sock.fileDescriptor, ptr.baseAddress! + written, n - written) + } + guard w > 0 else { + throw GRPCStatus( + code: .internalError, + message: "copy: vsock write error: \(swiftErrno("write"))" + ) + } + written += w + } + } + } + } + + log.debug( + "copy: copyOut complete", + metadata: [ + "path": "\(path)", + "isArchive": "\(isArchive)", + ]) + + // Send completion response after vsock data transfer is done. + try await responseStream.send(.with { $0.status = .complete }) } func mount(request: Com_Apple_Containerization_Sandbox_V3_MountRequest, context: GRPC.GRPCAsyncServerCallContext) diff --git a/vminitd/Sources/vminitd/Server.swift b/vminitd/Sources/vminitd/Server.swift index 93f6abeb..91defe53 100644 --- a/vminitd/Sources/vminitd/Server.swift +++ b/vminitd/Sources/vminitd/Server.swift @@ -79,10 +79,12 @@ final class Initd: Sendable { let log: Logger let state: State let group: EventLoopGroup + let blockingPool: NIOThreadPool - init(log: Logger, group: EventLoopGroup) { + init(log: Logger, group: EventLoopGroup, blockingPool: NIOThreadPool) { self.log = log self.group = group + self.blockingPool = blockingPool self.state = State() }