Skip to content

Commit 75fa939

Browse files
Stream tar over a file descriptor for cp -, no host staging
The first pass wrapped the existing path based cp: it shelled out to /usr/bin/tar, unpacked the stdin archive into a host temp directory, then copied the extracted files in, and did the reverse on the way out. That gave up the two things tar streaming is for. Ownership and mode came from whatever the host filesystem allowed rather than from the tar headers, and every entry had to fit the host's path length limits under a temp directory even when its path was valid inside the archive. Hand the descriptor down instead. `cp -` now passes the CLI's stdin or stdout through both XPC hops on a new archiveFd key, using the same file handle passing that dial and logs already rely on, to LinuxContainer's stream based copyIn(archive:)/copyOut(to:). The bytes go straight to the guest over vsock and the guest extracts them as root, so the ownership, mode and symlink targets in the headers are applied verbatim and nothing is unpacked on the host. Path traversal is rejected during extraction, where the archive is actually read, rather than by pre-scanning entry names on the host. Each hop closes the descriptor it owns and duplicates for the outgoing message. Setting a file handle on an XPC message closes the descriptor it is given, and the container lookups on the receiving side can throw before the handle is ever forwarded, which otherwise leaked an fd in the apiserver on every copy to a stopped container. Behavior now follows docker and podman: output is uncompressed tar named relative to the source's parent so the source's basename is the top level entry, for files as well as directories, and input may be uncompressed or gzip, bzip2 or xz compressed. The integration tests assert metadata rather than content: a stream carrying uid, gid and modes the host could not reproduce unprivileged, a traversal entry that must not escape, entry naming on the way out, and a round trip. Requires the stream based copy API from apple/containerization#812; the containerization pin still needs bumping once that lands.
1 parent 7199840 commit 75fa939

10 files changed

Lines changed: 367 additions & 250 deletions

File tree

Sources/ContainerCommands/Container/ContainerCopy.swift

