From 6f1306199b5e2fd4f07836237c149004acf5cbf0 Mon Sep 17 00:00:00 2001 From: Simone Panico Date: Tue, 10 Feb 2026 21:15:43 +0100 Subject: [PATCH 01/11] Add support to Copy Files between Container and Host --- Sources/ContainerCommands/Application.swift | 1 + .../Container/ContainerCopy.swift | 81 ++++++++++++++++++ .../Helpers/APIServer/APIServer+Start.swift | 2 + .../RuntimeLinuxHelper+Start.swift | 2 + .../Client/ContainerClient.swift | 37 ++++++++ .../ContainerAPIService/Client/XPC+.swift | 7 ++ .../Server/Containers/ContainersHarness.swift | 51 +++++++++++ .../Server/Containers/ContainersService.swift | 18 ++++ .../Client/SandboxClient.swift | 33 +++++++ .../Client/SandboxKeys.swift | 5 ++ .../Client/SandboxRoutes.swift | 4 + .../Server/SandboxService.swift | 85 +++++++++++++++++++ 12 files changed, 326 insertions(+) create mode 100644 Sources/ContainerCommands/Container/ContainerCopy.swift diff --git a/Sources/ContainerCommands/Application.swift b/Sources/ContainerCommands/Application.swift index 266ab0608..08ebe3a5a 100644 --- a/Sources/ContainerCommands/Application.swift +++ b/Sources/ContainerCommands/Application.swift @@ -51,6 +51,7 @@ public struct Application: AsyncLoggableCommand { CommandGroup( name: "Container", subcommands: [ + ContainerCopy.self, ContainerCreate.self, ContainerDelete.self, ContainerExec.self, diff --git a/Sources/ContainerCommands/Container/ContainerCopy.swift b/Sources/ContainerCommands/Container/ContainerCopy.swift new file mode 100644 index 000000000..6417a9663 --- /dev/null +++ b/Sources/ContainerCommands/Container/ContainerCopy.swift @@ -0,0 +1,81 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025-2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ArgumentParser +import ContainerAPIClient +import ContainerResource +import ContainerizationError +import Foundation +import Containerization + +extension Application { + public struct ContainerCopy: AsyncLoggableCommand { + enum PathRef { + case local(String) + case container(id: String, path: String) + } + + static func parsePathRef(_ ref: String) -> PathRef { + if let colonIdx = ref.firstIndex(of: ":") { + let id = String(ref[ref.startIndex.. ContainerStats { let request = XPCMessage(route: .containerStats) diff --git a/Sources/Services/ContainerAPIService/Client/XPC+.swift b/Sources/Services/ContainerAPIService/Client/XPC+.swift index ca838aade..01e59d1e8 100644 --- a/Sources/Services/ContainerAPIService/Client/XPC+.swift +++ b/Sources/Services/ContainerAPIService/Client/XPC+.swift @@ -123,6 +123,11 @@ public enum XPCKeys: String { /// Disk usage case diskUsageStats + + /// Copy parameters + case sourcePath + case destinationPath + case fileMode } public enum XPCRoute: String { @@ -142,6 +147,8 @@ public enum XPCRoute: String { case containerEvent case containerStats case containerDiskUsage + case containerCopyIn + case containerCopyOut case pluginLoad case pluginGet diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift index b96d86de9..b33c85f9f 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift @@ -284,6 +284,57 @@ public struct ContainersHarness: Sendable { return reply } + @Sendable + public func copyIn(_ message: XPCMessage) async throws -> XPCMessage { + guard let id = message.string(key: .id) else { + throw ContainerizationError( + .invalidArgument, + message: "id cannot be empty" + ) + } + guard let sourcePath = message.string(key: .sourcePath) else { + throw ContainerizationError( + .invalidArgument, + message: "source path cannot be empty" + ) + } + guard let destinationPath = message.string(key: .destinationPath) else { + throw ContainerizationError( + .invalidArgument, + message: "destination path cannot be empty" + ) + } + let mode = UInt32(message.uint64(key: .fileMode)) + + try await service.copyIn(id: id, source: sourcePath, destination: destinationPath, mode: mode) + return message.reply() + } + + @Sendable + public func copyOut(_ message: XPCMessage) async throws -> XPCMessage { + guard let id = message.string(key: .id) else { + throw ContainerizationError( + .invalidArgument, + message: "id cannot be empty" + ) + } + guard let sourcePath = message.string(key: .sourcePath) else { + throw ContainerizationError( + .invalidArgument, + message: "source path cannot be empty" + ) + } + guard let destinationPath = message.string(key: .destinationPath) else { + throw ContainerizationError( + .invalidArgument, + message: "destination path cannot be empty" + ) + } + + try await service.copyOut(id: id, source: sourcePath, destination: destinationPath) + return message.reply() + } + @Sendable public func stats(_ message: XPCMessage) async throws -> XPCMessage { let id = message.string(key: .id) diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index c76e7d11c..d8d1a6e10 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -465,6 +465,24 @@ public actor ContainersService { } } + /// Copy a file from the host into the container. + public func copyIn(id: String, source: String, destination: String, mode: UInt32) async throws { + self.log.debug("\(#function)") + + let state = try self._getContainerState(id: id) + let client = try state.getClient() + try await client.copyIn(source: source, destination: destination, mode: mode) + } + + /// Copy a file from the container to the host. + public func copyOut(id: String, source: String, destination: String) async throws { + self.log.debug("\(#function)") + + let state = try self._getContainerState(id: id) + let client = try state.getClient() + try await client.copyOut(source: source, destination: destination) + } + /// Get statistics for the container. public func stats(id: String) async throws -> ContainerStats { self.log.debug("\(#function)") diff --git a/Sources/Services/ContainerSandboxService/Client/SandboxClient.swift b/Sources/Services/ContainerSandboxService/Client/SandboxClient.swift index ca61eee4c..eead59ec4 100644 --- a/Sources/Services/ContainerSandboxService/Client/SandboxClient.swift +++ b/Sources/Services/ContainerSandboxService/Client/SandboxClient.swift @@ -275,6 +275,39 @@ extension SandboxClient { } } + public func copyIn(source: String, destination: String, mode: UInt32) async throws { + let request = XPCMessage(route: SandboxRoutes.copyIn.rawValue) + request.set(key: SandboxKeys.sourcePath.rawValue, value: source) + request.set(key: SandboxKeys.destinationPath.rawValue, value: destination) + request.set(key: SandboxKeys.fileMode.rawValue, value: UInt64(mode)) + + do { + try await self.client.send(request, responseTimeout: .seconds(300)) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to copy into container \(self.id)", + cause: error + ) + } + } + + public func copyOut(source: String, destination: String) async throws { + let request = XPCMessage(route: SandboxRoutes.copyOut.rawValue) + request.set(key: SandboxKeys.sourcePath.rawValue, value: source) + request.set(key: SandboxKeys.destinationPath.rawValue, value: destination) + + do { + try await self.client.send(request, responseTimeout: .seconds(300)) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to copy from container \(self.id)", + cause: error + ) + } + } + public func statistics() async throws -> ContainerStats { let request = XPCMessage(route: SandboxRoutes.statistics.rawValue) diff --git a/Sources/Services/ContainerSandboxService/Client/SandboxKeys.swift b/Sources/Services/ContainerSandboxService/Client/SandboxKeys.swift index 2cb5b5ff7..43a67cba3 100644 --- a/Sources/Services/ContainerSandboxService/Client/SandboxKeys.swift +++ b/Sources/Services/ContainerSandboxService/Client/SandboxKeys.swift @@ -42,4 +42,9 @@ public enum SandboxKeys: String { /// Container statistics case statistics + + /// Copy parameters + case sourcePath + case destinationPath + case fileMode } diff --git a/Sources/Services/ContainerSandboxService/Client/SandboxRoutes.swift b/Sources/Services/ContainerSandboxService/Client/SandboxRoutes.swift index 79af080f9..6ed62d34a 100644 --- a/Sources/Services/ContainerSandboxService/Client/SandboxRoutes.swift +++ b/Sources/Services/ContainerSandboxService/Client/SandboxRoutes.swift @@ -41,4 +41,8 @@ public enum SandboxRoutes: String { case shutdown = "com.apple.container.sandbox/shutdown" /// Get statistics for the sandbox. case statistics = "com.apple.container.sandbox/statistics" + /// Copy a file into the container. + case copyIn = "com.apple.container.sandbox/copyIn" + /// Copy a file out of the container. + case copyOut = "com.apple.container.sandbox/copyOut" } diff --git a/Sources/Services/ContainerSandboxService/Server/SandboxService.swift b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift index 3cc645696..ed0bb20b8 100644 --- a/Sources/Services/ContainerSandboxService/Server/SandboxService.swift +++ b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift @@ -606,6 +606,91 @@ public actor SandboxService { return reply } + /// Copy a file from the host into the container. + /// + /// - Parameters: + /// - message: An XPC message with the following parameters: + /// - sourcePath: The host path to copy from. + /// - destinationPath: The container path to copy to. + /// - fileMode: The file permissions mode (UInt64). + /// + /// - Returns: An XPC message with no parameters. + @Sendable + public func copyIn(_ message: XPCMessage) async throws -> XPCMessage { + self.log.info("`copyIn` xpc handler") + switch self.state { + case .running, .booted: + guard let source = message.string(key: SandboxKeys.sourcePath.rawValue) else { + throw ContainerizationError( + .invalidArgument, + message: "no source path supplied for copyIn" + ) + } + guard let destination = message.string(key: SandboxKeys.destinationPath.rawValue) else { + throw ContainerizationError( + .invalidArgument, + message: "no destination path supplied for copyIn" + ) + } + let mode = UInt32(message.uint64(key: SandboxKeys.fileMode.rawValue)) + + let ctr = try getContainer() + try await ctr.container.copyIn( + from: URL(fileURLWithPath: source), + to: URL(fileURLWithPath: destination), + mode: mode + ) + + return message.reply() + default: + throw ContainerizationError( + .invalidState, + message: "cannot copyIn: container is not running" + ) + } + } + + /// Copy a file from the container to the host. + /// + /// - Parameters: + /// - message: An XPC message with the following parameters: + /// - sourcePath: The container path to copy from. + /// - destinationPath: The host path to copy to. + /// + /// - Returns: An XPC message with no parameters. + @Sendable + public func copyOut(_ message: XPCMessage) async throws -> XPCMessage { + self.log.info("`copyOut` xpc handler") + switch self.state { + case .running, .booted: + guard let source = message.string(key: SandboxKeys.sourcePath.rawValue) else { + throw ContainerizationError( + .invalidArgument, + message: "no source path supplied for copyOut" + ) + } + guard let destination = message.string(key: SandboxKeys.destinationPath.rawValue) else { + throw ContainerizationError( + .invalidArgument, + message: "no destination path supplied for copyOut" + ) + } + + let ctr = try getContainer() + try await ctr.container.copyOut( + from: URL(fileURLWithPath: source), + to: URL(fileURLWithPath: destination) + ) + + return message.reply() + default: + throw ContainerizationError( + .invalidState, + message: "cannot copyOut: container is not running" + ) + } + } + /// Dial a vsock port on the virtual machine. /// /// - Parameters: From 5f81b124353f4f16673fba42fb8980f70b18c82d Mon Sep 17 00:00:00 2001 From: Simone Panico Date: Tue, 10 Feb 2026 21:53:28 +0100 Subject: [PATCH 02/11] Add CLICopy Tests --- .../Container/ContainerCopy.swift | 6 +- .../Subcommands/Containers/TestCLICopy.swift | 221 ++++++++++++++++++ 2 files changed, 224 insertions(+), 3 deletions(-) create mode 100644 Tests/CLITests/Subcommands/Containers/TestCLICopy.swift diff --git a/Sources/ContainerCommands/Container/ContainerCopy.swift b/Sources/ContainerCommands/Container/ContainerCopy.swift index 6417a9663..ccb1fb070 100644 --- a/Sources/ContainerCommands/Container/ContainerCopy.swift +++ b/Sources/ContainerCommands/Container/ContainerCopy.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025-2026 Apple Inc. and the container project authors. +// Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -17,9 +17,9 @@ import ArgumentParser import ContainerAPIClient import ContainerResource +import Containerization import ContainerizationError import Foundation -import Containerization extension Application { public struct ContainerCopy: AsyncLoggableCommand { @@ -59,7 +59,7 @@ extension Application { let client = ContainerClient() let srcRef = Self.parsePathRef(source) let dstRef = Self.parsePathRef(destination) - + switch (srcRef, dstRef) { case (.container(let id, let path), .local(let localPath)): let resolvedLocal = URL(fileURLWithPath: localPath).standardizedFileURL.path diff --git a/Tests/CLITests/Subcommands/Containers/TestCLICopy.swift b/Tests/CLITests/Subcommands/Containers/TestCLICopy.swift new file mode 100644 index 000000000..217851805 --- /dev/null +++ b/Tests/CLITests/Subcommands/Containers/TestCLICopy.swift @@ -0,0 +1,221 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerizationExtras +import Foundation +import Testing + +class TestCLICopyCommand: CLITest { + private func getTestName() -> String { + Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() + } + + @Test func testCopyHostToContainer() throws { + do { + let name = getTestName() + try doCreate(name: name) + defer { + try? doStop(name: name) + } + try doStart(name: name) + try waitForContainerRunning(name) + + let tempFile = testDir.appendingPathComponent("testfile.txt") + let content = "hello from host" + try content.write(to: tempFile, atomically: true, encoding: .utf8) + + let (_, _, error, status) = try run(arguments: [ + "copy", + tempFile.path, + "\(name):/tmp/", + ]) + if status != 0 { + throw CLIError.executionFailed("copy failed: \(error)") + } + + let catOutput = try doExec(name: name, cmd: ["cat", "/tmp/testfile.txt"]) + #expect( + catOutput.trimmingCharacters(in: .whitespacesAndNewlines) == content, + "expected file content to be '\(content)', got '\(catOutput.trimmingCharacters(in: .whitespacesAndNewlines))'" + ) + + try doStop(name: name) + } catch { + Issue.record("failed to copy file from host to container: \(error)") + return + } + } + + @Test func testCopyContainerToHost() throws { + do { + let name = getTestName() + try doCreate(name: name) + defer { + try? doStop(name: name) + } + try doStart(name: name) + try waitForContainerRunning(name) + + let content = "hello from container" + _ = try doExec(name: name, cmd: ["sh", "-c", "echo -n '\(content)' > /tmp/containerfile.txt"]) + + let destPath = testDir.appendingPathComponent("containerfile.txt") + let (_, _, error, status) = try run(arguments: [ + "copy", + "\(name):/tmp/containerfile.txt", + destPath.path, + ]) + if status != 0 { + throw CLIError.executionFailed("copy failed: \(error)") + } + + let hostContent = try String(contentsOfFile: destPath.path, encoding: .utf8) + #expect( + hostContent == content, + "expected file content to be '\(content)', got '\(hostContent)'" + ) + + try doStop(name: name) + } catch { + Issue.record("failed to copy file from container to host: \(error)") + return + } + } + + @Test func testCopyUsingCpAlias() throws { + do { + let name = getTestName() + try doCreate(name: name) + defer { + try? doStop(name: name) + } + try doStart(name: name) + try waitForContainerRunning(name) + + let tempFile = testDir.appendingPathComponent("aliasfile.txt") + let content = "testing cp alias" + try content.write(to: tempFile, atomically: true, encoding: .utf8) + + let (_, _, error, status) = try run(arguments: [ + "cp", + tempFile.path, + "\(name):/tmp/", + ]) + if status != 0 { + throw CLIError.executionFailed("cp alias failed: \(error)") + } + + let catOutput = try doExec(name: name, cmd: ["cat", "/tmp/aliasfile.txt"]) + #expect( + catOutput.trimmingCharacters(in: .whitespacesAndNewlines) == content, + "expected file content to be '\(content)', got '\(catOutput.trimmingCharacters(in: .whitespacesAndNewlines))'" + ) + + try doStop(name: name) + } catch { + Issue.record("failed to copy file using cp alias: \(error)") + return + } + } + + @Test func testCopyLocalToLocalFails() throws { + let (_, _, _, status) = try run(arguments: [ + "copy", + "/tmp/source.txt", + "/tmp/dest.txt", + ]) + #expect(status != 0, "expected local-to-local copy to fail") + } + + @Test func testCopyContainerToContainerFails() throws { + do { + let name = getTestName() + try doCreate(name: name) + defer { + try? doStop(name: name) + } + + let (_, _, _, status) = try run(arguments: [ + "copy", + "\(name):/tmp/file.txt", + "\(name):/tmp/file2.txt", + ]) + #expect(status != 0, "expected container-to-container copy to fail") + } catch { + Issue.record("failed test for container-to-container copy: \(error)") + return + } + } + + @Test func testCopyToNonRunningContainerFails() throws { + do { + let name = getTestName() + try doCreate(name: name) + defer { + try? doStop(name: name) + } + + let tempFile = testDir.appendingPathComponent("norun.txt") + try "test".write(to: tempFile, atomically: true, encoding: .utf8) + + let (_, _, _, status) = try run(arguments: [ + "copy", + tempFile.path, + "\(name):/tmp/", + ]) + #expect(status != 0, "expected copy to non-running container to fail") + } catch { + Issue.record("failed test for copy to non-running container: \(error)") + return + } + } + + @Test func testCopyHostToContainerWithoutTrailingSlash() throws { + do { + let name = getTestName() + try doCreate(name: name) + defer { + try? doStop(name: name) + } + try doStart(name: name) + try waitForContainerRunning(name) + + let tempFile = testDir.appendingPathComponent("noslash.txt") + let content = "no trailing slash" + try content.write(to: tempFile, atomically: true, encoding: .utf8) + + let (_, _, error, status) = try run(arguments: [ + "copy", + tempFile.path, + "\(name):/tmp", + ]) + if status != 0 { + throw CLIError.executionFailed("copy failed: \(error)") + } + + let catOutput = try doExec(name: name, cmd: ["cat", "/tmp/noslash.txt"]) + #expect( + catOutput.trimmingCharacters(in: .whitespacesAndNewlines) == content, + "expected file content to be '\(content)', got '\(catOutput.trimmingCharacters(in: .whitespacesAndNewlines))'" + ) + + try doStop(name: name) + } catch { + Issue.record("failed to copy file without trailing slash: \(error)") + return + } + } +} From 821d2b621012df0619c6c05aa5b6ba892a244575 Mon Sep 17 00:00:00 2001 From: Simone Panico Date: Tue, 10 Feb 2026 22:22:14 +0100 Subject: [PATCH 03/11] Update Docs --- Makefile | 1 + docs/command-reference.md | 33 +++++++++++++++++++++++++++++++++ docs/how-to.md | 25 +++++++++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/Makefile b/Makefile index 93a4e079e..184b1297d 100644 --- a/Makefile +++ b/Makefile @@ -184,6 +184,7 @@ integration: init-block $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunLifecycle || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIExecCommand || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLICreateCommand || exit_code=1 ; \ + $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLICopyCommand || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunCommand1 || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunCommand2 || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunCommand3 || exit_code=1 ; \ diff --git a/docs/command-reference.md b/docs/command-reference.md index d2aa31179..9cc0ae112 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -424,6 +424,39 @@ container stats --no-stream web container stats --format json --no-stream web ``` +### `container copy (cp)` + +Copies files between a container and the local filesystem. The container must be running. One of the source or destination must be a container reference in the form `container_id:path`. + +**Usage** + +```bash +container copy [--debug] +``` + +**Arguments** + +* ``: Source path (local path or `container_id:path`) +* ``: Destination path (local path or `container_id:path`) + +**Path Format** + +* Local path: `/path/to/file` or `relative/path` +* Container path: `container_id:/path/in/container` + +**Examples** + +```bash +# copy a file from host to container +container cp ./config.json mycontainer:/etc/app/ + +# copy a file from container to host +container cp mycontainer:/var/log/app.log ./logs/ + +# copy using the full command name +container copy ./data.txt mycontainer:/tmp/ +``` + ### `container prune` Removes stopped containers to reclaim disk space. The command outputs the amount of space freed after deletion. diff --git a/docs/how-to.md b/docs/how-to.md index 633fe21ec..44c1543d1 100644 --- a/docs/how-to.md +++ b/docs/how-to.md @@ -60,6 +60,31 @@ total 4 % +## Copy files between host and container + +Use `container copy` (or the short alias `container cp`) to transfer files between a running container and your local filesystem. + +To copy a local file into a running container: + +
+% echo "hello" > greeting.txt
+% container cp greeting.txt mycontainer:/tmp/
+% container exec mycontainer cat /tmp/greeting.txt
+hello
+%
+
+ +To copy a file from a container to the host: + +
+% container cp mycontainer:/etc/hostname ./hostname.txt
+% cat hostname.txt
+mycontainer
+%
+
+ +The path format uses a colon to distinguish container paths from local paths: `container_id:/path/in/container`. One of the source or destination must always be a container reference. The container must be running for the copy to succeed. + ## Build and run a multiplatform image Using the [project from the tutorial example](tutorial.md#set-up-a-simple-project), you can create an image to use both on Apple silicon Macs and on x86-64 servers. From 993f3968db4cd541a329820b3c3552ef109ebdaf Mon Sep 17 00:00:00 2001 From: Simone Date: Tue, 17 Feb 2026 22:28:36 +0100 Subject: [PATCH 04/11] Refactor file copy methods to use URL types and add container state validation --- .../Container/ContainerCopy.swift | 19 ++++++++------ .../Client/ContainerClient.swift | 12 ++++----- .../Server/Containers/ContainersService.swift | 6 +++++ docs/how-to.md | 25 ------------------- 4 files changed, 23 insertions(+), 39 deletions(-) diff --git a/Sources/ContainerCommands/Container/ContainerCopy.swift b/Sources/ContainerCommands/Container/ContainerCopy.swift index ccb1fb070..adb4b7137 100644 --- a/Sources/ContainerCommands/Container/ContainerCopy.swift +++ b/Sources/ContainerCommands/Container/ContainerCopy.swift @@ -29,9 +29,10 @@ extension Application { } static func parsePathRef(_ ref: String) -> PathRef { - if let colonIdx = ref.firstIndex(of: ":") { - let id = String(ref[ref.startIndex.. -## Copy files between host and container - -Use `container copy` (or the short alias `container cp`) to transfer files between a running container and your local filesystem. - -To copy a local file into a running container: - -
-% echo "hello" > greeting.txt
-% container cp greeting.txt mycontainer:/tmp/
-% container exec mycontainer cat /tmp/greeting.txt
-hello
-%
-
- -To copy a file from a container to the host: - -
-% container cp mycontainer:/etc/hostname ./hostname.txt
-% cat hostname.txt
-mycontainer
-%
-
- -The path format uses a colon to distinguish container paths from local paths: `container_id:/path/in/container`. One of the source or destination must always be a container reference. The container must be running for the copy to succeed. - ## Build and run a multiplatform image Using the [project from the tutorial example](tutorial.md#set-up-a-simple-project), you can create an image to use both on Apple silicon Macs and on x86-64 servers. From bd2c05516aaf660339045aa83472bfd5c8be517c Mon Sep 17 00:00:00 2001 From: Simone Date: Tue, 17 Feb 2026 22:33:24 +0100 Subject: [PATCH 05/11] Correct destination path handling in ContainerCopy command --- Sources/ContainerCommands/Container/ContainerCopy.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/ContainerCommands/Container/ContainerCopy.swift b/Sources/ContainerCommands/Container/ContainerCopy.swift index adb4b7137..b248f3f64 100644 --- a/Sources/ContainerCommands/Container/ContainerCopy.swift +++ b/Sources/ContainerCommands/Container/ContainerCopy.swift @@ -63,13 +63,13 @@ extension Application { switch (srcRef, dstRef) { case (.container(let id, let path), .local(let localPath)): - let destURL = URL(fileURLWithPath: localPath).standardizedFileURL let srcURL = URL(fileURLWithPath: path) + let localDest = localPath.hasSuffix("/") ? localPath + srcURL.lastPathComponent : localPath + let destURL = URL(fileURLWithPath: localDest).standardizedFileURL try await client.copyOut(id: id, source: srcURL, destination: destURL) case (.local(let localPath), .container(let id, let path)): let srcURL = URL(fileURLWithPath: localPath).standardizedFileURL - let filename = srcURL.lastPathComponent - let containerDest = path.hasSuffix("/") ? path + filename : path + "/" + filename + let containerDest = path.hasSuffix("/") ? path + srcURL.lastPathComponent : path let destURL = URL(fileURLWithPath: containerDest) try await client.copyIn(id: id, source: srcURL, destination: destURL) case (.container, .container): From 8d91bcc8629fe269c35cf2a49a3b6e8353a2b293 Mon Sep 17 00:00:00 2001 From: Simone Date: Wed, 18 Feb 2026 21:35:41 +0100 Subject: [PATCH 06/11] Add correct destination path handling in ContainerCopy command --- .../Container/ContainerCopy.swift | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/Sources/ContainerCommands/Container/ContainerCopy.swift b/Sources/ContainerCommands/Container/ContainerCopy.swift index b248f3f64..938ddf508 100644 --- a/Sources/ContainerCommands/Container/ContainerCopy.swift +++ b/Sources/ContainerCommands/Container/ContainerCopy.swift @@ -28,16 +28,16 @@ extension Application { case container(id: String, path: String) } - static func parsePathRef(_ ref: String) -> PathRef { + static func parsePathRef(_ ref: String) throws -> PathRef { let parts = ref.components(separatedBy: ":") - if parts.count == 2 { - let id = parts[0] - let path = parts[1] - if !id.isEmpty && !path.isEmpty { - return .container(id: id, path: path) - } + switch parts.count { + case 1: + return .local(ref) + case 2 where !parts[0].isEmpty && parts[1].starts(with: "/"): + return .container(id: parts[0], path: parts[1]) + default: + throw ContainerizationError(.invalidArgument, message: "invalid path given: \(ref)") } - return .local(ref) } public init() {} @@ -58,17 +58,32 @@ extension Application { public func run() async throws { let client = ContainerClient() - let srcRef = Self.parsePathRef(source) - let dstRef = Self.parsePathRef(destination) + let srcRef = try Self.parsePathRef(source) + let dstRef = try Self.parsePathRef(destination) switch (srcRef, dstRef) { case (.container(let id, let path), .local(let localPath)): let srcURL = URL(fileURLWithPath: path) - let localDest = localPath.hasSuffix("/") ? localPath + srcURL.lastPathComponent : localPath - let destURL = URL(fileURLWithPath: localDest).standardizedFileURL - try await client.copyOut(id: id, source: srcURL, destination: destURL) + let destURL = URL(fileURLWithPath: localPath).standardizedFileURL + var isDirectory: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: destURL.path, isDirectory: &isDirectory) + if localPath.hasSuffix("/") { + guard exists && isDirectory.boolValue else { + throw ContainerizationError(.invalidArgument, message: "destination path is not a directory: \(localPath)") + } + } + let appendFilename = localPath.hasSuffix("/") || (exists && isDirectory.boolValue) + let finalDestURL = appendFilename ? destURL.appendingPathComponent(srcURL.lastPathComponent) : destURL + try await client.copyOut(id: id, source: srcURL, destination: finalDestURL) case (.local(let localPath), .container(let id, let path)): let srcURL = URL(fileURLWithPath: localPath).standardizedFileURL + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: srcURL.path, isDirectory: &isDirectory) else { + throw ContainerizationError(.notFound, message: "source path does not exist: \(localPath)") + } + if localPath.hasSuffix("/") && !isDirectory.boolValue { + throw ContainerizationError(.invalidArgument, message: "source path is not a directory: \(localPath)") + } let containerDest = path.hasSuffix("/") ? path + srcURL.lastPathComponent : path let destURL = URL(fileURLWithPath: containerDest) try await client.copyIn(id: id, source: srcURL, destination: destURL) From e50aa8626a10573f76a277077ef38b684c83243a Mon Sep 17 00:00:00 2001 From: Simone Date: Wed, 11 Mar 2026 13:25:13 +0100 Subject: [PATCH 07/11] Update Package.resolved and fix XPCKeys enum case for statistics --- Package.resolved | 6 +++--- Sources/Services/ContainerAPIService/Client/XPC+.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Package.resolved b/Package.resolved index d1aabdfd4..901bd1a9b 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "040dd2e2c8649defb737f900e4032d270935f0af91ebf4a87d64391ecd4ea40b", + "originHash" : "ff3186365938b6d99b7cae1f7e22da26aea8b8e1a4b2356d650eeb3cb2b4a6ad", "pins" : [ { "identity" : "async-http-client", @@ -22,7 +22,7 @@ { "identity" : "dns", "kind" : "remoteSourceControl", - "location" : "https://github.com/Bouke/DNS.git", + "location" : "https://github.com/Bouke/DNS", "state" : { "revision" : "78bbd1589890a90b202d11d5f9e1297050cf0eb2", "version" : "1.2.0" @@ -31,7 +31,7 @@ { "identity" : "dnsclient", "kind" : "remoteSourceControl", - "location" : "https://github.com/orlandos-nl/DNSClient.git", + "location" : "https://github.com/orlandos-nl/DNSClient", "state" : { "revision" : "551fbddbf4fa728d4cd86f6a5208fe4f925f0549", "version" : "2.4.4" diff --git a/Sources/Services/ContainerAPIService/Client/XPC+.swift b/Sources/Services/ContainerAPIService/Client/XPC+.swift index 16e6fb0bc..ed79b24a5 100644 --- a/Sources/Services/ContainerAPIService/Client/XPC+.swift +++ b/Sources/Services/ContainerAPIService/Client/XPC+.swift @@ -124,7 +124,7 @@ public enum XPCKeys: String { case volumeContainerId /// Container statistics - case statisticshttps://github.com/apple/container/pull/1190/conflict?name=Sources%252FHelpers%252FAPIServer%252FAPIServer%252BStart.swift&ancestor_oid=53175b91c6a1d7019854596f41660d0e45600ba0&base_oid=155d6c3448d8a05053ad3494b8ab58ef619757b2&head_oid=f5de594190fdf80b039e02950c732a203ddaeca0 + case statistics case containerSize /// Container list filters From e030032833790aae9be24f1cd20174ccfe3656e2 Mon Sep 17 00:00:00 2001 From: Simone Date: Wed, 11 Mar 2026 14:41:44 +0100 Subject: [PATCH 08/11] Add Copy Directory Support + Add Copy Directory Tests --- Package.resolved | 6 +- Package.swift | 2 +- .../Client/ContainerClient.swift | 4 +- .../Server/Containers/ContainersService.swift | 4 +- .../Client/SandboxRoutes.swift | 4 +- .../Server/SandboxService.swift | 4 +- .../Subcommands/Containers/TestCLICopy.swift | 139 ++++++++++++++++-- 7 files changed, 140 insertions(+), 23 deletions(-) diff --git a/Package.resolved b/Package.resolved index 901bd1a9b..0aee731bf 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "ff3186365938b6d99b7cae1f7e22da26aea8b8e1a4b2356d650eeb3cb2b4a6ad", + "originHash" : "388f8f1a8e8c875bee4f17ec2ad069f8a684bb7e23cc65a72b4aad1e6d82c50b", "pins" : [ { "identity" : "async-http-client", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/containerization.git", "state" : { - "revision" : "d3ff56e5dd93a4573ce7950f3428710bbe467f9c", - "version" : "0.27.0" + "branch" : "main", + "revision" : "d3ff56e5dd93a4573ce7950f3428710bbe467f9c" } }, { diff --git a/Package.swift b/Package.swift index 3a24b7971..06277001d 100644 --- a/Package.swift +++ b/Package.swift @@ -48,7 +48,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/Bouke/DNS.git", from: "1.2.0"), - .package(url: "https://github.com/apple/containerization.git", exact: Version(stringLiteral: scVersion)), + .package(url: "https://github.com/apple/containerization.git", branch: "main"), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), .package(url: "https://github.com/apple/swift-collections.git", from: "1.2.0"), .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), diff --git a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift index 6d558cb10..53e81b438 100644 --- a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift +++ b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift @@ -299,7 +299,7 @@ public struct ContainerClient: Sendable { return fh } - /// Copy a file from the host into the container. + /// Copy a file or directory from the host into the container. public func copyIn(id: String, source: URL, destination: URL, mode: UInt32 = 0o644) async throws { let request = XPCMessage(route: .containerCopyIn) request.set(key: .id, value: id) @@ -318,7 +318,7 @@ public struct ContainerClient: Sendable { } } - /// Copy a file from the container to the host. + /// Copy a file or directory from the container to the host. public func copyOut(id: String, source: URL, destination: URL) async throws { let request = XPCMessage(route: .containerCopyOut) request.set(key: .id, value: id) diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index 40b81aafd..7bbfd9f2a 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -776,7 +776,7 @@ public actor ContainersService { } } - /// Copy a file from the host into the container. + /// Copy a file or directory from the host into the container. public func copyIn(id: String, source: String, destination: String, mode: UInt32) async throws { self.log.debug("\(#function)") @@ -788,7 +788,7 @@ public actor ContainersService { try await client.copyIn(source: source, destination: destination, mode: mode) } - /// Copy a file from the container to the host. + /// Copy a file or directory from the container to the host. public func copyOut(id: String, source: String, destination: String) async throws { self.log.debug("\(#function)") diff --git a/Sources/Services/ContainerSandboxService/Client/SandboxRoutes.swift b/Sources/Services/ContainerSandboxService/Client/SandboxRoutes.swift index 6ed62d34a..b0da30f54 100644 --- a/Sources/Services/ContainerSandboxService/Client/SandboxRoutes.swift +++ b/Sources/Services/ContainerSandboxService/Client/SandboxRoutes.swift @@ -41,8 +41,8 @@ public enum SandboxRoutes: String { case shutdown = "com.apple.container.sandbox/shutdown" /// Get statistics for the sandbox. case statistics = "com.apple.container.sandbox/statistics" - /// Copy a file into the container. + /// Copy a file or directory into the container. case copyIn = "com.apple.container.sandbox/copyIn" - /// Copy a file out of the container. + /// Copy a file or directory out of the container. case copyOut = "com.apple.container.sandbox/copyOut" } diff --git a/Sources/Services/ContainerSandboxService/Server/SandboxService.swift b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift index c65a4e23e..aabc5282c 100644 --- a/Sources/Services/ContainerSandboxService/Server/SandboxService.swift +++ b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift @@ -632,7 +632,7 @@ public actor SandboxService { return reply } - /// Copy a file from the host into the container. + /// Copy a file or directory from the host into the container. /// /// - Parameters: /// - message: An XPC message with the following parameters: @@ -676,7 +676,7 @@ public actor SandboxService { } } - /// Copy a file from the container to the host. + /// Copy a file or directory from the container to the host. /// /// - Parameters: /// - message: An XPC message with the following parameters: diff --git a/Tests/CLITests/Subcommands/Containers/TestCLICopy.swift b/Tests/CLITests/Subcommands/Containers/TestCLICopy.swift index 217851805..2d56b7bf2 100644 --- a/Tests/CLITests/Subcommands/Containers/TestCLICopy.swift +++ b/Tests/CLITests/Subcommands/Containers/TestCLICopy.swift @@ -183,7 +183,7 @@ class TestCLICopyCommand: CLITest { } } - @Test func testCopyHostToContainerWithoutTrailingSlash() throws { + @Test func testCopyDirectoryHostToContainer() throws { do { let name = getTestName() try doCreate(name: name) @@ -193,28 +193,145 @@ class TestCLICopyCommand: CLITest { try doStart(name: name) try waitForContainerRunning(name) - let tempFile = testDir.appendingPathComponent("noslash.txt") - let content = "no trailing slash" - try content.write(to: tempFile, atomically: true, encoding: .utf8) + let srcDir = testDir.appendingPathComponent("hostdir") + try FileManager.default.createDirectory(at: srcDir, withIntermediateDirectories: true) + try "file1 content".write(to: srcDir.appendingPathComponent("file1.txt"), atomically: true, encoding: .utf8) + try "file2 content".write(to: srcDir.appendingPathComponent("file2.txt"), atomically: true, encoding: .utf8) let (_, _, error, status) = try run(arguments: [ "copy", - tempFile.path, - "\(name):/tmp", + srcDir.path, + "\(name):/tmp/", ]) if status != 0 { - throw CLIError.executionFailed("copy failed: \(error)") + throw CLIError.executionFailed("copy directory failed: \(error)") } - let catOutput = try doExec(name: name, cmd: ["cat", "/tmp/noslash.txt"]) + let cat1 = try doExec(name: name, cmd: ["cat", "/tmp/hostdir/file1.txt"]) #expect( - catOutput.trimmingCharacters(in: .whitespacesAndNewlines) == content, - "expected file content to be '\(content)', got '\(catOutput.trimmingCharacters(in: .whitespacesAndNewlines))'" + cat1.trimmingCharacters(in: .whitespacesAndNewlines) == "file1 content", + "expected file1 content, got '\(cat1.trimmingCharacters(in: .whitespacesAndNewlines))'" + ) + let cat2 = try doExec(name: name, cmd: ["cat", "/tmp/hostdir/file2.txt"]) + #expect( + cat2.trimmingCharacters(in: .whitespacesAndNewlines) == "file2 content", + "expected file2 content, got '\(cat2.trimmingCharacters(in: .whitespacesAndNewlines))'" + ) + + try doStop(name: name) + } catch { + Issue.record("failed to copy directory from host to container: \(error)") + return + } + } + + @Test func testCopyDirectoryContainerToHost() throws { + do { + let name = getTestName() + try doCreate(name: name) + defer { + try? doStop(name: name) + } + try doStart(name: name) + try waitForContainerRunning(name) + + _ = try doExec(name: name, cmd: ["sh", "-c", "mkdir -p /tmp/guestdir && echo -n 'aaa' > /tmp/guestdir/a.txt && echo -n 'bbb' > /tmp/guestdir/b.txt"]) + + let destPath = testDir.appendingPathComponent("guestdir") + let (_, _, error, status) = try run(arguments: [ + "copy", + "\(name):/tmp/guestdir", + destPath.path, + ]) + if status != 0 { + throw CLIError.executionFailed("copy directory failed: \(error)") + } + + let contentA = try String(contentsOfFile: destPath.appendingPathComponent("a.txt").path, encoding: .utf8) + #expect(contentA == "aaa", "expected 'aaa', got '\(contentA)'") + let contentB = try String(contentsOfFile: destPath.appendingPathComponent("b.txt").path, encoding: .utf8) + #expect(contentB == "bbb", "expected 'bbb', got '\(contentB)'") + + try doStop(name: name) + } catch { + Issue.record("failed to copy directory from container to host: \(error)") + return + } + } + + @Test func testCopyNestedDirectoryHostToContainer() throws { + do { + let name = getTestName() + try doCreate(name: name) + defer { + try? doStop(name: name) + } + try doStart(name: name) + try waitForContainerRunning(name) + + let srcDir = testDir.appendingPathComponent("nested") + let subDir = srcDir.appendingPathComponent("sub") + try FileManager.default.createDirectory(at: subDir, withIntermediateDirectories: true) + try "root file".write(to: srcDir.appendingPathComponent("root.txt"), atomically: true, encoding: .utf8) + try "nested file".write(to: subDir.appendingPathComponent("deep.txt"), atomically: true, encoding: .utf8) + + let (_, _, error, status) = try run(arguments: [ + "copy", + srcDir.path, + "\(name):/tmp/", + ]) + if status != 0 { + throw CLIError.executionFailed("copy nested directory failed: \(error)") + } + + let catRoot = try doExec(name: name, cmd: ["cat", "/tmp/nested/root.txt"]) + #expect( + catRoot.trimmingCharacters(in: .whitespacesAndNewlines) == "root file", + "expected 'root file', got '\(catRoot.trimmingCharacters(in: .whitespacesAndNewlines))'" ) + let catDeep = try doExec(name: name, cmd: ["cat", "/tmp/nested/sub/deep.txt"]) + #expect( + catDeep.trimmingCharacters(in: .whitespacesAndNewlines) == "nested file", + "expected 'nested file', got '\(catDeep.trimmingCharacters(in: .whitespacesAndNewlines))'" + ) + + try doStop(name: name) + } catch { + Issue.record("failed to copy nested directory from host to container: \(error)") + return + } + } + + @Test func testCopyNestedDirectoryContainerToHost() throws { + do { + let name = getTestName() + try doCreate(name: name) + defer { + try? doStop(name: name) + } + try doStart(name: name) + try waitForContainerRunning(name) + + _ = try doExec(name: name, cmd: ["sh", "-c", "mkdir -p /tmp/nested/sub && echo -n 'root file' > /tmp/nested/root.txt && echo -n 'nested file' > /tmp/nested/sub/deep.txt"]) + + let destPath = testDir.appendingPathComponent("nested") + let (_, _, error, status) = try run(arguments: [ + "copy", + "\(name):/tmp/nested", + destPath.path, + ]) + if status != 0 { + throw CLIError.executionFailed("copy nested directory failed: \(error)") + } + + let contentRoot = try String(contentsOfFile: destPath.appendingPathComponent("root.txt").path, encoding: .utf8) + #expect(contentRoot == "root file", "expected 'root file', got '\(contentRoot)'") + let contentDeep = try String(contentsOfFile: destPath.appendingPathComponent("sub").appendingPathComponent("deep.txt").path, encoding: .utf8) + #expect(contentDeep == "nested file", "expected 'nested file', got '\(contentDeep)'") try doStop(name: name) } catch { - Issue.record("failed to copy file without trailing slash: \(error)") + Issue.record("failed to copy nested directory from container to host: \(error)") return } } From 0564e142328431a7753c6c818d7a26995661d3c1 Mon Sep 17 00:00:00 2001 From: Simone Date: Mon, 16 Mar 2026 09:00:21 +0100 Subject: [PATCH 09/11] Add destinationIsDirectory bool --- Package.resolved | 11 +---------- Package.swift | 4 ++-- .../Container/ContainerCopy.swift | 14 ++++---------- .../Client/ContainerClient.swift | 6 ++++-- .../Services/ContainerAPIService/Client/XPC+.swift | 1 + .../Server/Containers/ContainersHarness.swift | 7 +++++-- .../Server/Containers/ContainersService.swift | 8 ++++---- .../Client/SandboxClient.swift | 6 ++++-- .../Client/SandboxKeys.swift | 1 + .../Server/SandboxService.swift | 9 +++++++-- 10 files changed, 33 insertions(+), 34 deletions(-) diff --git a/Package.resolved b/Package.resolved index 0aee731bf..2cc1ea0f3 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "388f8f1a8e8c875bee4f17ec2ad069f8a684bb7e23cc65a72b4aad1e6d82c50b", + "originHash" : "98f5dc1ba2c7d53cde5e51ab4da297279d5e2713b8d676907c930cdcaedd1bcf", "pins" : [ { "identity" : "async-http-client", @@ -10,15 +10,6 @@ "version" : "1.26.1" } }, - { - "identity" : "containerization", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/containerization.git", - "state" : { - "branch" : "main", - "revision" : "d3ff56e5dd93a4573ce7950f3428710bbe467f9c" - } - }, { "identity" : "dns", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 06277001d..479ef8938 100644 --- a/Package.swift +++ b/Package.swift @@ -23,7 +23,7 @@ import PackageDescription let releaseVersion = ProcessInfo.processInfo.environment["RELEASE_VERSION"] ?? "0.0.0" let gitCommit = ProcessInfo.processInfo.environment["GIT_COMMIT"] ?? "unspecified" let builderShimVersion = "0.8.0" -let scVersion = "0.27.0" +let scVersion = "latest" let package = Package( name: "container", @@ -48,7 +48,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/Bouke/DNS.git", from: "1.2.0"), - .package(url: "https://github.com/apple/containerization.git", branch: "main"), + .package(path: "../containerization"), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), .package(url: "https://github.com/apple/swift-collections.git", from: "1.2.0"), .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), diff --git a/Sources/ContainerCommands/Container/ContainerCopy.swift b/Sources/ContainerCommands/Container/ContainerCopy.swift index 938ddf508..efd2f8895 100644 --- a/Sources/ContainerCommands/Container/ContainerCopy.swift +++ b/Sources/ContainerCommands/Container/ContainerCopy.swift @@ -67,14 +67,9 @@ extension Application { let destURL = URL(fileURLWithPath: localPath).standardizedFileURL var isDirectory: ObjCBool = false let exists = FileManager.default.fileExists(atPath: destURL.path, isDirectory: &isDirectory) - if localPath.hasSuffix("/") { - guard exists && isDirectory.boolValue else { - throw ContainerizationError(.invalidArgument, message: "destination path is not a directory: \(localPath)") - } - } - let appendFilename = localPath.hasSuffix("/") || (exists && isDirectory.boolValue) + let appendFilename = exists && isDirectory.boolValue let finalDestURL = appendFilename ? destURL.appendingPathComponent(srcURL.lastPathComponent) : destURL - try await client.copyOut(id: id, source: srcURL, destination: finalDestURL) + try await client.copyOut(id: id, source: srcURL, destination: finalDestURL, destinationIsDirectory: localPath.hasSuffix("/") && !appendFilename) case (.local(let localPath), .container(let id, let path)): let srcURL = URL(fileURLWithPath: localPath).standardizedFileURL var isDirectory: ObjCBool = false @@ -84,9 +79,8 @@ extension Application { if localPath.hasSuffix("/") && !isDirectory.boolValue { throw ContainerizationError(.invalidArgument, message: "source path is not a directory: \(localPath)") } - let containerDest = path.hasSuffix("/") ? path + srcURL.lastPathComponent : path - let destURL = URL(fileURLWithPath: containerDest) - try await client.copyIn(id: id, source: srcURL, destination: destURL) + let destURL = URL(fileURLWithPath: path) + try await client.copyIn(id: id, source: srcURL, destination: destURL, destinationIsDirectory: path.hasSuffix("/")) case (.container, .container): throw ContainerizationError(.invalidArgument, message: "copying between containers is not supported") case (.local, .local): diff --git a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift index 53e81b438..589142e81 100644 --- a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift +++ b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift @@ -300,12 +300,13 @@ public struct ContainerClient: Sendable { } /// Copy a file or directory from the host into the container. - public func copyIn(id: String, source: URL, destination: URL, mode: UInt32 = 0o644) async throws { + public func copyIn(id: String, source: URL, destination: URL, mode: UInt32 = 0o644, destinationIsDirectory: Bool = false) async throws { let request = XPCMessage(route: .containerCopyIn) request.set(key: .id, value: id) request.set(key: .sourcePath, value: source.path) request.set(key: .destinationPath, value: destination.path) request.set(key: .fileMode, value: UInt64(mode)) + request.set(key: .destinationIsDirectory, value: destinationIsDirectory) do { try await xpcSend(message: request, timeout: .seconds(300)) @@ -319,11 +320,12 @@ public struct ContainerClient: Sendable { } /// Copy a file or directory from the container to the host. - public func copyOut(id: String, source: URL, destination: URL) async throws { + public func copyOut(id: String, source: URL, destination: URL, destinationIsDirectory: Bool = false) async throws { let request = XPCMessage(route: .containerCopyOut) request.set(key: .id, value: id) request.set(key: .sourcePath, value: source.path) request.set(key: .destinationPath, value: destination.path) + request.set(key: .destinationIsDirectory, value: destinationIsDirectory) do { try await xpcSend(message: request, timeout: .seconds(300)) diff --git a/Sources/Services/ContainerAPIService/Client/XPC+.swift b/Sources/Services/ContainerAPIService/Client/XPC+.swift index ed79b24a5..bf0d2bde0 100644 --- a/Sources/Services/ContainerAPIService/Client/XPC+.swift +++ b/Sources/Services/ContainerAPIService/Client/XPC+.swift @@ -137,6 +137,7 @@ public enum XPCKeys: String { case sourcePath case destinationPath case fileMode + case destinationIsDirectory } public enum XPCRoute: String { diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift index 114a8405c..a4732bf83 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift @@ -311,8 +311,9 @@ public struct ContainersHarness: Sendable { ) } let mode = UInt32(message.uint64(key: .fileMode)) + let destinationIsDirectory = message.bool(key: .destinationIsDirectory) - try await service.copyIn(id: id, source: sourcePath, destination: destinationPath, mode: mode) + try await service.copyIn(id: id, source: sourcePath, destination: destinationPath, mode: mode, destinationIsDirectory: destinationIsDirectory) return message.reply() } @@ -337,7 +338,9 @@ public struct ContainersHarness: Sendable { ) } - try await service.copyOut(id: id, source: sourcePath, destination: destinationPath) + let destinationIsDirectory = message.bool(key: .destinationIsDirectory) + + try await service.copyOut(id: id, source: sourcePath, destination: destinationPath, destinationIsDirectory: destinationIsDirectory) return message.reply() } diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index 7bbfd9f2a..059d72665 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -777,7 +777,7 @@ public actor ContainersService { } /// Copy a file or directory from the host into the container. - public func copyIn(id: String, source: String, destination: String, mode: UInt32) async throws { + public func copyIn(id: String, source: String, destination: String, mode: UInt32, destinationIsDirectory: Bool = false) async throws { self.log.debug("\(#function)") let state = try self._getContainerState(id: id) @@ -785,11 +785,11 @@ public actor ContainersService { throw ContainerizationError(.invalidState, message: "container \(id) is not running") } let client = try state.getClient() - try await client.copyIn(source: source, destination: destination, mode: mode) + try await client.copyIn(source: source, destination: destination, mode: mode, destinationIsDirectory: destinationIsDirectory) } /// Copy a file or directory from the container to the host. - public func copyOut(id: String, source: String, destination: String) async throws { + public func copyOut(id: String, source: String, destination: String, destinationIsDirectory: Bool = false) async throws { self.log.debug("\(#function)") let state = try self._getContainerState(id: id) @@ -797,7 +797,7 @@ public actor ContainersService { throw ContainerizationError(.invalidState, message: "container \(id) is not running") } let client = try state.getClient() - try await client.copyOut(source: source, destination: destination) + try await client.copyOut(source: source, destination: destination, destinationIsDirectory: destinationIsDirectory) } /// Get statistics for the container. diff --git a/Sources/Services/ContainerSandboxService/Client/SandboxClient.swift b/Sources/Services/ContainerSandboxService/Client/SandboxClient.swift index bc57d1871..ba01d2006 100644 --- a/Sources/Services/ContainerSandboxService/Client/SandboxClient.swift +++ b/Sources/Services/ContainerSandboxService/Client/SandboxClient.swift @@ -277,11 +277,12 @@ extension SandboxClient { } } - public func copyIn(source: String, destination: String, mode: UInt32) async throws { + public func copyIn(source: String, destination: String, mode: UInt32, destinationIsDirectory: Bool = false) async throws { let request = XPCMessage(route: SandboxRoutes.copyIn.rawValue) request.set(key: SandboxKeys.sourcePath.rawValue, value: source) request.set(key: SandboxKeys.destinationPath.rawValue, value: destination) request.set(key: SandboxKeys.fileMode.rawValue, value: UInt64(mode)) + request.set(key: SandboxKeys.destinationIsDirectory.rawValue, value: destinationIsDirectory) do { try await self.client.send(request, responseTimeout: .seconds(300)) @@ -294,10 +295,11 @@ extension SandboxClient { } } - public func copyOut(source: String, destination: String) async throws { + public func copyOut(source: String, destination: String, destinationIsDirectory: Bool = false) async throws { let request = XPCMessage(route: SandboxRoutes.copyOut.rawValue) request.set(key: SandboxKeys.sourcePath.rawValue, value: source) request.set(key: SandboxKeys.destinationPath.rawValue, value: destination) + request.set(key: SandboxKeys.destinationIsDirectory.rawValue, value: destinationIsDirectory) do { try await self.client.send(request, responseTimeout: .seconds(300)) diff --git a/Sources/Services/ContainerSandboxService/Client/SandboxKeys.swift b/Sources/Services/ContainerSandboxService/Client/SandboxKeys.swift index 4f1c31771..fe372f0b5 100644 --- a/Sources/Services/ContainerSandboxService/Client/SandboxKeys.swift +++ b/Sources/Services/ContainerSandboxService/Client/SandboxKeys.swift @@ -47,6 +47,7 @@ public enum SandboxKeys: String { case sourcePath case destinationPath case fileMode + case destinationIsDirectory /// Network resource keys. case allocatedAttachments case networkAdditionalData diff --git a/Sources/Services/ContainerSandboxService/Server/SandboxService.swift b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift index aabc5282c..dab6a250e 100644 --- a/Sources/Services/ContainerSandboxService/Server/SandboxService.swift +++ b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift @@ -659,12 +659,14 @@ public actor SandboxService { ) } let mode = UInt32(message.uint64(key: SandboxKeys.fileMode.rawValue)) + let destinationIsDirectory = message.bool(key: SandboxKeys.destinationIsDirectory.rawValue) let ctr = try getContainer() try await ctr.container.copyIn( from: URL(fileURLWithPath: source), to: URL(fileURLWithPath: destination), - mode: mode + mode: mode, + destinationIsDirectory: destinationIsDirectory ) return message.reply() @@ -702,10 +704,13 @@ public actor SandboxService { ) } + let destinationIsDirectory = message.bool(key: SandboxKeys.destinationIsDirectory.rawValue) + let ctr = try getContainer() try await ctr.container.copyOut( from: URL(fileURLWithPath: source), - to: URL(fileURLWithPath: destination) + to: URL(fileURLWithPath: destination), + destinationIsDirectory: destinationIsDirectory ) return message.reply() From 48d96862d433812cc9841428877c5c4b382d9780 Mon Sep 17 00:00:00 2001 From: Simone Date: Mon, 16 Mar 2026 09:08:10 +0100 Subject: [PATCH 10/11] Revert Package.swift --- Package.resolved | 27 ++++++++++++++++++--------- Package.swift | 4 ++-- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/Package.resolved b/Package.resolved index 2cc1ea0f3..18b1a5443 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "98f5dc1ba2c7d53cde5e51ab4da297279d5e2713b8d676907c930cdcaedd1bcf", + "originHash" : "ff3186365938b6d99b7cae1f7e22da26aea8b8e1a4b2356d650eeb3cb2b4a6ad", "pins" : [ { "identity" : "async-http-client", @@ -10,6 +10,15 @@ "version" : "1.26.1" } }, + { + "identity" : "containerization", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/containerization.git", + "state" : { + "revision" : "d3ff56e5dd93a4573ce7950f3428710bbe467f9c", + "version" : "0.27.0" + } + }, { "identity" : "dns", "kind" : "remoteSourceControl", @@ -51,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser.git", "state" : { - "revision" : "309a47b2b1d9b5e991f36961c983ecec72275be3", - "version" : "1.6.1" + "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", + "version" : "1.7.0" } }, { @@ -150,8 +159,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", - "version" : "1.6.4" + "revision" : "bbd81b6725ae874c69e9b8c8804d462356b55523", + "version" : "1.10.1" } }, { @@ -213,8 +222,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-protobuf.git", "state" : { - "revision" : "102a647b573f60f73afdce5613a51d71349fe507", - "version" : "1.30.0" + "revision" : "a008af1a102ff3dd6cc3764bb69bf63226d0f5f6", + "version" : "1.36.1" } }, { @@ -231,8 +240,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-system.git", "state" : { - "revision" : "890830fff1a577dc83134890c7984020c5f6b43b", - "version" : "1.6.2" + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" } }, { diff --git a/Package.swift b/Package.swift index 479ef8938..3a24b7971 100644 --- a/Package.swift +++ b/Package.swift @@ -23,7 +23,7 @@ import PackageDescription let releaseVersion = ProcessInfo.processInfo.environment["RELEASE_VERSION"] ?? "0.0.0" let gitCommit = ProcessInfo.processInfo.environment["GIT_COMMIT"] ?? "unspecified" let builderShimVersion = "0.8.0" -let scVersion = "latest" +let scVersion = "0.27.0" let package = Package( name: "container", @@ -48,7 +48,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/Bouke/DNS.git", from: "1.2.0"), - .package(path: "../containerization"), + .package(url: "https://github.com/apple/containerization.git", exact: Version(stringLiteral: scVersion)), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), .package(url: "https://github.com/apple/swift-collections.git", from: "1.2.0"), .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), From 4b1a7d69f287a5792e2ff8e832cf256e8e4eea3e Mon Sep 17 00:00:00 2001 From: Simone Date: Wed, 18 Mar 2026 11:38:53 +0100 Subject: [PATCH 11/11] Refactor copyIn and copyOut methods to use createParents instead of destinationIsDirectory --- .../Container/ContainerCopy.swift | 37 +++++++++++++++++-- .../Client/ContainerClient.swift | 8 ++-- .../ContainerAPIService/Client/XPC+.swift | 2 +- .../Server/Containers/ContainersHarness.swift | 8 ++-- .../Server/Containers/ContainersService.swift | 8 ++-- .../Client/SandboxClient.swift | 8 ++-- .../Client/SandboxKeys.swift | 2 +- .../Server/SandboxService.swift | 8 ++-- 8 files changed, 55 insertions(+), 26 deletions(-) diff --git a/Sources/ContainerCommands/Container/ContainerCopy.swift b/Sources/ContainerCommands/Container/ContainerCopy.swift index efd2f8895..9f38d0dd9 100644 --- a/Sources/ContainerCommands/Container/ContainerCopy.swift +++ b/Sources/ContainerCommands/Container/ContainerCopy.swift @@ -67,9 +67,24 @@ extension Application { let destURL = URL(fileURLWithPath: localPath).standardizedFileURL var isDirectory: ObjCBool = false let exists = FileManager.default.fileExists(atPath: destURL.path, isDirectory: &isDirectory) - let appendFilename = exists && isDirectory.boolValue - let finalDestURL = appendFilename ? destURL.appendingPathComponent(srcURL.lastPathComponent) : destURL - try await client.copyOut(id: id, source: srcURL, destination: finalDestURL, destinationIsDirectory: localPath.hasSuffix("/") && !appendFilename) + + if exists && isDirectory.boolValue { + let finalDest = destURL.appendingPathComponent(srcURL.lastPathComponent) + try await client.copyOut(id: id, source: srcURL, destination: finalDest) + } else if localPath.hasSuffix("/") { + try await client.copyOut(id: id, source: srcURL, destination: destURL) + var resultIsDir: ObjCBool = false + if FileManager.default.fileExists(atPath: destURL.path, isDirectory: &resultIsDir), + !resultIsDir.boolValue + { + try? FileManager.default.removeItem(at: destURL) + throw ContainerizationError( + .invalidArgument, + message: "destination is not a directory: \(localPath)") + } + } else { + try await client.copyOut(id: id, source: srcURL, destination: destURL) + } case (.local(let localPath), .container(let id, let path)): let srcURL = URL(fileURLWithPath: localPath).standardizedFileURL var isDirectory: ObjCBool = false @@ -79,8 +94,22 @@ extension Application { if localPath.hasSuffix("/") && !isDirectory.boolValue { throw ContainerizationError(.invalidArgument, message: "source path is not a directory: \(localPath)") } + let destURL = URL(fileURLWithPath: path) - try await client.copyIn(id: id, source: srcURL, destination: destURL, destinationIsDirectory: path.hasSuffix("/")) + let dstWithBasename = destURL.appendingPathComponent(srcURL.lastPathComponent) + do { + try await client.copyIn(id: id, source: srcURL, destination: dstWithBasename, createParents: false) + } catch { + if isDirectory.boolValue { + try await client.copyIn(id: id, source: srcURL, destination: destURL) + } else if path.hasSuffix("/") { + throw ContainerizationError( + .invalidArgument, + message: "destination is not a directory: \(path)") + } else { + try await client.copyIn(id: id, source: srcURL, destination: destURL) + } + } case (.container, .container): throw ContainerizationError(.invalidArgument, message: "copying between containers is not supported") case (.local, .local): diff --git a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift index 589142e81..bd52bcae1 100644 --- a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift +++ b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift @@ -300,13 +300,13 @@ public struct ContainerClient: Sendable { } /// Copy a file or directory from the host into the container. - public func copyIn(id: String, source: URL, destination: URL, mode: UInt32 = 0o644, destinationIsDirectory: Bool = false) async throws { + public func copyIn(id: String, source: URL, destination: URL, mode: UInt32 = 0o644, createParents: Bool = true) async throws { let request = XPCMessage(route: .containerCopyIn) request.set(key: .id, value: id) request.set(key: .sourcePath, value: source.path) request.set(key: .destinationPath, value: destination.path) request.set(key: .fileMode, value: UInt64(mode)) - request.set(key: .destinationIsDirectory, value: destinationIsDirectory) + request.set(key: .createParents, value: createParents) do { try await xpcSend(message: request, timeout: .seconds(300)) @@ -320,12 +320,12 @@ public struct ContainerClient: Sendable { } /// Copy a file or directory from the container to the host. - public func copyOut(id: String, source: URL, destination: URL, destinationIsDirectory: Bool = false) async throws { + public func copyOut(id: String, source: URL, destination: URL, createParents: Bool = true) async throws { let request = XPCMessage(route: .containerCopyOut) request.set(key: .id, value: id) request.set(key: .sourcePath, value: source.path) request.set(key: .destinationPath, value: destination.path) - request.set(key: .destinationIsDirectory, value: destinationIsDirectory) + request.set(key: .createParents, value: createParents) do { try await xpcSend(message: request, timeout: .seconds(300)) diff --git a/Sources/Services/ContainerAPIService/Client/XPC+.swift b/Sources/Services/ContainerAPIService/Client/XPC+.swift index bf0d2bde0..24eee2085 100644 --- a/Sources/Services/ContainerAPIService/Client/XPC+.swift +++ b/Sources/Services/ContainerAPIService/Client/XPC+.swift @@ -137,7 +137,7 @@ public enum XPCKeys: String { case sourcePath case destinationPath case fileMode - case destinationIsDirectory + case createParents } public enum XPCRoute: String { diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift index a4732bf83..be8471549 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift @@ -311,9 +311,9 @@ public struct ContainersHarness: Sendable { ) } let mode = UInt32(message.uint64(key: .fileMode)) - let destinationIsDirectory = message.bool(key: .destinationIsDirectory) + let createParents = message.bool(key: .createParents) - try await service.copyIn(id: id, source: sourcePath, destination: destinationPath, mode: mode, destinationIsDirectory: destinationIsDirectory) + try await service.copyIn(id: id, source: sourcePath, destination: destinationPath, mode: mode, createParents: createParents) return message.reply() } @@ -338,9 +338,9 @@ public struct ContainersHarness: Sendable { ) } - let destinationIsDirectory = message.bool(key: .destinationIsDirectory) + let createParents = message.bool(key: .createParents) - try await service.copyOut(id: id, source: sourcePath, destination: destinationPath, destinationIsDirectory: destinationIsDirectory) + try await service.copyOut(id: id, source: sourcePath, destination: destinationPath, createParents: createParents) return message.reply() } diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index 059d72665..34f5effd0 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -777,7 +777,7 @@ public actor ContainersService { } /// Copy a file or directory from the host into the container. - public func copyIn(id: String, source: String, destination: String, mode: UInt32, destinationIsDirectory: Bool = false) async throws { + public func copyIn(id: String, source: String, destination: String, mode: UInt32, createParents: Bool = true) async throws { self.log.debug("\(#function)") let state = try self._getContainerState(id: id) @@ -785,11 +785,11 @@ public actor ContainersService { throw ContainerizationError(.invalidState, message: "container \(id) is not running") } let client = try state.getClient() - try await client.copyIn(source: source, destination: destination, mode: mode, destinationIsDirectory: destinationIsDirectory) + try await client.copyIn(source: source, destination: destination, mode: mode, createParents: createParents) } /// Copy a file or directory from the container to the host. - public func copyOut(id: String, source: String, destination: String, destinationIsDirectory: Bool = false) async throws { + public func copyOut(id: String, source: String, destination: String, createParents: Bool = true) async throws { self.log.debug("\(#function)") let state = try self._getContainerState(id: id) @@ -797,7 +797,7 @@ public actor ContainersService { throw ContainerizationError(.invalidState, message: "container \(id) is not running") } let client = try state.getClient() - try await client.copyOut(source: source, destination: destination, destinationIsDirectory: destinationIsDirectory) + try await client.copyOut(source: source, destination: destination, createParents: createParents) } /// Get statistics for the container. diff --git a/Sources/Services/ContainerSandboxService/Client/SandboxClient.swift b/Sources/Services/ContainerSandboxService/Client/SandboxClient.swift index ba01d2006..42aada3df 100644 --- a/Sources/Services/ContainerSandboxService/Client/SandboxClient.swift +++ b/Sources/Services/ContainerSandboxService/Client/SandboxClient.swift @@ -277,12 +277,12 @@ extension SandboxClient { } } - public func copyIn(source: String, destination: String, mode: UInt32, destinationIsDirectory: Bool = false) async throws { + public func copyIn(source: String, destination: String, mode: UInt32, createParents: Bool = true) async throws { let request = XPCMessage(route: SandboxRoutes.copyIn.rawValue) request.set(key: SandboxKeys.sourcePath.rawValue, value: source) request.set(key: SandboxKeys.destinationPath.rawValue, value: destination) request.set(key: SandboxKeys.fileMode.rawValue, value: UInt64(mode)) - request.set(key: SandboxKeys.destinationIsDirectory.rawValue, value: destinationIsDirectory) + request.set(key: SandboxKeys.createParents.rawValue, value: createParents) do { try await self.client.send(request, responseTimeout: .seconds(300)) @@ -295,11 +295,11 @@ extension SandboxClient { } } - public func copyOut(source: String, destination: String, destinationIsDirectory: Bool = false) async throws { + public func copyOut(source: String, destination: String, createParents: Bool = true) async throws { let request = XPCMessage(route: SandboxRoutes.copyOut.rawValue) request.set(key: SandboxKeys.sourcePath.rawValue, value: source) request.set(key: SandboxKeys.destinationPath.rawValue, value: destination) - request.set(key: SandboxKeys.destinationIsDirectory.rawValue, value: destinationIsDirectory) + request.set(key: SandboxKeys.createParents.rawValue, value: createParents) do { try await self.client.send(request, responseTimeout: .seconds(300)) diff --git a/Sources/Services/ContainerSandboxService/Client/SandboxKeys.swift b/Sources/Services/ContainerSandboxService/Client/SandboxKeys.swift index fe372f0b5..fad86eab8 100644 --- a/Sources/Services/ContainerSandboxService/Client/SandboxKeys.swift +++ b/Sources/Services/ContainerSandboxService/Client/SandboxKeys.swift @@ -47,7 +47,7 @@ public enum SandboxKeys: String { case sourcePath case destinationPath case fileMode - case destinationIsDirectory + case createParents /// Network resource keys. case allocatedAttachments case networkAdditionalData diff --git a/Sources/Services/ContainerSandboxService/Server/SandboxService.swift b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift index dab6a250e..47038e689 100644 --- a/Sources/Services/ContainerSandboxService/Server/SandboxService.swift +++ b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift @@ -659,14 +659,14 @@ public actor SandboxService { ) } let mode = UInt32(message.uint64(key: SandboxKeys.fileMode.rawValue)) - let destinationIsDirectory = message.bool(key: SandboxKeys.destinationIsDirectory.rawValue) + let createParents = message.bool(key: SandboxKeys.createParents.rawValue) let ctr = try getContainer() try await ctr.container.copyIn( from: URL(fileURLWithPath: source), to: URL(fileURLWithPath: destination), mode: mode, - destinationIsDirectory: destinationIsDirectory + createParents: createParents ) return message.reply() @@ -704,13 +704,13 @@ public actor SandboxService { ) } - let destinationIsDirectory = message.bool(key: SandboxKeys.destinationIsDirectory.rawValue) + let createParents = message.bool(key: SandboxKeys.createParents.rawValue) let ctr = try getContainer() try await ctr.container.copyOut( from: URL(fileURLWithPath: source), to: URL(fileURLWithPath: destination), - destinationIsDirectory: destinationIsDirectory + createParents: createParents ) return message.reply()