Skip to content

Commit 570a56f

Browse files
Add stream-based copyIn/copyOut for tar stream cp support
Adds file-handle/stream-based copy entrypoints so callers can transfer a tar stream directly to/from a container's filesystem without staging the contents on the host, enabling true `docker cp -` / `podman cp -` parity in downstream CLIs (apple/container#1908). - LinuxContainer.copyIn(archive:to:) splices a tar stream from a file handle straight to the guest over vsock; the guest extracts it in place, honoring the ownership/mode/symlink metadata in the tar headers. - LinuxContainer.copyOut(from:to:) streams the guest-produced tar archive to a file handle; it always archives, even a single regular file, to match `docker cp CONTAINER:/path -`. - ArchiveReader.init(fileHandle:) auto-detects archive format and compression filter, so an externally supplied tar stream (uncompressed or gzip/bzip2/xz) is accepted, not only the internal pax+gzip form. - vminitd copy handlers use the auto-detecting reader for copyIn and honor CopyRequest.is_archive to force single-file archiving on copyOut. No host-side temp staging, so host permission and path-length limits no longer constrain the transfer. Existing path-based copyIn/copyOut behavior is unchanged (the new is_archive-on-copyOut path is opt-in). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 450d44e commit 570a56f

4 files changed

Lines changed: 274 additions & 3 deletions

File tree

Sources/Containerization/LinuxContainer.swift

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1439,6 +1439,179 @@ extension LinuxContainer {
14391439
}
14401440
}
14411441
}
1442+
1443+
/// Copy the contents of a tar stream into the container.
1444+
///
1445+
/// The `archive` file handle is expected to yield a tar stream, either
1446+
/// uncompressed or compressed (gzip/bzip2/xz), matching `docker cp -` /
1447+
/// `podman cp -` input semantics. The bytes are streamed directly to the
1448+
/// guest, which extracts them in place honoring the ownership, mode and
1449+
/// symlink metadata recorded in the tar headers. No intermediate files are
1450+
/// created on the host, so host-side permission and path-length constraints
1451+
/// do not apply.
1452+
///
1453+
/// - Parameters:
1454+
/// - archive: A file handle yielding the tar stream to extract.
1455+
/// - destination: The guest directory to extract the archive into.
1456+
/// - createParents: Create parent directories of `destination` if missing.
1457+
/// - chunkSize: The transfer chunk size in bytes.
1458+
public func copyIn(
1459+
archive: FileHandle,
1460+
to destination: URL,
1461+
createParents: Bool = true,
1462+
chunkSize: Int = defaultCopyChunkSize
1463+
) async throws {
1464+
try await self.state.withLock {
1465+
let state = try $0.startedState("copyIn")
1466+
1467+
let guestPath = URL(filePath: self.root).appending(path: destination.path)
1468+
let port = self.hostVsockPorts.wrappingAdd(1, ordering: .relaxed).oldValue
1469+
let listener = try state.vm.listen(port)
1470+
1471+
try await withThrowingTaskGroup(of: Void.self) { group in
1472+
group.addTask {
1473+
try await state.vm.withAgent { agent in
1474+
guard let vminitd = agent as? Vminitd else {
1475+
throw ContainerizationError(.unsupported, message: "copyIn requires Vminitd agent")
1476+
}
1477+
try await vminitd.copy(
1478+
direction: .copyIn,
1479+
guestPath: guestPath,
1480+
vsockPort: port,
1481+
createParents: createParents,
1482+
isArchive: true
1483+
)
1484+
}
1485+
}
1486+
1487+
group.addTask {
1488+
guard let conn = await listener.first(where: { _ in true }) else {
1489+
throw ContainerizationError(.internalError, message: "copyIn: vsock connection not established")
1490+
}
1491+
try listener.finish()
1492+
1493+
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
1494+
self.copyQueue.async {
1495+
do {
1496+
defer { conn.closeFile() }
1497+
try Self.spliceFd(
1498+
from: archive.fileDescriptor,
1499+
to: conn.fileDescriptor,
1500+
chunkSize: chunkSize,
1501+
label: "copyIn"
1502+
)
1503+
continuation.resume()
1504+
} catch {
1505+
continuation.resume(throwing: error)
1506+
}
1507+
}
1508+
}
1509+
}
1510+
1511+
try await group.waitForAll()
1512+
}
1513+
}
1514+
}
1515+
1516+
/// Stream the contents of a container path as a tar archive to a file handle.
1517+
///
1518+
/// Matches `docker cp CONTAINER:/path -` / `podman cp` output semantics: the
1519+
/// output is always a tar archive, even for a single regular file, and
1520+
/// preserves the ownership and mode recorded in the guest filesystem. The
1521+
/// guest performs the archiving natively and streams the result directly;
1522+
/// no intermediate files are created on the host.
1523+
///
1524+
/// - Parameters:
1525+
/// - source: The guest path (file or directory) to archive.
1526+
/// - archive: A file handle the tar stream is written to.
1527+
/// - chunkSize: The transfer chunk size in bytes.
1528+
public func copyOut(
1529+
from source: URL,
1530+
to archive: FileHandle,
1531+
chunkSize: Int = defaultCopyChunkSize
1532+
) async throws {
1533+
try await self.state.withLock {
1534+
let state = try $0.startedState("copyOut")
1535+
1536+
let guestPath = URL(filePath: self.root).appending(path: source.path)
1537+
let port = self.hostVsockPorts.wrappingAdd(1, ordering: .relaxed).oldValue
1538+
let listener = try state.vm.listen(port)
1539+
1540+
let (metadataStream, metadataCont) = AsyncStream.makeStream(of: Vminitd.CopyMetadata.self)
1541+
1542+
try await withThrowingTaskGroup(of: Void.self) { group in
1543+
group.addTask {
1544+
try await state.vm.withAgent { agent in
1545+
guard let vminitd = agent as? Vminitd else {
1546+
throw ContainerizationError(.unsupported, message: "copyOut requires Vminitd agent")
1547+
}
1548+
try await vminitd.copy(
1549+
direction: .copyOut,
1550+
guestPath: guestPath,
1551+
vsockPort: port,
1552+
isArchive: true,
1553+
onMetadata: { meta in
1554+
metadataCont.yield(meta)
1555+
metadataCont.finish()
1556+
}
1557+
)
1558+
}
1559+
}
1560+
1561+
group.addTask {
1562+
// Wait for the guest to report metadata (and thus that the
1563+
// source exists) before accepting the data connection.
1564+
_ = await metadataStream.first(where: { _ in true })
1565+
1566+
guard let conn = await listener.first(where: { _ in true }) else {
1567+
throw ContainerizationError(.internalError, message: "copyOut: vsock connection not established")
1568+
}
1569+
try listener.finish()
1570+
1571+
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
1572+
self.copyQueue.async {
1573+
do {
1574+
defer { conn.closeFile() }
1575+
try Self.spliceFd(
1576+
from: conn.fileDescriptor,
1577+
to: archive.fileDescriptor,
1578+
chunkSize: chunkSize,
1579+
label: "copyOut"
1580+
)
1581+
continuation.resume()
1582+
} catch {
1583+
continuation.resume(throwing: error)
1584+
}
1585+
}
1586+
}
1587+
}
1588+
1589+
try await group.waitForAll()
1590+
}
1591+
}
1592+
}
1593+
1594+
/// Copy raw bytes from one file descriptor to another until EOF.
1595+
private static func spliceFd(from srcFd: Int32, to dstFd: Int32, chunkSize: Int, label: String) throws {
1596+
var buf = [UInt8](repeating: 0, count: chunkSize)
1597+
while true {
1598+
let n = read(srcFd, &buf, buf.count)
1599+
if n == 0 { break }
1600+
guard n > 0 else {
1601+
throw ContainerizationError(.internalError, message: "\(label): read error: \(String(cString: strerror(errno)))")
1602+
}
1603+
var written = 0
1604+
while written < n {
1605+
let w = buf.withUnsafeBytes { ptr in
1606+
write(dstFd, ptr.baseAddress! + written, n - written)
1607+
}
1608+
guard w > 0 else {
1609+
throw ContainerizationError(.internalError, message: "\(label): vsock write error: \(String(cString: strerror(errno)))")
1610+
}
1611+
written += w
1612+
}
1613+
}
1614+
}
14421615
}
14431616

