Skip to content

Commit 37b9999

Browse files
Add container volume cp
Signed-off-by: Karan <karanlokchandani@protonmail.com>
1 parent 9c239aa commit 37b9999

File tree

10 files changed

+472
-0
lines changed

10 files changed

+472
-0
lines changed

Sources/ContainerClient/Archiver.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ public final class Archiver: Sendable {
7878
}
7979
entryInfo.append(info)
8080
}
81+
// Handle empty directories - if no entries were added, add the directory itself
82+
if entryInfo.isEmpty {
83+
if let info = closure(source) {
84+
entryInfo.append(info)
85+
}
86+
}
8187
}
8288

8389
let archiver = try ArchiveWriter(
@@ -98,6 +104,22 @@ public final class Archiver: Sendable {
98104
}
99105
}
100106

107+
public static func compress(
108+
source: URL,
109+
to handle: FileHandle,
110+
followSymlinks: Bool = false,
111+
writerConfiguration: ArchiveWriterConfiguration = ArchiveWriterConfiguration(format: .paxRestricted, filter: .gzip),
112+
closure: (URL) -> ArchiveEntryInfo?
113+
) throws {
114+
let destination = URL(fileURLWithPath: "/dev/fd/\(handle.fileDescriptor)")
115+
try compress(source: source, destination: destination, followSymlinks: followSymlinks, writerConfiguration: writerConfiguration, closure: closure)
116+
}
117+
118+
public static func uncompress(from handle: FileHandle, to destination: URL) throws {
119+
let source = URL(fileURLWithPath: "/dev/fd/\(handle.fileDescriptor)")
120+
try uncompress(source: source, destination: destination)
121+
}
122+
101123
public static func uncompress(source: URL, destination: URL) throws {
102124
let source = source.standardizedFileURL
103125
let destination = destination.standardizedFileURL

Sources/ContainerClient/Core/ClientVolume.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,4 +90,31 @@ public struct ClientVolume {
9090
let size = reply.uint64(key: .volumeSize)
9191
return size
9292
}
93+
94+
public static func copyIn(volume: String, path: String, fileHandle: FileHandle) async throws {
95+
let client = XPCClient(service: serviceIdentifier)
96+
let message = XPCMessage(route: .volumeCopyIn)
97+
message.set(key: .volumeName, value: volume)
98+
message.set(key: .path, value: path)
99+
100+
let fd = fileHandle.fileDescriptor
101+
let xpcHandle = FileHandle(fileDescriptor: fd, closeOnDealloc: false)
102+
message.set(key: .fd, value: xpcHandle)
103+
104+
_ = try await client.send(message)
105+
}
106+
107+
public static func copyOut(volume: String, path: String, fileHandle: FileHandle) async throws {
108+
let client = XPCClient(service: serviceIdentifier)
109+
let message = XPCMessage(route: .volumeCopyOut)
110+
message.set(key: .volumeName, value: volume)
111+
message.set(key: .path, value: path)
112+
113+
let fd = fileHandle.fileDescriptor
114+
let xpcHandle = FileHandle(fileDescriptor: fd, closeOnDealloc: false)
115+
message.set(key: .fd, value: xpcHandle)
116+
117+
_ = try await client.send(message)
118+
}
119+
93120
}

