@@ -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}
0 commit comments