14441617
extension VirtualMachineInstance {

Sources/ContainerizationArchive/ArchiveReader.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,29 @@ public final class ArchiveReader {
106106
.checkOk(elseThrow: { .unableToOpenArchive($0) })
107107
}
108108

109+
/// Initializes an `ArchiveReader` to read from the provided file descriptor,
110+
/// auto-detecting the archive `Format` and compression `Filter`.
111+
///
112+
/// Use this when the incoming stream's format and compression are not known
113+
/// ahead of time, e.g. a tar stream supplied on stdin that may be
114+
/// uncompressed or compressed (gzip, bzip2, xz, ...), matching
115+
/// `docker cp -` / `podman cp -` input semantics. zstd is not auto-detected
116+
/// from a stream because it requires seeking; use
117+
/// ``init(format:filter:fileHandle:)`` with ``Filter/zstd`` for that case.
118+
public init(fileHandle: FileHandle) throws {
119+
self.underlying = archive_read_new()
120+
self.fileHandle = fileHandle
121+
122+
try archive_read_support_filter_all(underlying)
123+
.checkOk(elseThrow: .failedToDetectFilter)
124+
try archive_read_support_format_all(underlying)
125+
.checkOk(elseThrow: .failedToDetectFormat)
126+
127+
let fd = fileHandle.fileDescriptor
128+
try archive_read_open_fd(underlying, fd, 4096)
129+
.checkOk(elseThrow: { .unableToOpenArchive($0) })
130+
}
131+
109132
/// Initialize the `ArchiveReader` to read from a specified file URL
110133
/// by trying to auto determine the archives `Format` and `Filter`.
111134
public init(file: URL) throws {

Tests/ContainerizationArchiveTests/ArchiveReaderTests.swift

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,66 @@ struct ArchiveReaderTests {
658658
}
659659
}
660660

661+
// MARK: - FileHandle Stream Auto-Detect Tests
662+
663+
/// The stream-based auto-detecting reader must accept an uncompressed tar
664+
/// stream, matching `docker cp -` / `podman cp -` input semantics.
665+
@Test func readUncompressedTarFromFileHandle() throws {
666+
let archiveURL = try createTestArchive(
667+
name: "stream-plain",
668+
entries: [
669+
("dir/", .directory, nil),
670+
("dir/file.txt", .regular("streamed plain"), nil),
671+
])
672+
defer { try? FileManager.default.removeItem(at: archiveURL.deletingLastPathComponent()) }
673+
674+
let extractDir = try createExtractionDirectory(name: "stream-plain")
675+
defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }
676+
677+
let fh = try FileHandle(forReadingFrom: archiveURL)
678+
defer { try? fh.close() }
679+
680+
let reader = try ArchiveReader(fileHandle: fh)
681+
let rejected = try reader.extractContents(to: extractDir)
682+
683+
#expect(rejected.isEmpty)
684+
let content = try String(contentsOf: extractDir.appendingPathComponent("dir/file.txt"), encoding: .utf8)
685+
#expect(content == "streamed plain")
686+
}
687+
688+
/// The stream-based auto-detecting reader must also accept a gzip-compressed
689+
/// tar stream (the format the guest emits internally for directory copies),
690+
/// without being told the filter up front.
691+
@Test func readGzipTarFromFileHandle() throws {
692+
let testDirectory = createTemporaryDirectory(baseName: "ArchiveReaderTests")!
693+
defer { try? FileManager.default.removeItem(at: testDirectory) }
694+
let archiveURL = testDirectory.appendingPathComponent("stream-gzip.tar.gz")
695+
696+
let writer = try ArchiveWriter(configuration: .init(format: .pax, filter: .gzip))
697+
try writer.open(file: archiveURL)
698+
let entry = WriteEntry()
699+
entry.path = "hello.txt"
700+
entry.fileType = .regular
701+
entry.permissions = 0o644
702+
let data = "streamed gzip".data(using: .utf8)!
703+
entry.size = numericCast(data.count)
704+
try writer.writeEntry(entry: entry, data: data)
705+
try writer.finishEncoding()
706+
707+
let extractDir = try createExtractionDirectory(name: "stream-gzip")
708+
defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }
709+
710+
let fh = try FileHandle(forReadingFrom: archiveURL)
711+
defer { try? fh.close() }
712+
713+
let reader = try ArchiveReader(fileHandle: fh)
714+
let rejected = try reader.extractContents(to: extractDir)
715+
716+
#expect(rejected.isEmpty)
717+
let content = try String(contentsOf: extractDir.appendingPathComponent("hello.txt"), encoding: .utf8)
718+
#expect(content == "streamed gzip")
719+
}
720+
661721
// MARK: - Zstd Compression Tests
662722