Sources/ContainerClient/Core/XPC+.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ public enum XPCKeys: String {
117117
case volumeLabels
118118
case volumeReadonly
119119
case volumeContainerId
120+
case path
120121

121122
/// Container statistics
122123
case statistics
@@ -157,6 +158,8 @@ public enum XPCRoute: String {
157158
case volumeDelete
158159
case volumeList
159160
case volumeInspect
161+
case volumeCopyIn
162+
case volumeCopyOut
160163

161164
case volumeDiskUsage
162165
case systemDiskUsage

Sources/ContainerCommands/Volume/VolumeCommand.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ extension Application {
2727
VolumeList.self,
2828
VolumeInspect.self,
2929
VolumePrune.self,
30+
VolumeCopy.self,
3031
],
3132
aliases: ["v"]
3233
)
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2025 Apple Inc. and the container project authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
import ArgumentParser
18+
import ContainerClient
19+
import Darwin
20+
import Foundation
21+
22+
extension Application.VolumeCommand {
23+
struct VolumeCopy: AsyncParsableCommand {
24+
static let configuration = CommandConfiguration(
25+
commandName: "cp",
26+
abstract: "Copy files/folders between a container volume and the local filesystem"
27+
)
28+
29+
@Argument(help: "Source path (use volume:path for volume paths)")
30+
var source: String
31+
32+
@Argument(help: "Destination path (use volume:path for volume paths)")
33+
var destination: String
34+
35+
@Flag(name: .shortAndLong, help: "Copy recursively")
36+
var recursive: Bool = false
37+
38+
func run() async throws {
39+
let (sourceVol, sourcePath) = parsePath(source)
40+
let (destVol, destPath) = parsePath(destination)
41+
42+
try validatePaths(sourceVol, destVol)
43+
44+
if let volName = sourceVol, let volPath = sourcePath {
45+
try await copyFromVolume(volume: volName, volumePath: volPath, localPath: destination)
46+
} else if let volName = destVol, let volPath = destPath {
47+
try await copyToVolume(volume: volName, volumePath: volPath, localPath: source)
48+
}
49+
}
50+
51+
private func validatePaths(_ sourceVol: String?, _ destVol: String?) throws {
52+
if sourceVol != nil && destVol != nil {
53+
throw ValidationError("copying between volumes is not supported")
54+
}
55+
if sourceVol == nil && destVol == nil {
56+
throw ValidationError("one path must be a volume path (use volume:path format)")
57+
}
58+
}
59+
60+
private func copyFromVolume(volume: String, volumePath: String, localPath: String) async throws {
61+
let localURL = URL(fileURLWithPath: localPath)
62+
let pipe = Pipe()
63+
64+
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
65+
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
66+
defer { try? FileManager.default.removeItem(at: tempDir) }
67+
68+
let writeFD = dup(pipe.fileHandleForWriting.fileDescriptor)
69+
let writeHandle = FileHandle(fileDescriptor: writeFD, closeOnDealloc: true)
70+
71+
try await withThrowingTaskGroup(of: Void.self) { group in
72+
group.addTask {
73+
defer { try? pipe.fileHandleForWriting.close() }
74+
try await ClientVolume.copyOut(volume: volume, path: volumePath, fileHandle: writeHandle)
75+
}
76+
group.addTask {
77+
try Archiver.uncompress(from: pipe.fileHandleForReading, to: tempDir)
78+
}
79+
try await group.waitForAll()
80+
}
81+
82+
let contents = try FileManager.default.contentsOfDirectory(at: tempDir, includingPropertiesForKeys: nil)
83+
if contents.count == 1 {
84+
let extracted = contents[0]
85+
var isDir: ObjCBool = false
86+
_ = FileManager.default.fileExists(atPath: extracted.path, isDirectory: &isDir)
87+
if isDir.boolValue && !recursive {
88+
throw ValidationError("source '\(volume):\(volumePath)' is a directory, use -r to copy recursively")
89+
}
90+
if FileManager.default.fileExists(atPath: localPath) {
91+
_ = try FileManager.default.replaceItemAt(localURL, withItemAt: extracted)
92+
} else {
93+
try FileManager.default.moveItem(at: extracted, to: localURL)
94+
}
95+
} else {
96+
if !recursive {
97+
throw ValidationError("source '\(volume):\(volumePath)' is a directory, use -r to copy recursively")
98+
}
99+
try FileManager.default.createDirectory(at: localURL, withIntermediateDirectories: true)
100+
for item in contents {
101+
let dest = localURL.appendingPathComponent(item.lastPathComponent)
102+
try FileManager.default.moveItem(at: item, to: dest)
103+
}
104+
}
105+
}
106+
107+
private func copyToVolume(volume: String, volumePath: String, localPath: String) async throws {
108+
let localURL = URL(fileURLWithPath: localPath)
109+
let pipe = Pipe()
110+
111+
var isDir: ObjCBool = false
112+
if !FileManager.default.fileExists(atPath: localPath, isDirectory: &isDir) {
113+
throw ValidationError("source path does not exist: '\(localPath)'")
114+
}
115+
if isDir.boolValue && !recursive {
116+
throw ValidationError("source is a directory, use -r to copy recursively")
117+
}
118+
119+
let readFD = dup(pipe.fileHandleForReading.fileDescriptor)
120+
let readHandle = FileHandle(fileDescriptor: readFD, closeOnDealloc: true)
121+
122+
let isDirectory = isDir.boolValue
123+
124+
try await withThrowingTaskGroup(of: Void.self) { group in
125+
group.addTask {
126+
defer { try? pipe.fileHandleForWriting.close() }
127+
try Archiver.compress(source: localURL, to: pipe.fileHandleForWriting) { url in
128+
let relativePath = self.computeRelativePath(source: localURL, current: url, isDirectory: isDirectory)
129+
return Archiver.ArchiveEntryInfo(
130+
pathOnHost: url,
131+
pathInArchive: URL(fileURLWithPath: relativePath)
132+
)
133+
}
134+
}
135+
group.addTask {
136+
try await ClientVolume.copyIn(volume: volume, path: volumePath, fileHandle: readHandle)
137+
}
138+
try await group.waitForAll()
139+
}
140+
}
141+
142+
private func computeRelativePath(source: URL, current: URL, isDirectory: Bool) -> String {
143+
guard source.path != current.path else {
144+
return source.lastPathComponent
145+
}
146+
let components = current.pathComponents
147+
let baseComponents = source.pathComponents
148+
guard components.count > baseComponents.count && components.prefix(baseComponents.count) == baseComponents[...] else {
149+
return current.lastPathComponent
150+
}
151+
let relativePart = components[baseComponents.count...].joined(separator: "/")
152+
return "\(source.lastPathComponent)/\(relativePart)"
153+
}
154+
155+
private func parsePath(_ path: String) -> (String?, String?) {
156+
let parts = path.split(separator: ":", maxSplits: 1)
157+
if parts.count == 2 {
158+
return (String(parts[0]), String(parts[1]))
159+
}
160+
return (nil, nil)
161+
}
162+
}
163+
}

Sources/Helpers/APIServer/APIServer+Start.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,8 @@ extension APIServer {
272272
routes[XPCRoute.volumeList] = harness.list
273273
routes[XPCRoute.volumeInspect] = harness.inspect
274274
routes[XPCRoute.volumeDiskUsage] = harness.diskUsage
275+
routes[XPCRoute.volumeCopyIn] = harness.copyIn
276+
routes[XPCRoute.volumeCopyOut] = harness.copyOut
275277

276278
return service
277279
}

Sources/Services/ContainerAPIService/Volumes/VolumesHarness.swift

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,38 @@ public struct VolumesHarness: Sendable {
104104
reply.set(key: .volumeSize, value: size)
105105
return reply
106106
}
107+
108+
@Sendable
109+
public func copyIn(_ message: XPCMessage) async throws -> XPCMessage {
110+
guard let name = message.string(key: .volumeName) else {
111+
throw ContainerizationError(.invalidArgument, message: "volume name cannot be empty")
112+
}
113+
guard let path = message.string(key: .path) else {
114+
throw ContainerizationError(.invalidArgument, message: "path cannot be empty")
115+
}
116+
guard let fd = message.fileHandle(key: .fd) else {
117+
throw ContainerizationError(.invalidArgument, message: "file descriptor cannot be empty")
118+
}
119+
defer { try? fd.close() }
120+
121+
try await service.copyIn(name: name, path: path, fileHandle: fd)
122+
return message.reply()
123+
}
124+
125+
@Sendable
126+
public func copyOut(_ message: XPCMessage) async throws -> XPCMessage {
127+
guard let name = message.string(key: .volumeName) else {
128+
throw ContainerizationError(.invalidArgument, message: "volume name cannot be empty")
129+
}
130+
guard let path = message.string(key: .path) else {
131+
throw ContainerizationError(.invalidArgument, message: "path cannot be empty")
132+
}
133+
guard let fd = message.fileHandle(key: .fd) else {
134+
throw ContainerizationError(.invalidArgument, message: "file descriptor cannot be empty")
135+
}
136+
defer { try? fd.close() }
137+
138+
try await service.copyOut(name: name, path: path, fileHandle: fd)
139+
return message.reply()
140+
}
107141
}

