diff --git a/Sources/Containerization/LinuxContainer.swift b/Sources/Containerization/LinuxContainer.swift index dc502e2d..b046eba8 100644 --- a/Sources/Containerization/LinuxContainer.swift +++ b/Sources/Containerization/LinuxContainer.swift @@ -830,6 +830,57 @@ extension LinuxContainer { try await relayManager.start(port: port, socket: socket) try await relayAgent.relaySocket(port: port, configuration: socket) } + + /// Default chunk size for file transfers (1MiB). + public static let defaultCopyChunkSize = 1024 * 1024 + + /// Copy a file from the host into the container. + public func copyIn( + from source: URL, + to destination: URL, + mode: UInt32 = 0o644, + createParents: Bool = true, + chunkSize: Int = defaultCopyChunkSize, + progress: ProgressHandler? = nil + ) async throws { + try await self.state.withLock { + let state = try $0.startedState("copyIn") + + 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 + ) + } + } + } + + /// Copy a file from the container to the host. + public func copyOut( + from source: URL, + to destination: URL, + chunkSize: Int = defaultCopyChunkSize, + progress: ProgressHandler? = nil + ) async throws { + try await self.state.withLock { + let state = try $0.startedState("copyOut") + + 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, + chunkSize: chunkSize, + progress: progress + ) + } + } + } } extension VirtualMachineInstance { diff --git a/Sources/Containerization/SandboxContext/SandboxContext.grpc.swift b/Sources/Containerization/SandboxContext/SandboxContext.grpc.swift index ed59bfe2..a0a4d3aa 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.grpc.swift +++ b/Sources/Containerization/SandboxContext/SandboxContext.grpc.swift @@ -79,6 +79,16 @@ 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, + callOptions: CallOptions?, + handler: @escaping (Com_Apple_Containerization_Sandbox_V3_CopyOutChunk) -> Void + ) -> ServerStreamingCall + func createProcess( _ request: Com_Apple_Containerization_Sandbox_V3_CreateProcessRequest, callOptions: CallOptions? @@ -337,6 +347,45 @@ 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. + /// + /// - Parameters: + /// - request: Request to send to CopyOut. + /// - 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, + callOptions: CallOptions? = nil, + handler: @escaping (Com_Apple_Containerization_Sandbox_V3_CopyOutChunk) -> Void + ) -> ServerStreamingCall { + return self.makeServerStreamingCall( + path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyOut.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeCopyOutInterceptors() ?? [], + handler: handler + ) + } + /// Create a new process inside the container. /// /// - Parameters: @@ -771,6 +820,15 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncClientP callOptions: CallOptions? ) -> GRPCAsyncUnaryCall + func makeCopyInCall( + callOptions: CallOptions? + ) -> GRPCAsyncClientStreamingCall + + func makeCopyOutCall( + _ request: Com_Apple_Containerization_Sandbox_V3_CopyOutRequest, + callOptions: CallOptions? + ) -> GRPCAsyncServerStreamingCall + func makeCreateProcessCall( _ request: Com_Apple_Containerization_Sandbox_V3_CreateProcessRequest, callOptions: CallOptions? @@ -980,6 +1038,28 @@ 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, + callOptions: CallOptions? = nil + ) -> GRPCAsyncServerStreamingCall { + return self.makeAsyncServerStreamingCall( + path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyOut.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeCopyOutInterceptors() ?? [] + ) + } + public func makeCreateProcessCall( _ request: Com_Apple_Containerization_Sandbox_V3_CreateProcessRequest, callOptions: CallOptions? = nil @@ -1307,6 +1387,42 @@ 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, + 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 { + return self.performAsyncServerStreamingCall( + path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyOut.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeCopyOutInterceptors() ?? [] + ) + } + public func createProcess( _ request: Com_Apple_Containerization_Sandbox_V3_CreateProcessRequest, callOptions: CallOptions? = nil @@ -1570,6 +1686,12 @@ 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 'createProcess'. func makeCreateProcessInterceptors() -> [ClientInterceptor] @@ -1639,6 +1761,8 @@ 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.createProcess, Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.deleteProcess, Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.startProcess, @@ -1715,6 +1839,18 @@ 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", + type: GRPCCallType.serverStreaming + ) + public static let createProcess = GRPCMethodDescriptor( name: "CreateProcess", path: "/com.apple.containerization.sandbox.v3.SandboxContext/CreateProcess", @@ -1858,6 +1994,12 @@ 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 + /// Create a new process inside the container. func createProcess(request: Com_Apple_Containerization_Sandbox_V3_CreateProcessRequest, context: StatusOnlyCallContext) -> EventLoopFuture @@ -2007,6 +2149,24 @@ 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": + return ServerStreamingServerHandler( + context: context, + requestDeserializer: ProtobufDeserializer(), + responseSerializer: ProtobufSerializer(), + interceptors: self.interceptors?.makeCopyOutInterceptors() ?? [], + userFunction: self.copyOut(request:context:) + ) + case "CreateProcess": return UnaryServerHandler( context: context, @@ -2237,6 +2397,19 @@ 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, + context: GRPCAsyncServerCallContext + ) async throws + /// Create a new process inside the container. func createProcess( request: Com_Apple_Containerization_Sandbox_V3_CreateProcessRequest, @@ -2447,6 +2620,24 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncProvider { wrapping: { try await self.writeFile(request: $0, context: $1) } ) + case "CopyIn": + 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) } + ) + case "CreateProcess": return GRPCAsyncServerHandler( context: context, @@ -2653,6 +2844,14 @@ 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'. + /// Defaults to calling `self.makeInterceptors()`. + func makeCopyOutInterceptors() -> [ServerInterceptor] + /// - Returns: Interceptors to use when handling 'createProcess'. /// Defaults to calling `self.makeInterceptors()`. func makeCreateProcessInterceptors() -> [ServerInterceptor] @@ -2740,6 +2939,8 @@ 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.createProcess, Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.deleteProcess, Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.startProcess, @@ -2816,6 +3017,18 @@ 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", + type: GRPCCallType.serverStreaming + ) + public static let createProcess = GRPCMethodDescriptor( name: "CreateProcess", path: "/com.apple.containerization.sandbox.v3.SandboxContext/CreateProcess", diff --git a/Sources/Containerization/SandboxContext/SandboxContext.pb.swift b/Sources/Containerization/SandboxContext/SandboxContext.pb.swift index 1af7b26d..bf8fb2ff 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.pb.swift +++ b/Sources/Containerization/SandboxContext/SandboxContext.pb.swift @@ -756,6 +756,137 @@ public struct Com_Apple_Containerization_Sandbox_V3_WriteFileResponse: Sendable public init() {} } +public struct Com_Apple_Containerization_Sandbox_V3_CopyInChunk: @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. + + public var content: Com_Apple_Containerization_Sandbox_V3_CopyInChunk.OneOf_Content? = nil + + /// 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)} + } + + /// File data chunk. + public var data: Data { + get { + if case .data(let v)? = content {return v} + return Data() + } + set {content = .data(newValue)} + } + + 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 init() {} +} + +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. + + /// Destination path in the guest. + public var path: String = String() + + /// File mode (defaults to 0644 if not set). + public var mode: UInt32 = 0 + + /// Create parent directories if they don't exist. + public var createParents: Bool = false + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Com_Apple_Containerization_Sandbox_V3_CopyInResponse: 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() + + public init() {} +} + +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. + + /// Source path in the guest. + public var path: String = String() + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +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. + + public var content: Com_Apple_Containerization_Sandbox_V3_CopyOutChunk.OneOf_Content? = nil + + /// 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() + } + set {content = .init_p(newValue)} + } + + /// File data chunk. + public var data: Data { + get { + if case .data(let v)? = content {return v} + return Data() + } + set {content = .data(newValue)} + } + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + 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) + + } + + 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 @@ -2565,6 +2696,263 @@ 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" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "init"), + 2: .same(proto: "data"), + ] + + 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 { + 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) + } + }() + 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 + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.path.isEmpty { + try visitor.visitSingularStringField(value: self.path, fieldNumber: 1) + } + if self.mode != 0 { + try visitor.visitSingularUInt32Field(value: self.mode, fieldNumber: 2) + } + if self.createParents != false { + try visitor.visitSingularBoolField(value: self.createParents, fieldNumber: 3) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_CopyInInit, rhs: Com_Apple_Containerization_Sandbox_V3_CopyInInit) -> Bool { + if lhs.path != rhs.path {return false} + if lhs.mode != rhs.mode {return false} + if lhs.createParents != rhs.createParents {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" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "path"), + ] + + 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" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "init"), + 2: .same(proto: "data"), + ] + + 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 { + 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) + } + }() + 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_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 + } + } + } + + public func traverse(visitor: inout V) throws { + if self.totalSize != 0 { + try visitor.visitSingularUInt64Field(value: self.totalSize, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_CopyOutInit, rhs: Com_Apple_Containerization_Sandbox_V3_CopyOutInit) -> Bool { + if lhs.totalSize != rhs.totalSize {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + 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 a4f7dd21..a36f7a06 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.proto +++ b/Sources/Containerization/SandboxContext/SandboxContext.proto @@ -24,6 +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); // Create a new process inside the container. rpc CreateProcess(CreateProcessRequest) returns (CreateProcessResponse); @@ -225,6 +229,45 @@ message WriteFileRequest { message WriteFileResponse {} +message CopyInChunk { + oneof content { + // Initialization message (must be first). + CopyInInit init = 1; + // File data chunk. + bytes data = 2; + } +} + +message CopyInInit { + // Destination path in the guest. + string path = 1; + // File mode (defaults to 0644 if not set). + uint32 mode = 2; + // 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; + } +} + +message CopyOutInit { + // Total file size in bytes. + uint64 total_size = 1; +} + message IpLinkSetRequest { string interface = 1; bool up = 2; diff --git a/Sources/Containerization/VirtualMachineAgent.swift b/Sources/Containerization/VirtualMachineAgent.swift index c3950de4..bed83239 100644 --- a/Sources/Containerization/VirtualMachineAgent.swift +++ b/Sources/Containerization/VirtualMachineAgent.swift @@ -46,6 +46,26 @@ 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, + chunkSize: Int, + progress: ProgressHandler? + ) async throws + // Process lifecycle func createProcess( id: String, @@ -96,4 +116,24 @@ 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, + chunkSize: Int, + progress: ProgressHandler? + ) async throws { + throw ContainerizationError(.unsupported, message: "copyOut") + } } diff --git a/Sources/Containerization/Vminitd.swift b/Sources/Containerization/Vminitd.swift index 1800978d..2524cbc5 100644 --- a/Sources/Containerization/Vminitd.swift +++ b/Sources/Containerization/Vminitd.swift @@ -409,6 +409,93 @@ 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? + ) 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)'" + ) + } + + await progress?([ProgressEvent(event: "add-total-size", value: fileSize)]) + + let call = client.makeCopyInCall() + + try await call.requestStream.send( + .with { + $0.content = .init_p( + .with { + $0.path = destination.path + $0.mode = mode + $0.createParents = createParents + }) + } + ) + + 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, + chunkSize: Int, + progress: ProgressHandler? + ) async throws { + let request = Com_Apple_Containerization_Sandbox_V3_CopyOutRequest.with { + $0.path = source.path + } + + 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() } + + // Note: chunkSize is controlled by the guest agent for copyOut. + // The parameter is kept for API symmetry but not used here. + 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: + break + } + } + } } extension Hosts { diff --git a/Sources/Integration/ContainerTests.swift b/Sources/Integration/ContainerTests.swift index e5b0cad0..f4f79508 100644 --- a/Sources/Integration/ContainerTests.swift +++ b/Sources/Integration/ContainerTests.swift @@ -1436,4 +1436,181 @@ extension IntegrationSuite { throw IntegrationError.assert(msg: "container with CAP_CHOWN should succeed, got exit code \(status.exitCode)") } } + + func testCopyIn() async throws { + let id = "test-copy-in" + + let bs = try await bootstrap(id) + + // Create a temp file on the host with known content + let testContent = "Hello from the host! This is a copyIn test." + let hostFile = FileManager.default.uniqueTemporaryDirectory(create: true) + .appendingPathComponent("test-input.txt") + try testContent.write(to: hostFile, atomically: true, encoding: .utf8) + + let buffer = BufferWriter() + 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 file into the container + try await container.copyIn( + from: hostFile, + to: URL(filePath: "/tmp/copied-file.txt") + ) + + // Verify the file exists and has correct content + let exec = try await container.exec("verify-copy") { config in + config.arguments = ["cat", "/tmp/copied-file.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: "cat command failed with status \(status)") + } + + guard let output = String(data: buffer.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert output to UTF8") + } + + guard output == testContent else { + throw IntegrationError.assert( + msg: "copied file content mismatch: expected '\(testContent)', got '\(output)'") + } + + try await container.kill(SIGKILL) + try await container.wait() + try await container.stop() + } catch { + try? await container.stop() + throw error + } + } + + func testCopyOut() async throws { + let id = "test-copy-out" + + let bs = try await bootstrap(id) + + let testContent = "Hello from the guest! This is a copyOut test." + let hostDestination = FileManager.default.uniqueTemporaryDirectory(create: true) + .appendingPathComponent("test-output.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() + + // Create a file inside the container + let exec = try await container.exec("create-file") { config in + config.arguments = ["sh", "-c", "echo -n '\(testContent)' > /tmp/guest-file.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 file in guest, status \(status)") + } + + // Copy the file out of the container + try await container.copyOut( + from: URL(filePath: "/tmp/guest-file.txt"), + to: hostDestination + ) + + // Verify the file was copied correctly + let copiedContent = try String(contentsOf: hostDestination, encoding: .utf8) + + guard copiedContent == testContent else { + throw IntegrationError.assert( + msg: "copied file content mismatch: expected '\(testContent)', got '\(copiedContent)'") + } + + try await container.kill(SIGKILL) + try await container.wait() + try await container.stop() + } catch { + try? await container.stop() + throw error + } + } + + func testCopyLargeFile() async throws { + let id = "test-copy-large-file" + + let bs = try await bootstrap(id) + + // Create a 10MB file on the host with a repeating pattern + let fileSize = 10 * 1024 * 1024 + let hostFile = FileManager.default.uniqueTemporaryDirectory(create: true) + .appendingPathComponent("large-file.bin") + + // Generate data with a repeating pattern + let pattern = Data("ContainerizationCopyTest".utf8) + var testData = Data(capacity: fileSize) + while testData.count < fileSize { + testData.append(pattern) + } + testData = testData.prefix(fileSize) + try testData.write(to: hostFile) + + let hostDestination = FileManager.default.uniqueTemporaryDirectory(create: true) + .appendingPathComponent("large-file-out.bin") + + 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 large file into the container + try await container.copyIn( + from: hostFile, + to: URL(filePath: "/tmp/large-file.bin") + ) + + // Copy it back out + try await container.copyOut( + from: URL(filePath: "/tmp/large-file.bin"), + to: hostDestination + ) + + // Verify the content matches + let copiedData = try Data(contentsOf: hostDestination) + + guard copiedData.count == testData.count else { + throw IntegrationError.assert( + msg: "file size mismatch: expected \(testData.count), got \(copiedData.count)") + } + + guard copiedData == testData else { + throw IntegrationError.assert(msg: "file content mismatch after round-trip copy") + } + + try await container.kill(SIGKILL) + try await container.wait() + try await container.stop() + } catch { + try? await container.stop() + throw error + } + } } diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index 3d2747b3..2fb902e0 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -305,6 +305,9 @@ struct IntegrationSuite: AsyncParsableCommand { Test("container capabilities OCI default", testCapabilitiesOCIDefault), Test("container capabilities all capabilities", testCapabilitiesAllCapabilities), Test("container capabilities file ownership", testCapabilitiesFileOwnership), + Test("container copy in", testCopyIn), + Test("container copy out", testCopyOut), + Test("container copy large file", testCopyLargeFile), // Pods Test("pod single container", testPodSingleContainer), diff --git a/vminitd/Sources/vminitd/Server+GRPC.swift b/vminitd/Sources/vminitd/Server+GRPC.swift index 338c3afd..8ac8265b 100644 --- a/vminitd/Sources/vminitd/Server+GRPC.swift +++ b/vminitd/Sources/vminitd/Server+GRPC.swift @@ -306,6 +306,154 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncProvid return .init() } + // Chunk size for streaming file transfers (1MB). + private static let copyChunkSize = 1024 * 1024 + + func copyIn( + requestStream: GRPCAsyncRequestStream, + context: GRPC.GRPCAsyncServerCallContext + ) async throws -> Com_Apple_Containerization_Sandbox_V3_CopyInResponse { + var fileHandle: FileHandle? + var path: String = "" + var totalBytes: Int = 0 + + 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 + ) + } + + 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 + } + } + + log.debug( + "copyIn complete", + metadata: [ + "path": "\(path)", + "totalBytes": "\(totalBytes)", + ]) + + return .init() + } catch { + log.error( + "copyIn", + metadata: [ + "path": "\(path)", + "error": "\(error)", + ]) + if error is GRPCStatus { + throw error + } + throw GRPCStatus( + code: .internalError, + message: "copyIn: \(error)" + ) + } + } + + func copyOut( + request: Com_Apple_Containerization_Sandbox_V3_CopyOutRequest, + responseStream: GRPCAsyncResponseStreamWriter, + context: GRPC.GRPCAsyncServerCallContext + ) 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 fileHandle = try FileHandle(forReadingFrom: fileURL) + defer { try? fileHandle.close() } + + // Send init message with total size. + try await responseStream.send( + .with { + $0.content = .init_p(.with { $0.totalSize = fileSize }) + } + ) + + var totalSent: UInt64 = 0 + while true { + guard let data = try fileHandle.read(upToCount: Self.copyChunkSize), !data.isEmpty else { + break + } + + try await responseStream.send(.with { $0.content = .data(data) }) + totalSent += UInt64(data.count) + } + + log.debug( + "copyOut complete", + metadata: [ + "path": "\(path)", + "totalBytes": "\(totalSent)", + ]) + } catch { + log.error( + "copyOut", + metadata: [ + "path": "\(path)", + "error": "\(error)", + ]) + if error is GRPCStatus { + throw error + } + throw GRPCStatus( + code: .internalError, + message: "copyOut: \(error)" + ) + } + } + func mount(request: Com_Apple_Containerization_Sandbox_V3_MountRequest, context: GRPC.GRPCAsyncServerCallContext) async throws -> Com_Apple_Containerization_Sandbox_V3_MountResponse {