663723
@Test func readZstdCompressedArchive() throws {

vminitd/Sources/VminitdCore/Server+GRPC.swift

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -535,7 +535,11 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContext.SimpleServ
535535
try FileManager.default.createDirectory(at: destURL, withIntermediateDirectories: true)
536536

537537
let fileHandle = FileHandle(fileDescriptor: sockFd, closeOnDealloc: false)
538-
let reader = try ArchiveReader(format: .pax, filter: .gzip, fileHandle: fileHandle)
538+
// Auto-detect format and compression filter so both the internal
539+
// pax+gzip archives (directory copyIn) and externally supplied tar
540+
// streams (uncompressed or compressed, e.g. `docker cp -`) are
541+
// accepted.
542+
let reader = try ArchiveReader(fileHandle: fileHandle)
539543
return try reader.extractContents(to: destURL)
540544
}
541545

@@ -562,7 +566,10 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContext.SimpleServ
562566
guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) else {
563567
throw RPCError(code: .notFound, message: "copy: path not found '\(path)'")
564568
}
565-
let isArchive = isDirectory.boolValue
569+
// `request.isArchive` forces archive output even for a single regular
570+
// file, matching `docker cp CONTAINER:/path -` which always emits a tar
571+
// stream. Directories are always archived.
572+
let isArchive = isDirectory.boolValue || request.isArchive
566573

567574
// Determine total size for single files.
568575
var totalSize: UInt64 = 0
@@ -593,7 +600,15 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContext.SimpleServ
593600
let fileURL = URL(fileURLWithPath: path)
594601
let writer = try ArchiveWriter(configuration: .init(format: .pax, filter: .gzip))
595602
try writer.open(fileDescriptor: sock.fileDescriptor)
596-
try writer.archiveDirectory(fileURL)
603+
if isDirectory.boolValue {
604+
try writer.archiveDirectory(fileURL)
605+
} else {
606+
// Forced single-file archive: emit one entry named after the
607+
// file's basename, relative to its parent directory.
608+
let filePath = FilePath(path)
609+
let base = filePath.removingLastComponent()
610+
try writer.archive([filePath], base: base)
611+
}
597612
try writer.finishEncoding()
598613
} else {
599614
let srcFd = open(path, O_RDONLY)

0 commit comments

Comments
 (0)