diff --git a/Sources/ContainerCommands/Application.swift b/Sources/ContainerCommands/Application.swift index ba057fb3e..edce439cb 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..938ddf508 --- /dev/null +++ b/Sources/ContainerCommands/Container/ContainerCopy.swift @@ -0,0 +1,99 @@ +//===----------------------------------------------------------------------===// +// 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 ArgumentParser +import ContainerAPIClient +import ContainerResource +import Containerization +import ContainerizationError +import Foundation + +extension Application { + public struct ContainerCopy: AsyncLoggableCommand { + enum PathRef { + case local(String) + case container(id: String, path: String) + } + + static func parsePathRef(_ ref: String) throws -> PathRef { + let parts = ref.components(separatedBy: ":") + 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)") + } + } + + public init() {} + + public static let configuration = CommandConfiguration( + commandName: "copy", + abstract: "Copy files/folders between a container and the local filesystem", + aliases: ["cp"]) + + @OptionGroup() + public var logOptions: Flags.Logging + + @Argument(help: "Source path (container:path or local path)") + var source: String + + @Argument(help: "Destination path (container:path or local path)") + var destination: String + + public func run() async throws { + let client = ContainerClient() + 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 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) + case (.container, .container): + throw ContainerizationError(.invalidArgument, message: "copying between containers is not supported") + case (.local, .local): + throw ContainerizationError( + .invalidArgument, + message: "one of source or destination must be a container reference (container_id:path)") + } + } + } +} diff --git a/Sources/Helpers/APIServer/APIServer+Start.swift b/Sources/Helpers/APIServer/APIServer+Start.swift index f5de59419..81a51c9d6 100644 --- a/Sources/Helpers/APIServer/APIServer+Start.swift +++ b/Sources/Helpers/APIServer/APIServer+Start.swift @@ -247,6 +247,8 @@ extension APIServer { routes[XPCRoute.containerKill] = harness.kill routes[XPCRoute.containerStats] = harness.stats routes[XPCRoute.containerDiskUsage] = harness.diskUsage + routes[XPCRoute.containerCopyIn] = harness.copyIn + routes[XPCRoute.containerCopyOut] = harness.copyOut routes[XPCRoute.containerExport] = harness.export return service diff --git a/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper+Start.swift b/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper+Start.swift index 1f45e94aa..32f9c2e18 100644 --- a/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper+Start.swift +++ b/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper+Start.swift @@ -100,6 +100,8 @@ extension RuntimeLinuxHelper { SandboxRoutes.dial.rawValue: server.dial, SandboxRoutes.shutdown.rawValue: server.shutdown, SandboxRoutes.statistics.rawValue: server.statistics, + SandboxRoutes.copyIn.rawValue: server.copyIn, + SandboxRoutes.copyOut.rawValue: server.copyOut, ], log: log ) diff --git a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift index ec50d91c0..6d558cb10 100644 --- a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift +++ b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift @@ -299,6 +299,43 @@ public struct ContainerClient: Sendable { return fh } + /// Copy a file 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) + request.set(key: .sourcePath, value: source.path) + request.set(key: .destinationPath, value: destination.path) + request.set(key: .fileMode, value: UInt64(mode)) + + do { + try await xpcSend(message: request, timeout: .seconds(300)) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to copy into container \(id)", + cause: error + ) + } + } + + /// Copy a file 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) + request.set(key: .sourcePath, value: source.path) + request.set(key: .destinationPath, value: destination.path) + + do { + try await xpcSend(message: request, timeout: .seconds(300)) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to copy from container \(id)", + cause: error + ) + } + } + /// Get resource usage statistics for a container. public func stats(id: String) async throws -> ContainerStats { let request = XPCMessage(route: .containerStats) diff --git a/Sources/Services/ContainerAPIService/Client/XPC+.swift b/Sources/Services/ContainerAPIService/Client/XPC+.swift index 53f098510..c6f6d1689 100644 --- a/Sources/Services/ContainerAPIService/Client/XPC+.swift +++ b/Sources/Services/ContainerAPIService/Client/XPC+.swift @@ -123,7 +123,7 @@ public enum XPCKeys: String { case volumeContainerId /// Container statistics - case 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 containerSize /// Container list filters @@ -131,6 +131,11 @@ public enum XPCKeys: String { /// Disk usage case diskUsageStats + + /// Copy parameters + case sourcePath + case destinationPath + case fileMode } public enum XPCRoute: String { @@ -150,6 +155,8 @@ public enum XPCRoute: String { case containerEvent case containerStats case containerDiskUsage + case containerCopyIn + case containerCopyOut case containerExport case pluginLoad diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift index c315c3e08..114a8405c 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift @@ -290,6 +290,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 0bd9eded4..85bd4501c 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -749,6 +749,30 @@ 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) + guard state.snapshot.status == .running else { + throw ContainerizationError(.invalidState, message: "container \(id) is not running") + } + 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) + guard state.snapshot.status == .running else { + throw ContainerizationError(.invalidState, message: "container \(id) is not running") + } + 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 { log.debug( diff --git a/Sources/Services/ContainerSandboxService/Client/SandboxClient.swift b/Sources/Services/ContainerSandboxService/Client/SandboxClient.swift index 6f1cdd2d8..bc57d1871 100644 --- a/Sources/Services/ContainerSandboxService/Client/SandboxClient.swift +++ b/Sources/Services/ContainerSandboxService/Client/SandboxClient.swift @@ -277,6 +277,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 e207cb049..4f1c31771 100644 --- a/Sources/Services/ContainerSandboxService/Client/SandboxKeys.swift +++ b/Sources/Services/ContainerSandboxService/Client/SandboxKeys.swift @@ -43,6 +43,10 @@ public enum SandboxKeys: String { /// Container statistics case statistics + /// Copy parameters + case sourcePath + case destinationPath + case fileMode /// Network resource keys. case allocatedAttachments case networkAdditionalData 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 0abb93461..4d9f48e9d 100644 --- a/Sources/Services/ContainerSandboxService/Server/SandboxService.swift +++ b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift @@ -630,6 +630,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: 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 + } + } +} 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.