Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 ; \
Expand Down
1 change: 1 addition & 0 deletions Sources/ContainerCommands/Application.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ public struct Application: AsyncLoggableCommand {
CommandGroup(
name: "Container",
subcommands: [
ContainerCopy.self,
ContainerCreate.self,
ContainerDelete.self,
ContainerExec.self,
Expand Down
84 changes: 84 additions & 0 deletions Sources/ContainerCommands/Container/ContainerCopy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
//===----------------------------------------------------------------------===//
// 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) -> 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)
}
}
return .local(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 = Self.parsePathRef(source)
let dstRef = 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)
case (.local(let localPath), .container(let id, let path)):
let srcURL = URL(fileURLWithPath: localPath).standardizedFileURL
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)")
}
}
}
}
2 changes: 2 additions & 0 deletions Sources/Helpers/APIServer/APIServer+Start.swift
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,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

return service
}
Expand Down
2 changes: 2 additions & 0 deletions Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper+Start.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
37 changes: 37 additions & 0 deletions Sources/Services/ContainerAPIService/Client/ContainerClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions Sources/Services/ContainerAPIService/Client/XPC+.swift
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,11 @@ public enum XPCKeys: String {

/// Disk usage
case diskUsageStats

/// Copy parameters
case sourcePath
case destinationPath
case fileMode
}

public enum XPCRoute: String {
Expand All @@ -148,6 +153,8 @@ public enum XPCRoute: String {
case containerEvent
case containerStats
case containerDiskUsage
case containerCopyIn
case containerCopyOut

case pluginLoad
case pluginGet
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,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 {
self.log.debug("\(#function)")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,9 @@ public enum SandboxKeys: String {

/// Container statistics
case statistics

/// Copy parameters
case sourcePath
case destinationPath
case fileMode
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Loading