Lines changed: 11 additions & 178 deletions
Original file line numberDiff line numberDiff line change
@@ -62,41 +62,18 @@ extension Application {
6262
let srcRef = try Self.parsePathRef(source)
6363
let dstRef = try Self.parsePathRef(destination)
6464

65-
if destination == "-" {
66-
guard case .container(let id, let path) = srcRef else {
67-
throw ContainerizationError(
68-
.invalidArgument,
69-
message: "when destination is '-', source must be a container reference"
70-
)
71-
}
72-
guard case .local(let localDash) = dstRef, localDash == "-" else {
73-
throw ContainerizationError(
74-
.invalidArgument,
75-
message: "destination '-' is only supported for container-to-host tar streams"
76-
)
77-
}
78-
try await Self.streamTarFromContainer(client: client, id: id, sourcePath: path)
79-
return
80-
}
81-
82-
if source == "-" {
83-
guard case .local(let localDash) = srcRef, localDash == "-" else {
84-
throw ContainerizationError(
85-
.invalidArgument,
86-
message: "source '-' is only supported for host-to-container tar streams"
87-
)
88-
}
89-
guard case .container(let id, let path) = dstRef else {
90-
throw ContainerizationError(
91-
.invalidArgument,
92-
message: "when source is '-', destination must be a container reference"
93-
)
94-
}
95-
try await Self.streamTarToContainer(client: client, id: id, destinationPath: path)
96-
return
97-
}
98-
9965
switch (srcRef, dstRef) {
66+
// `-` is an uncompressed tar stream on stdin/stdout, matching
67+
// `docker cp` and `podman cp`. The descriptor is handed to the
68+
// runtime and forwarded to the guest unmodified: nothing is staged
69+
// on the host, so host permission rules and path length limits do
70+
// not apply, and the ownership and mode recorded in the tar headers
71+
// are what land in the container. Neither case prints, so stdout
72+
// carries only archive bytes.
73+
case (.container(let id, let path), .local("-")):
74+
try await client.copyOut(id: id, source: path, archive: FileHandle.standardOutput)
75+
case (.local("-"), .container(let id, let path)):
76+
try await client.copyIn(id: id, archive: FileHandle.standardInput, destination: path)
10077
case (.container(let id, let path), .local(let localPath)):
10178
let srcPath = FilePath(path)
10279
let destPath = FilePath(URL(fileURLWithPath: localPath, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false))
@@ -151,149 +128,5 @@ extension Application {
151128
message: "one of source or destination must be a container reference (container_id:path)")
152129
}
153130
}
154-
155-
private static func streamTarFromContainer(client: ContainerClient, id: String, sourcePath: String) async throws {
156-
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
157-
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
158-
defer { try? FileManager.default.removeItem(at: tempDir) }
159-
160-
let sourceFilePath = FilePath(sourcePath)
161-
let fallbackName = "copy"
162-
let leafName = sourceFilePath.lastComponent?.string ?? fallbackName
163-
let stagingPath = tempDir.appendingPathComponent(leafName)
164-
165-
try await client.copyOut(id: id, source: sourcePath, destination: stagingPath.path(percentEncoded: false), createParents: true)
166-
167-
var isDirectory: ObjCBool = false
168-
guard FileManager.default.fileExists(atPath: stagingPath.path(percentEncoded: false), isDirectory: &isDirectory) else {
169-
throw ContainerizationError(.internalError, message: "failed to stage container copy source for tar streaming")
170-
}
171-
172-
let tarArgs: [String] = ["-C", tempDir.path(percentEncoded: false), "-cf", "-", leafName]
173-
174-
_ = isDirectory // kept for parity if future behavior diverges by source type
175-
176-
try runTar(args: tarArgs, stdinData: nil, outputToStdout: true)
177-
}
178-
179-
private static func streamTarToContainer(client: ContainerClient, id: String, destinationPath: String) async throws {
180-
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
181-
let extractDir = tempDir.appendingPathComponent("extract")
182-
let archivePath = tempDir.appendingPathComponent("stdin.tar")
183-
try FileManager.default.createDirectory(at: extractDir, withIntermediateDirectories: true)
184-
FileManager.default.createFile(atPath: archivePath.path(percentEncoded: false), contents: nil)
185-
defer { try? FileManager.default.removeItem(at: tempDir) }
186-
187-
let archiveHandle = try FileHandle(forWritingTo: archivePath)
188-
defer { try? archiveHandle.close() }
189-
190-
var totalBytesRead = 0
191-
while let chunk = try FileHandle.standardInput.read(upToCount: 64 * 1024), !chunk.isEmpty {
192-
archiveHandle.write(chunk)
193-
totalBytesRead += chunk.count
194-
}
195-
196-
if totalBytesRead == 0 {
197-
throw ContainerizationError(.invalidArgument, message: "empty tar stream on stdin")
198-
}
199-
200-
let listed = try runTar(args: ["-tf", archivePath.path(percentEncoded: false)], stdinData: nil, outputToStdout: false)
201-
let entries =
202-
listed
203-
.split(separator: "\n", omittingEmptySubsequences: true)
204-
.map(String.init)
205-
206-
guard !entries.isEmpty else {
207-
throw ContainerizationError(.invalidArgument, message: "tar stream has no entries")
208-
}
209-
210-
for entry in entries {
211-
let normalized = entry.trimmingCharacters(in: .whitespacesAndNewlines)
212-
if normalized.isEmpty {
213-
continue
214-
}
215-
if normalized.hasPrefix("/") {
216-
throw ContainerizationError(.invalidArgument, message: "tar stream contains absolute path: \(normalized)")
217-
}
218-
let parts = normalized.split(separator: "/")
219-
if parts.contains("..") {
220-
throw ContainerizationError(.invalidArgument, message: "tar stream contains parent traversal: \(normalized)")
221-
}
222-
}
223-
224-
_ = try runTar(
225-
args: ["-xf", archivePath.path(percentEncoded: false), "-C", extractDir.path(percentEncoded: false)],
226-
stdinData: nil,
227-
outputToStdout: false
228-
)
229-
230-
let topLevelNames = Set(
231-
entries.compactMap { entry -> String? in
232-
let trimmed = entry.trimmingCharacters(in: .whitespacesAndNewlines)
233-
guard !trimmed.isEmpty else { return nil }
234-
return String(trimmed.split(separator: "/", maxSplits: 1).first ?? "")
235-
}
236-
).sorted()
237-
238-
guard !topLevelNames.isEmpty else {
239-
throw ContainerizationError(.invalidArgument, message: "tar stream has no copyable top-level entries")
240-
}
241-
242-
if topLevelNames.count == 1 {
243-
let name = topLevelNames[0]
244-
let src = extractDir.appendingPathComponent(name).path(percentEncoded: false)
245-
try await client.copyIn(id: id, source: src, destination: destinationPath, createParents: true)
246-
return
247-
}
248-
249-
let destinationDirPath = destinationPath.hasSuffix("/") ? destinationPath : destinationPath + "/"
250-
for name in topLevelNames {
251-
let src = extractDir.appendingPathComponent(name).path(percentEncoded: false)
252-
try await client.copyIn(id: id, source: src, destination: destinationDirPath, createParents: true)
253-
}
254-
}
255-
256-
@discardableResult
257-
private static func runTar(args: [String], stdinData: Data?, outputToStdout: Bool) throws -> String {
258-
let process = Process()
259-
process.executableURL = URL(filePath: "/usr/bin/tar")
260-
process.arguments = args
261-
262-
let errPipe = Pipe()
263-
process.standardError = errPipe
264-
if outputToStdout {
265-
process.standardOutput = FileHandle.standardOutput
266-
} else {
267-
process.standardOutput = Pipe()
268-
}
269-
270-
if let stdinData {
271-
let inputPipe = Pipe()
272-
process.standardInput = inputPipe
273-
try process.run()
274-
inputPipe.fileHandleForWriting.write(stdinData)
275-
inputPipe.fileHandleForWriting.closeFile()
276-
} else {
277-
try process.run()
278-
}
279-
280-
process.waitUntilExit()
281-
282-
let stderrData = errPipe.fileHandleForReading.readDataToEndOfFile()
283-
let stderrText = String(decoding: stderrData, as: UTF8.self)
284-
285-
if process.terminationStatus != 0 {
286-
let errorText = stderrText.isEmpty ? "tar failed with status \(process.terminationStatus)" : stderrText
287-
throw ContainerizationError(.internalError, message: errorText)
288-
}
289-
290-
if outputToStdout {
291-
return ""
292-
}
293-
294-
let outPipe = process.standardOutput as? Pipe
295-
let stdoutData = outPipe?.fileHandleForReading.readDataToEndOfFile() ?? Data()
296-
return String(decoding: stdoutData, as: UTF8.self)
297-
}
298131
}
299132
}

