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() }