From 6f1306199b5e2fd4f07836237c149004acf5cbf0 Mon Sep 17 00:00:00 2001 From: Simone Panico Date: Tue, 10 Feb 2026 21:15:43 +0100 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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)