Sources/Services/ContainerAPIService/Client/ContainerClient.swift

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,53 @@ public struct ContainerClient: Sendable {
350350
}
351351
}
352352

353+
/// Extract a tar stream read from `archive` into a container directory.
354+
///
355+
/// The stream is handed to the runtime as a file descriptor and forwarded to
356+
/// the guest unmodified, so the ownership and mode recorded in the tar
357+
/// headers are applied verbatim inside the container.
358+
public func copyIn(id: String, archive: FileHandle, destination: String, createParents: Bool = true) async throws {
359+
let request = XPCMessage(route: .containerCopyIn)
360+
request.set(key: .id, value: id)
361+
request.set(key: .destinationPath, value: destination)
362+
request.set(key: .createParents, value: createParents)
363+
request.set(key: .archiveFd, value: Self.duplicate(archive))
364+
365+
do {
366+
try await xpcSend(message: request, timeout: .seconds(300))
367+
} catch {
368+
throw ContainerizationError(
369+
.internalError,
370+
message: "failed to copy tar stream into container \(id)",
371+
cause: error
372+
)
373+
}
374+
}
375+
376+
/// Write a container path to `archive` as a tar stream.
377+
public func copyOut(id: String, source: String, archive: FileHandle) async throws {
378+
let request = XPCMessage(route: .containerCopyOut)
379+
request.set(key: .id, value: id)
380+
request.set(key: .sourcePath, value: source)
381+
request.set(key: .archiveFd, value: Self.duplicate(archive))
382+
383+
do {
384+
try await xpcSend(message: request, timeout: .seconds(300))
385+
} catch {
386+
throw ContainerizationError(
387+
.internalError,
388+
message: "failed to copy tar stream from container \(id)",
389+
cause: error
390+
)
391+
}
392+
}
393+
394+
/// `XPCMessage.set(key:value:)` takes ownership of the descriptor and closes
395+
/// it, so hand it a duplicate and leave the caller's handle intact.
396+
private static func duplicate(_ handle: FileHandle) -> FileHandle {
397+
FileHandle(fileDescriptor: dup(handle.fileDescriptor), closeOnDealloc: false)
398+
}
399+
353400
/// Get resource usage statistics for a container.
354401
public func stats(id: String) async throws -> ContainerStats {
355402
let request = XPCMessage(route: .containerStats)

Sources/Services/ContainerAPIService/Client/XPC+.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,8 @@ public enum XPCKeys: String {
143143
case destinationPath
144144
case fileMode
145145
case createParents
146+
/// FD carrying a tar stream for `cp -`. Replaces the host side of the copy.
147+
case archiveFd
146148
}
147149

148150
public enum XPCRoute: String {

Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -303,20 +303,30 @@ public struct ContainersHarness: Sendable {
303303
message: "id cannot be empty"
304304
)
305305
}
306-
guard let sourcePath = message.string(key: .sourcePath) else {
306+
guard let destinationPath = message.string(key: .destinationPath) else {
307307
throw ContainerizationError(
308308
.invalidArgument,
309-
message: "source path cannot be empty"
309+
message: "destination path cannot be empty"
310310
)
311311
}
312-
guard let destinationPath = message.string(key: .destinationPath) else {
312+
let createParents = message.bool(key: .createParents)
313+
314+
// fileHandle(key:) hands back a duplicate this process owns, so close it
315+
// however the copy ends. The container lookups underneath can throw
316+
// before the descriptor is ever forwarded.
317+
if let archive = message.fileHandle(key: .archiveFd) {
318+
defer { try? archive.close() }
319+
try await service.copyIn(id: id, archive: archive, destination: destinationPath, createParents: createParents)
320+
return message.reply()
321+
}
322+
323+
guard let sourcePath = message.string(key: .sourcePath) else {
313324
throw ContainerizationError(
314325
.invalidArgument,
315-
message: "destination path cannot be empty"
326+
message: "source path cannot be empty"
316327
)
317328
}
318329
let mode = UInt32(message.uint64(key: .fileMode))
319-
let createParents = message.bool(key: .createParents)
320330

321331
try await service.copyIn(id: id, source: sourcePath, destination: destinationPath, mode: mode, createParents: createParents)
322332
return message.reply()
@@ -336,6 +346,13 @@ public struct ContainersHarness: Sendable {
336346
message: "source path cannot be empty"
337347
)
338348
}
349+
350+
if let archive = message.fileHandle(key: .archiveFd) {
351+
defer { try? archive.close() }
352+
try await service.copyOut(id: id, source: sourcePath, archive: archive)
353+
return message.reply()
354+
}
355+
339356
guard let destinationPath = message.string(key: .destinationPath) else {
340357
throw ContainerizationError(
341358
.invalidArgument,

Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,30 @@ public actor ContainersService {
786786
try await client.copyOut(source: source, destination: destination, createParents: createParents)
787787
}
788788

789+
/// Extract a tar stream read from `archive` into a container directory.
790+
public func copyIn(id: String, archive: FileHandle, destination: String, createParents: Bool = true) async throws {
791+
self.log.debug("\(#function)")
792+
793+
let state = try self._getContainerState(id: id)
794+
guard state.snapshot.status == .running else {
795+
throw ContainerizationError(.invalidState, message: "container \(id) is not running")
796+
}
797+
let client = try state.getClient()
798+
try await client.copyIn(archive: archive, destination: destination, createParents: createParents)
799+
}
800+
801+
/// Write a container path to `archive` as a tar stream.
802+
public func copyOut(id: String, source: String, archive: FileHandle) async throws {
803+
self.log.debug("\(#function)")
804+
805+
let state = try self._getContainerState(id: id)
806+
guard state.snapshot.status == .running else {
807+
throw ContainerizationError(.invalidState, message: "container \(id) is not running")
808+
}
809+
let client = try state.getClient()
810+
try await client.copyOut(source: source, archive: archive)
811+
}
812+
789813
/// Get statistics for the container.
790814
public func stats(id: String) async throws -> ContainerStats {
791815
log.debug(

Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,46 @@ extension RuntimeClient {
319319
}
320320
}
321321

322+
/// Extract a tar stream read from `archive` into a container directory.
323+
///
324+
/// `archive` stays owned by the caller; a duplicate is sent over XPC, since
325+
/// setting a file handle on a message closes the descriptor it is given.
326+
public func copyIn(archive: FileHandle, destination: String, createParents: Bool = true) async throws {
327+
let request = XPCMessage(route: RuntimeRoutes.copyIn.rawValue)
328+
request.set(key: RuntimeKeys.destinationPath.rawValue, value: destination)
329+
request.set(key: RuntimeKeys.createParents.rawValue, value: createParents)
330+
request.set(key: RuntimeKeys.archiveFd.rawValue, value: FileHandle(fileDescriptor: dup(archive.fileDescriptor), closeOnDealloc: false))
331+
332+
do {
333+
try await self.client.send(request, responseTimeout: .seconds(300))
334+
} catch {
335+
throw ContainerizationError(
336+
.internalError,
337+
message: "failed to copy tar stream into container \(self.id)",
338+
cause: error
339+
)
340+
}
341+
}
342+
343+
/// Write a container path to `archive` as a tar stream.
344+
///
345+
/// `archive` stays owned by the caller, as in ``copyIn(archive:destination:createParents:)``.
346+
public func copyOut(source: String, archive: FileHandle) async throws {
347+
let request = XPCMessage(route: RuntimeRoutes.copyOut.rawValue)
348+
request.set(key: RuntimeKeys.sourcePath.rawValue, value: source)
349+
request.set(key: RuntimeKeys.archiveFd.rawValue, value: FileHandle(fileDescriptor: dup(archive.fileDescriptor), closeOnDealloc: false))
350+
351+
do {
352+
try await self.client.send(request, responseTimeout: .seconds(300))
353+
} catch {
354+
throw ContainerizationError(
355+
.internalError,
356+
message: "failed to copy tar stream from container \(self.id)",
357+
cause: error
358+
)
359+
}
360+
}
361+
322362
public func statistics() async throws -> ContainerStats {
323363
let request = XPCMessage(route: RuntimeRoutes.statistics.rawValue)
324364

0 commit comments

Comments
 (0)