Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
22 changes: 22 additions & 0 deletions Sources/ContainerClient/Archiver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ public final class Archiver: Sendable {
}
entryInfo.append(info)
}
// Handle empty directories - if no entries were added, add the directory itself
if entryInfo.isEmpty {
if let info = closure(source) {
entryInfo.append(info)
}
}
}

let archiver = try ArchiveWriter(
Expand All @@ -98,6 +104,22 @@ public final class Archiver: Sendable {
}
}

public static func compress(
source: URL,
to handle: FileHandle,
followSymlinks: Bool = false,
writerConfiguration: ArchiveWriterConfiguration = ArchiveWriterConfiguration(format: .paxRestricted, filter: .gzip),
closure: (URL) -> ArchiveEntryInfo?
) throws {
let destination = URL(fileURLWithPath: "/dev/fd/\(handle.fileDescriptor)")
try compress(source: source, destination: destination, followSymlinks: followSymlinks, writerConfiguration: writerConfiguration, closure: closure)
}

public static func uncompress(from handle: FileHandle, to destination: URL) throws {
let source = URL(fileURLWithPath: "/dev/fd/\(handle.fileDescriptor)")
try uncompress(source: source, destination: destination)
}

public static func uncompress(source: URL, destination: URL) throws {
let source = source.standardizedFileURL
let destination = destination.standardizedFileURL
Expand Down
27 changes: 27 additions & 0 deletions Sources/ContainerClient/Core/ClientVolume.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,31 @@ public struct ClientVolume {
let size = reply.uint64(key: .volumeSize)
return size
}

public static func copyIn(volume: String, path: String, fileHandle: FileHandle) async throws {
let client = XPCClient(service: serviceIdentifier)
let message = XPCMessage(route: .volumeCopyIn)
message.set(key: .volumeName, value: volume)
message.set(key: .path, value: path)

let fd = fileHandle.fileDescriptor
let xpcHandle = FileHandle(fileDescriptor: fd, closeOnDealloc: false)
message.set(key: .fd, value: xpcHandle)

_ = try await client.send(message)
}

public static func copyOut(volume: String, path: String, fileHandle: FileHandle) async throws {
let client = XPCClient(service: serviceIdentifier)
let message = XPCMessage(route: .volumeCopyOut)
message.set(key: .volumeName, value: volume)
message.set(key: .path, value: path)

let fd = fileHandle.fileDescriptor
let xpcHandle = FileHandle(fileDescriptor: fd, closeOnDealloc: false)
message.set(key: .fd, value: xpcHandle)

_ = try await client.send(message)
}

}
3 changes: 3 additions & 0 deletions Sources/ContainerClient/Core/XPC+.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ public enum XPCKeys: String {
case volumeLabels
case volumeReadonly
case volumeContainerId
case path

/// Container statistics
case statistics
Expand Down Expand Up @@ -157,6 +158,8 @@ public enum XPCRoute: String {
case volumeDelete
case volumeList
case volumeInspect
case volumeCopyIn
case volumeCopyOut

case volumeDiskUsage
case systemDiskUsage
Expand Down
1 change: 1 addition & 0 deletions Sources/ContainerCommands/Volume/VolumeCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ extension Application {
VolumeList.self,
VolumeInspect.self,
VolumePrune.self,
VolumeCopy.self,
],
aliases: ["v"]
)
Expand Down
163 changes: 163 additions & 0 deletions Sources/ContainerCommands/Volume/VolumeCopy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 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 ContainerClient
import Darwin
import Foundation

extension Application.VolumeCommand {
struct VolumeCopy: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "cp",
abstract: "Copy files/folders between a container volume and the local filesystem"
)

@Argument(help: "Source path (use volume:path for volume paths)")
var source: String

@Argument(help: "Destination path (use volume:path for volume paths)")
var destination: String

@Flag(name: .shortAndLong, help: "Copy recursively")
var recursive: Bool = false

func run() async throws {
let (sourceVol, sourcePath) = parsePath(source)
let (destVol, destPath) = parsePath(destination)

try validatePaths(sourceVol, destVol)

if let volName = sourceVol, let volPath = sourcePath {
try await copyFromVolume(volume: volName, volumePath: volPath, localPath: destination)
} else if let volName = destVol, let volPath = destPath {
try await copyToVolume(volume: volName, volumePath: volPath, localPath: source)
}
}

private func validatePaths(_ sourceVol: String?, _ destVol: String?) throws {
if sourceVol != nil && destVol != nil {
throw ValidationError("copying between volumes is not supported")
}
if sourceVol == nil && destVol == nil {
throw ValidationError("one path must be a volume path (use volume:path format)")
}
}

private func copyFromVolume(volume: String, volumePath: String, localPath: String) async throws {
let localURL = URL(fileURLWithPath: localPath)
let pipe = Pipe()

let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }

let writeFD = dup(pipe.fileHandleForWriting.fileDescriptor)
let writeHandle = FileHandle(fileDescriptor: writeFD, closeOnDealloc: true)

try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
defer { try? pipe.fileHandleForWriting.close() }
try await ClientVolume.copyOut(volume: volume, path: volumePath, fileHandle: writeHandle)
}
group.addTask {
try Archiver.uncompress(from: pipe.fileHandleForReading, to: tempDir)
}
try await group.waitForAll()
}

