Skip to content

Commit cb40f5c

Browse files
Fix cp tar stream support for stdin and stdout
1 parent 6089024 commit cb40f5c

3 files changed

Lines changed: 265 additions & 0 deletions

File tree

Sources/ContainerCommands/Container/ContainerCopy.swift

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,40 @@ 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+
6599
switch (srcRef, dstRef) {
66100
case (.container(let id, let path), .local(let localPath)):
67101
let srcPath = FilePath(path)
@@ -117,5 +151,146 @@ extension Application {
117151
message: "one of source or destination must be a container reference (container_id:path)")
118152
}
119153
}
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 = listed
202+
.split(separator: "\n", omittingEmptySubsequences: true)
203+
.map(String.init)
204+
205+
guard !entries.isEmpty else {
206+
throw ContainerizationError(.invalidArgument, message: "tar stream has no entries")
207+
}
208+
209+
for entry in entries {
210+
let normalized = entry.trimmingCharacters(in: .whitespacesAndNewlines)
211+
if normalized.isEmpty {
212+
continue
213+
}
214+
if normalized.hasPrefix("/") {
215+
throw ContainerizationError(.invalidArgument, message: "tar stream contains absolute path: \(normalized)")
216+
}
217+
let parts = normalized.split(separator: "/")
218+
if parts.contains("..") {
219+
throw ContainerizationError(.invalidArgument, message: "tar stream contains parent traversal: \(normalized)")
220+
}
221+
}
222+
223+
_ = try runTar(
224+
args: ["-xf", archivePath.path(percentEncoded: false), "-C", extractDir.path(percentEncoded: false)],
225+
stdinData: nil,
226+
outputToStdout: false
227+
)
228+
229+
let topLevelNames = Set(entries.compactMap { entry -> String? in
230+
let trimmed = entry.trimmingCharacters(in: .whitespacesAndNewlines)
231+
guard !trimmed.isEmpty else { return nil }
232+
return String(trimmed.split(separator: "/", maxSplits: 1).first ?? "")
233+
}).sorted()
234+
235+
guard !topLevelNames.isEmpty else {
236+
throw ContainerizationError(.invalidArgument, message: "tar stream has no copyable top-level entries")
237+
}
238+
239+
if topLevelNames.count == 1 {
240+
let name = topLevelNames[0]
241+
let src = extractDir.appendingPathComponent(name).path(percentEncoded: false)
242+
try await client.copyIn(id: id, source: src, destination: destinationPath, createParents: true)
243+
return
244+
}
245+
246+
let destinationDirPath = destinationPath.hasSuffix("/") ? destinationPath : destinationPath + "/"
247+
for name in topLevelNames {
248+
let src = extractDir.appendingPathComponent(name).path(percentEncoded: false)
249+
try await client.copyIn(id: id, source: src, destination: destinationDirPath, createParents: true)
250+
}
251+
}
252+
253+
@discardableResult
254+
private static func runTar(args: [String], stdinData: Data?, outputToStdout: Bool) throws -> String {
255+
let process = Process()
256+
process.executableURL = URL(filePath: "/usr/bin/tar")
257+
process.arguments = args
258+
259+
let errPipe = Pipe()
260+
process.standardError = errPipe
261+
if outputToStdout {
262+
process.standardOutput = FileHandle.standardOutput
263+
} else {
264+
process.standardOutput = Pipe()
265+
}
266+
267+
if let stdinData {
268+
let inputPipe = Pipe()
269+
process.standardInput = inputPipe
270+
try process.run()
271+
inputPipe.fileHandleForWriting.write(stdinData)
272+
inputPipe.fileHandleForWriting.closeFile()
273+
} else {
274+
try process.run()
275+
}
276+
277+
process.waitUntilExit()
278+
279+
let stderrData = errPipe.fileHandleForReading.readDataToEndOfFile()
280+
let stderrText = String(decoding: stderrData, as: UTF8.self)
281+
282+
if process.terminationStatus != 0 {
283+
let errorText = stderrText.isEmpty ? "tar failed with status \(process.terminationStatus)" : stderrText
284+
throw ContainerizationError(.internalError, message: errorText)
285+
}
286+
287+
if outputToStdout {
288+
return ""
289+
}
290+
291+
let outPipe = process.standardOutput as? Pipe
292+
let stdoutData = outPipe?.fileHandleForReading.readDataToEndOfFile() ?? Data()
293+
return String(decoding: stdoutData, as: UTF8.self)
294+
}
120295
}
121296
}