Sources/Services/ContainerAPIService/Volumes/VolumesService.swift

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,4 +284,40 @@ public actor VolumesService {
284284
return volume
285285
}
286286

287+
public func copyIn(name: String, path: String, fileHandle: FileHandle) async throws {
288+
try await lock.withLock { _ in
289+
let volume = try await self._inspect(name)
290+
let volumePath = self.volumePath(for: volume.name)
291+
let destination = URL(fileURLWithPath: volumePath).appendingPathComponent(path)
292+
try Archiver.uncompress(from: fileHandle, to: destination)
293+
}
294+
}
295+
296+
public func copyOut(name: String, path: String, fileHandle: FileHandle) async throws {
297+
try await lock.withLock { _ in
298+
let volume = try await self._inspect(name)
299+
let volumePath = self.volumePath(for: volume.name)
300+
let source = URL(fileURLWithPath: volumePath).appendingPathComponent(path)
301+
302+
try Archiver.compress(source: source, to: fileHandle) { url in
303+
let relativePath: String
304+
let sourcePath = source.path
305+
let urlPath = url.path
306+
307+
if sourcePath == urlPath {
308+
relativePath = source.lastPathComponent
309+
} else if urlPath.hasPrefix(sourcePath + "/") {
310+
relativePath = String(urlPath.dropFirst(sourcePath.count + 1))
311+
} else {
312+
relativePath = url.lastPathComponent
313+
}
314+
315+
return Archiver.ArchiveEntryInfo(
316+
pathOnHost: url,
317+
pathInArchive: URL(fileURLWithPath: relativePath)
318+
)
319+
}
320+
}
321+
}
322+
287323
}

0 commit comments

Comments
 (0)