let contents = try FileManager.default.contentsOfDirectory(at: tempDir, includingPropertiesForKeys: nil)
if contents.count == 1 {
let extracted = contents[0]
var isDir: ObjCBool = false
_ = FileManager.default.fileExists(atPath: extracted.path, isDirectory: &isDir)
if isDir.boolValue && !recursive {
throw ValidationError("source '\(volume):\(volumePath)' is a directory, use -r to copy recursively")
}
if FileManager.default.fileExists(atPath: localPath) {
_ = try FileManager.default.replaceItemAt(localURL, withItemAt: extracted)
} else {
try FileManager.default.moveItem(at: extracted, to: localURL)
}
} else {
if !recursive {
throw ValidationError("source '\(volume):\(volumePath)' is a directory, use -r to copy recursively")
}
try FileManager.default.createDirectory(at: localURL, withIntermediateDirectories: true)
for item in contents {
let dest = localURL.appendingPathComponent(item.lastPathComponent)
try FileManager.default.moveItem(at: item, to: dest)
}
}
}

private func copyToVolume(volume: String, volumePath: String, localPath: String) async throws {
let localURL = URL(fileURLWithPath: localPath)
let pipe = Pipe()

var isDir: ObjCBool = false
if !FileManager.default.fileExists(atPath: localPath, isDirectory: &isDir) {
throw ValidationError("source path does not exist: '\(localPath)'")
}
if isDir.boolValue && !recursive {
throw ValidationError("source is a directory, use -r to copy recursively")
}

let readFD = dup(pipe.fileHandleForReading.fileDescriptor)
let readHandle = FileHandle(fileDescriptor: readFD, closeOnDealloc: true)

let isDirectory = isDir.boolValue

try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
defer { try? pipe.fileHandleForWriting.close() }
try Archiver.compress(source: localURL, to: pipe.fileHandleForWriting) { url in
let relativePath = self.computeRelativePath(source: localURL, current: url, isDirectory: isDirectory)
return Archiver.ArchiveEntryInfo(
pathOnHost: url,
pathInArchive: URL(fileURLWithPath: relativePath)
)
}
}
group.addTask {
try await ClientVolume.copyIn(volume: volume, path: volumePath, fileHandle: readHandle)
}
try await group.waitForAll()
}
}