Tests/IntegrationTests/Containers/TestCLICopyCommand.swift

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,44 @@ import Testing
2020
@Suite
2121
struct TestCLICopyCommand {
2222

23+
private func tarCreate(baseDir: URL, entryName: String) throws -> Data {
24+
let process = Process()
25+
process.executableURL = URL(filePath: "/usr/bin/tar")
26+
process.arguments = ["-C", baseDir.path(percentEncoded: false), "-cf", "-", entryName]
27+
let outPipe = Pipe()
28+
let errPipe = Pipe()
29+
process.standardOutput = outPipe
30+
process.standardError = errPipe
31+
try process.run()
32+
process.waitUntilExit()
33+
let output = outPipe.fileHandleForReading.readDataToEndOfFile()
34+
let error = errPipe.fileHandleForReading.readDataToEndOfFile()
35+
if process.terminationStatus != 0 {
36+
let message = String(decoding: error, as: UTF8.self)
37+
throw CommandError.executionFailed("tar create failed: \(message)")
38+
}
39+
return output
40+
}
41+
42+
private func tarExtract(archiveData: Data, destination: URL) throws {
43+
let process = Process()
44+
process.executableURL = URL(filePath: "/usr/bin/tar")
45+
process.arguments = ["-xf", "-", "-C", destination.path(percentEncoded: false)]
46+
let inPipe = Pipe()
47+
let errPipe = Pipe()
48+
process.standardInput = inPipe
49+
process.standardError = errPipe
50+
try process.run()
51+
inPipe.fileHandleForWriting.write(archiveData)
52+
inPipe.fileHandleForWriting.closeFile()
53+
process.waitUntilExit()
54+
let error = errPipe.fileHandleForReading.readDataToEndOfFile()
55+
if process.terminationStatus != 0 {
56+
let message = String(decoding: error, as: UTF8.self)
57+
throw CommandError.executionFailed("tar extract failed: \(message)")
58+
}
59+
}
60+
2361
// MARK: - Basic host/container copy
2462

2563
@Test func testCopyHostToContainer() async throws {
@@ -64,6 +102,46 @@ struct TestCLICopyCommand {
64102
}
65103
}
66104

105+
@Test func testCopyContainerToStdoutTarStream() async throws {
106+
try await ContainerFixture.with { f in
107+
let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0])
108+
try await f.withContainer(image: image) { name in
109+
try f.doExec(name, cmd: ["sh", "-c", "echo -n 'tar-stream-out' > /tmp/tarout.txt"])
110+
111+
let result = try f.run(["copy", "\(name):/tmp/tarout.txt", "-"])
112+
#expect(result.status == 0)
113+
#expect(!result.outputData.isEmpty)
114+
115+
let extractDir = URL(fileURLWithPath: f.testDir.string).appendingPathComponent("extract-out")
116+
try FileManager.default.createDirectory(at: extractDir, withIntermediateDirectories: true)
117+
try tarExtract(archiveData: result.outputData, destination: extractDir)
118+
119+
let extractedFile = extractDir.appendingPathComponent("tarout.txt")
120+
let extracted = try String(contentsOf: extractedFile, encoding: .utf8)
121+
#expect(extracted == "tar-stream-out")
122+
}
123+
}
124+
}
125+
126+
@Test func testCopyStdinTarStreamToContainer() async throws {
127+
try await ContainerFixture.with { f in
128+
let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0])
129+
try await f.withContainer(image: image) { name in
130+
let payloadRoot = URL(fileURLWithPath: f.testDir.string).appendingPathComponent("payload")
131+
try FileManager.default.createDirectory(at: payloadRoot, withIntermediateDirectories: true)
132+
let payloadFile = payloadRoot.appendingPathComponent("in.txt")
133+
try "tar-stream-in".write(to: payloadFile, atomically: true, encoding: .utf8)
134+
let tarData = try tarCreate(baseDir: URL(fileURLWithPath: f.testDir.string), entryName: "payload")
135+
136+
let result = try f.run(["copy", "-", "\(name):/tmp/"], stdin: tarData)
137+
#expect(result.status == 0)
138+
139+
let content = try f.doExec(name, cmd: ["cat", "/tmp/payload/in.txt"])
140+
#expect(content.trimmingCharacters(in: .whitespacesAndNewlines) == "tar-stream-in")
141+
}
142+
}
143+
}
144+
67145
@Test func testCopyLocalToLocalFails() async throws {
68146
try await ContainerFixture.with { f in
69147
let result = try f.run(["copy", "/tmp/source.txt", "/tmp/dest.txt"])

docs/command-reference.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,11 @@ container stats --format json --no-stream web
485485

486486
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`.
487487

488+
`-` is also supported as a tar stream endpoint:
489+
490+
* `container cp <container_id:/path> -` writes an uncompressed tar stream to stdout
491+
* `container cp - <container_id:/path>` reads an uncompressed tar stream from stdin and extracts it into the destination path
492+
488493
**Usage**
489494

490495
```bash
@@ -500,6 +505,7 @@ container copy [--debug] <source> <destination>
500505

501506
* Local path: `/path/to/file` or `relative/path`
502507
* Container path: `container_id:/path/in/container`
508+
* Tar stream endpoint: `-` (stdin or stdout)
503509

504510
**Examples**
505511

@@ -512,6 +518,12 @@ container cp mycontainer:/var/log/app.log ./logs/
512518

513519
# copy using the full command name
514520
container copy ./data.txt mycontainer:/tmp/
521+
522+
# stream a tar archive out of a container path
523+
container cp mycontainer:/etc - > etc.tar
524+
525+
# stream a tar archive into a container path
526+
container cp - mycontainer:/tmp/ < payload.tar
515527
```
516528

517529
### `container prune`

0 commit comments

Comments
 (0)