private func computeRelativePath(source: URL, current: URL, isDirectory: Bool) -> String {
guard source.path != current.path else {
return source.lastPathComponent
}
let components = current.pathComponents
let baseComponents = source.pathComponents
guard components.count > baseComponents.count && components.prefix(baseComponents.count) == baseComponents[...] else {
return current.lastPathComponent
}
let relativePart = components[baseComponents.count...].joined(separator: "/")
return "\(source.lastPathComponent)/\(relativePart)"
}

private func parsePath(_ path: String) -> (String?, String?) {
let parts = path.split(separator: ":", maxSplits: 1)
if parts.count == 2 {
return (String(parts[0]), String(parts[1]))
}
return (nil, nil)
}
}
}
2 changes: 2 additions & 0 deletions Sources/Helpers/APIServer/APIServer+Start.swift
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,8 @@ extension APIServer {
routes[XPCRoute.volumeList] = harness.list
routes[XPCRoute.volumeInspect] = harness.inspect
routes[XPCRoute.volumeDiskUsage] = harness.diskUsage
routes[XPCRoute.volumeCopyIn] = harness.copyIn
routes[XPCRoute.volumeCopyOut] = harness.copyOut

return service
}
Expand Down
34 changes: 34 additions & 0 deletions Sources/Services/ContainerAPIService/Volumes/VolumesHarness.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,38 @@ public struct VolumesHarness: Sendable {
reply.set(key: .volumeSize, value: size)
return reply
}

@Sendable
public func copyIn(_ message: XPCMessage) async throws -> XPCMessage {
guard let name = message.string(key: .volumeName) else {
throw ContainerizationError(.invalidArgument, message: "volume name cannot be empty")
}
guard let path = message.string(key: .path) else {
throw ContainerizationError(.invalidArgument, message: "path cannot be empty")
}
guard let fd = message.fileHandle(key: .fd) else {
throw ContainerizationError(.invalidArgument, message: "file descriptor cannot be empty")
}
defer { try? fd.close() }

try await service.copyIn(name: name, path: path, fileHandle: fd)
return message.reply()
}

@Sendable
public func copyOut(_ message: XPCMessage) async throws -> XPCMessage {
guard let name = message.string(key: .volumeName) else {
throw ContainerizationError(.invalidArgument, message: "volume name cannot be empty")
}
guard let path = message.string(key: .path) else {
throw ContainerizationError(.invalidArgument, message: "path cannot be empty")
}
guard let fd = message.fileHandle(key: .fd) else {
throw ContainerizationError(.invalidArgument, message: "file descriptor cannot be empty")
}
defer { try? fd.close() }

try await service.copyOut(name: name, path: path, fileHandle: fd)
return message.reply()
}
}
36 changes: 36 additions & 0 deletions Sources/Services/ContainerAPIService/Volumes/VolumesService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -284,4 +284,40 @@ public actor VolumesService {
return volume
}

public func copyIn(name: String, path: String, fileHandle: FileHandle) async throws {
try await lock.withLock { _ in
let volume = try await self._inspect(name)
let volumePath = self.volumePath(for: volume.name)
let destination = URL(fileURLWithPath: volumePath).appendingPathComponent(path)
try Archiver.uncompress(from: fileHandle, to: destination)
}
}

public func copyOut(name: String, path: String, fileHandle: FileHandle) async throws {
try await lock.withLock { _ in
let volume = try await self._inspect(name)
let volumePath = self.volumePath(for: volume.name)
let source = URL(fileURLWithPath: volumePath).appendingPathComponent(path)

try Archiver.compress(source: source, to: fileHandle) { url in
let relativePath: String
let sourcePath = source.path
let urlPath = url.path

if sourcePath == urlPath {
relativePath = source.lastPathComponent
} else if urlPath.hasPrefix(sourcePath + "/") {
relativePath = String(urlPath.dropFirst(sourcePath.count + 1))
} else {
relativePath = url.lastPathComponent
}

return Archiver.ArchiveEntryInfo(
pathOnHost: url,
pathInArchive: URL(fileURLWithPath: relativePath)
)
}
}
}

}
Loading