From 715649255153d3cbe8bba005ad5d427a8aa8daea Mon Sep 17 00:00:00 2001 From: Danny Canter Date: Sun, 11 Jan 2026 11:57:32 -0800 Subject: [PATCH 1/4] _ContainerizationTar: Add new native tar reader/writer Swift does not have a native tar reader/writer, and we'd really like to avoid linking more (libarchive) libraries to vminitd if we can get away with it. For copying directories into/out of containers tar is fairly nice as it's a simple way to preserve everything you need to be able to reassemble the directory on the receiving end. Due to this, I decided to write a somewhat simple tar reader/writer solely for this purpose. Unfortunately, there's quite a lot of work to get vminitd/ to compile on macOS without the static linux SDK, so it was making unit testing these additions quite a pain, so for now the new work lives in _ContainerizationTar (where the underscore is trying to denote that this is rather experimental..). This change aims to add a simple tar reader and writer with support pax extended headers (for long file names and > 8 GiB files). Because its intended purpose is in a scenario where we own both the creator and ingestor, the reader does NOT handle every case, but it is good at unarchiving the archives the library has made :) --- Package.resolved | 6 +- Package.swift | 21 +- Sources/ContainerizationTar/TarHeader.swift | 542 ++++++++ Sources/ContainerizationTar/TarPax.swift | 328 +++++ Sources/ContainerizationTar/TarReader.swift | 340 +++++ Sources/ContainerizationTar/TarWriter.swift | 481 +++++++ Tests/ContainerizationTarTests/TarTests.swift | 1236 +++++++++++++++++ vminitd/Package.resolved | 6 +- vminitd/Package.swift | 1 + 9 files changed, 2954 insertions(+), 7 deletions(-) create mode 100644 Sources/ContainerizationTar/TarHeader.swift create mode 100644 Sources/ContainerizationTar/TarPax.swift create mode 100644 Sources/ContainerizationTar/TarReader.swift create mode 100644 Sources/ContainerizationTar/TarWriter.swift create mode 100644 Tests/ContainerizationTarTests/TarTests.swift diff --git a/Package.resolved b/Package.resolved index abb229e3..50a80a06 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "c82be4e21117351bb3f942869ce90d35dcd0dd0223dc1c49ce7a56b52709e836", + "originHash" : "e5fa0e8b0e9dab4b79c924cd2c585e41bb516d58ef0af8f1b3a1d1a4a7d9810d", "pins" : [ { "identity" : "async-http-client", @@ -141,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "34d486b01cd891297ac615e40d5999536a1e138d", - "version" : "2.83.0" + "revision" : "4a9a97111099376854a7f8f0f9f88b9d61f52eff", + "version" : "2.92.2" } }, { diff --git a/Package.swift b/Package.swift index 7006474c..792701dc 100644 --- a/Package.swift +++ b/Package.swift @@ -33,6 +33,7 @@ let package = Package( .library(name: "ContainerizationOS", targets: ["ContainerizationOS"]), .library(name: "ContainerizationExtras", targets: ["ContainerizationExtras"]), .library(name: "ContainerizationArchive", targets: ["ContainerizationArchive"]), + .library(name: "_ContainerizationTar", targets: ["_ContainerizationTar"]), .executable(name: "cctl", targets: ["cctl"]), ], dependencies: [ @@ -42,7 +43,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"), .package(url: "https://github.com/grpc/grpc-swift.git", from: "1.26.0"), .package(url: "https://github.com/apple/swift-protobuf.git", from: "1.29.0"), - .package(url: "https://github.com/apple/swift-nio.git", from: "2.80.0"), + .package(url: "https://github.com/apple/swift-nio.git", from: "2.92.2"), .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.20.1"), .package(url: "https://github.com/apple/swift-system.git", from: "1.4.0"), .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.1.0"), @@ -246,5 +247,23 @@ let package = Package( .target( name: "CShim" ), + .target( + name: "_ContainerizationTar", + dependencies: [ + .product(name: "SystemPackage", package: "swift-system"), + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "_NIOFileSystem", package: "swift-nio"), + ], + path: "Sources/ContainerizationTar" + ), + .testTarget( + name: "ContainerizationTarTests", + dependencies: [ + "_ContainerizationTar", + .product(name: "SystemPackage", package: "swift-system"), + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "_NIOFileSystem", package: "swift-nio"), + ] + ), ] ) diff --git a/Sources/ContainerizationTar/TarHeader.swift b/Sources/ContainerizationTar/TarHeader.swift new file mode 100644 index 00000000..a3f77219 --- /dev/null +++ b/Sources/ContainerizationTar/TarHeader.swift @@ -0,0 +1,542 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +/// TAR archive constants and header structure. +/// +/// TAR header format (POSIX ustar): +/// ``` +/// Offset Size Field +/// 0 100 File name +/// 100 8 File mode (octal) +/// 108 8 Owner UID (octal) +/// 116 8 Owner GID (octal) +/// 124 12 File size (octal) +/// 136 12 Modification time (octal) +/// 148 8 Checksum +/// 156 1 Type flag +/// 157 100 Link name +/// 257 6 Magic ("ustar\0") +/// 263 2 Version ("00") +/// 265 32 Owner user name +/// 297 32 Owner group name +/// 329 8 Device major number +/// 337 8 Device minor number +/// 345 155 Filename prefix +/// 500 12 Padding (zeros) +/// ``` +enum TarConstants { + /// Size of a TAR block in bytes. + static let blockSize = 512 + + /// USTAR magic string. + static let magic: [UInt8] = [0x75, 0x73, 0x74, 0x61, 0x72, 0x00] // "ustar\0" + + /// USTAR version. + static let version: [UInt8] = [0x30, 0x30] // "00" + + /// Maximum file size representable in traditional TAR (11 octal digits). + /// 8,589,934,591 bytes (~8GB) + static let maxTraditionalSize: Int64 = 0o77777777777 + + /// Maximum path length in traditional TAR name field. + static let maxNameLength = 100 + + /// Maximum path length using prefix field. + static let maxPrefixLength = 155 + + /// PAX header name used when writing extended headers. + static let paxHeaderName = "././@PaxHeader" + + /// Maximum size for PAX extended header data (1MB). + static let maxPaxSize = 1024 * 1024 +} + +/// TAR entry type flags. +public enum TarEntryType: UInt8, Sendable { + /// Regular file (or '\0' for old TAR). + case regular = 0x30 // '0' + + /// Hard link. + case hardLink = 0x31 // '1' + + /// Symbolic link. + case symbolicLink = 0x32 // '2' + + /// Character device. + case characterDevice = 0x33 // '3' + + /// Block device. + case blockDevice = 0x34 // '4' + + /// Directory. + case directory = 0x35 // '5' + + /// FIFO (named pipe). + case fifo = 0x36 // '6' + + /// Contiguous file. + case contiguous = 0x37 // '7' + + /// PAX extended header (per-file). + case paxExtended = 0x78 // 'x' + + /// PAX global extended header. + case paxGlobal = 0x67 // 'g' + + /// Null byte (old TAR regular file). + case regularAlt = 0x00 + + /// Whether this entry type represents a regular file. + public var isRegularFile: Bool { + self == .regular || self == .regularAlt + } +} + +/// Header field offsets and sizes. +enum TarHeaderField { + static let nameOffset = 0 + static let nameSize = 100 + + static let modeOffset = 100 + static let modeSize = 8 + + static let uidOffset = 108 + static let uidSize = 8 + + static let gidOffset = 116 + static let gidSize = 8 + + static let sizeOffset = 124 + static let sizeSize = 12 + + static let mtimeOffset = 136 + static let mtimeSize = 12 + + static let checksumOffset = 148 + static let checksumSize = 8 + + static let typeFlagOffset = 156 + static let typeFlagSize = 1 + + static let linkNameOffset = 157 + static let linkNameSize = 100 + + static let magicOffset = 257 + static let magicSize = 6 + + static let versionOffset = 263 + static let versionSize = 2 + + static let unameOffset = 265 + static let unameSize = 32 + + static let gnameOffset = 297 + static let gnameSize = 32 + + static let devMajorOffset = 329 + static let devMajorSize = 8 + + static let devMinorOffset = 337 + static let devMinorSize = 8 + + static let prefixOffset = 345 + static let prefixSize = 155 +} + +/// Represents a parsed TAR header. +public struct TarHeader: Sendable { + /// File path (may come from PAX extended header). + public var path: String + + /// File mode/permissions. + public var mode: UInt32 + + /// Owner user ID. + public var uid: UInt32 + + /// Owner group ID. + public var gid: UInt32 + + /// Content size in bytes. For regular files this is the file data size. + /// For PAX headers this is the size of the metadata records. + public var size: Int64 + + /// Modification time (Unix timestamp). + public var mtime: Int64 + + /// Entry type. + public var entryType: TarEntryType + + /// Link target (for symbolic/hard links). + public var linkName: String + + /// Owner user name. + public var userName: String + + /// Owner group name. + public var groupName: String + + /// Device major number. + public var deviceMajor: UInt32 + + /// Device minor number. + public var deviceMinor: UInt32 + + public init( + path: String, + mode: UInt32 = 0o644, + uid: UInt32 = 0, + gid: UInt32 = 0, + size: Int64 = 0, + mtime: Int64 = 0, + entryType: TarEntryType = .regular, + linkName: String = "", + userName: String = "root", + groupName: String = "root", + deviceMajor: UInt32 = 0, + deviceMinor: UInt32 = 0 + ) { + self.path = path + self.mode = mode + self.uid = uid + self.gid = gid + self.size = size + self.mtime = mtime + self.entryType = entryType + self.linkName = linkName + self.userName = userName + self.groupName = groupName + self.deviceMajor = deviceMajor + self.deviceMinor = deviceMinor + } +} + +// MARK: - Octal String Conversion + +extension TarHeader { + /// Convert an integer to an octal string with the specified width. + /// The string is null-terminated and right-padded with spaces if needed. + static func formatOctal(_ value: Int64, width: Int) -> [UInt8] { + var result = [UInt8](repeating: 0, count: width) + + // Format as octal string (width - 1 digits to leave room for null terminator) + let octalString = String(value, radix: 8) + let paddedString = String(repeating: "0", count: max(0, width - 1 - octalString.count)) + octalString + + // Copy to result buffer + let bytes = Array(paddedString.utf8) + let copyCount = min(bytes.count, width - 1) + for i in 0..) -> Int64 { + // Check for GNU binary extension (high bit set) + if let first = bytes.first, first & 0x80 != 0 { + // Binary format: remaining bytes are big-endian integer + var value: Int64 = 0 + for (index, byte) in bytes.enumerated() { + let b = index == 0 ? byte & 0x7F : byte // Clear high bit on first byte + value = (value << 8) | Int64(b) + } + return value + } + + // Standard octal ASCII format + var value: Int64 = 0 + for byte in bytes { + // Skip leading spaces and stop at null/space terminator + if byte == 0x20 { // space + if value == 0 { continue } // leading space + break // trailing space + } + if byte == 0x00 { break } // null terminator + + // Convert ASCII digit to value + if byte >= 0x30 && byte <= 0x37 { // '0' to '7' + value = value * 8 + Int64(byte - 0x30) + } + } + return value + } + + /// Parse a null-terminated string from a TAR header field. + static func parseString(_ bytes: ArraySlice) -> String { + // Find null terminator or end of slice + var endIndex = bytes.startIndex + for i in bytes.indices { + if bytes[i] == 0 { + break + } + endIndex = i + 1 + } + + let stringBytes = bytes[bytes.startIndex.. [UInt8]? { + var header = [UInt8](repeating: 0, count: TarConstants.blockSize) + + // Determine if we can fit the path in traditional format + let pathBytes = Array(path.utf8) + if pathBytes.count > TarConstants.maxNameLength + TarConstants.maxPrefixLength { + // Path too long even with prefix - need PAX + return nil + } + + // Try to split path into prefix and name + var nameBytes: [UInt8] + var prefixBytes: [UInt8] = [] + + if pathBytes.count <= TarConstants.maxNameLength { + nameBytes = pathBytes + } else { + // Find a slash to split on + guard let splitIndex = Self.findPathSplit(pathBytes) else { + // Can't split - need PAX + return nil + } + prefixBytes = Array(pathBytes[0.. TarConstants.maxTraditionalSize { + return nil + } + + // Name field + for (i, byte) in nameBytes.prefix(TarConstants.maxNameLength).enumerated() { + header[TarHeaderField.nameOffset + i] = byte + } + + // Mode + let modeOctal = Self.formatOctal(Int64(mode), width: TarHeaderField.modeSize) + for (i, byte) in modeOctal.enumerated() { + header[TarHeaderField.modeOffset + i] = byte + } + + // UID + let uidOctal = Self.formatOctal(Int64(uid), width: TarHeaderField.uidSize) + for (i, byte) in uidOctal.enumerated() { + header[TarHeaderField.uidOffset + i] = byte + } + + // GID + let gidOctal = Self.formatOctal(Int64(gid), width: TarHeaderField.gidSize) + for (i, byte) in gidOctal.enumerated() { + header[TarHeaderField.gidOffset + i] = byte + } + + // Size + let sizeOctal = Self.formatOctal(size, width: TarHeaderField.sizeSize) + for (i, byte) in sizeOctal.enumerated() { + header[TarHeaderField.sizeOffset + i] = byte + } + + // Modification time + let mtimeOctal = Self.formatOctal(mtime, width: TarHeaderField.mtimeSize) + for (i, byte) in mtimeOctal.enumerated() { + header[TarHeaderField.mtimeOffset + i] = byte + } + + // Checksum placeholder (spaces for calculation) + for i in 0.. Int? { + // Need to find a '/' such that: + // - prefix (before '/') is <= 155 bytes + // - name (after '/') is <= 100 bytes + let slash = UInt8(ascii: "/") + + for i in stride(from: min(pathBytes.count - 1, TarConstants.maxPrefixLength), through: 0, by: -1) { + if pathBytes[i] == slash { + let remainingLength = pathBytes.count - i - 1 + if remainingLength <= TarConstants.maxNameLength { + return i + } + } + } + return nil + } + + /// Calculate the TAR header checksum. + private func calculateChecksum(_ header: [UInt8]) -> Int { + var sum = 0 + for byte in header { + sum += Int(byte) + } + return sum + } +} + +// MARK: - Header Parsing + +extension TarHeader { + /// Parse a TAR header from a 512-byte block. + static func parse(from block: [UInt8]) -> TarHeader? { + guard block.count >= TarConstants.blockSize else { + return nil + } + + // Check if this is an empty block (end of archive) + if block.allSatisfy({ $0 == 0 }) { + return nil + } + + // Verify checksum + guard verifyChecksum(block) else { + return nil + } + + // Parse name (may need to combine with prefix) + let nameSlice = block[TarHeaderField.nameOffset..<(TarHeaderField.nameOffset + TarHeaderField.nameSize)] + let prefixSlice = block[TarHeaderField.prefixOffset..<(TarHeaderField.prefixOffset + TarHeaderField.prefixSize)] + + let name = parseString(nameSlice) + let prefix = parseString(prefixSlice) + + let path: String + if prefix.isEmpty { + path = name + } else { + path = prefix + "/" + name + } + + // Parse other fields + let modeSlice = block[TarHeaderField.modeOffset..<(TarHeaderField.modeOffset + TarHeaderField.modeSize)] + let uidSlice = block[TarHeaderField.uidOffset..<(TarHeaderField.uidOffset + TarHeaderField.uidSize)] + let gidSlice = block[TarHeaderField.gidOffset..<(TarHeaderField.gidOffset + TarHeaderField.gidSize)] + let sizeSlice = block[TarHeaderField.sizeOffset..<(TarHeaderField.sizeOffset + TarHeaderField.sizeSize)] + let mtimeSlice = block[TarHeaderField.mtimeOffset..<(TarHeaderField.mtimeOffset + TarHeaderField.mtimeSize)] + let linkNameSlice = block[TarHeaderField.linkNameOffset..<(TarHeaderField.linkNameOffset + TarHeaderField.linkNameSize)] + let unameSlice = block[TarHeaderField.unameOffset..<(TarHeaderField.unameOffset + TarHeaderField.unameSize)] + let gnameSlice = block[TarHeaderField.gnameOffset..<(TarHeaderField.gnameOffset + TarHeaderField.gnameSize)] + let devMajorSlice = block[TarHeaderField.devMajorOffset..<(TarHeaderField.devMajorOffset + TarHeaderField.devMajorSize)] + let devMinorSlice = block[TarHeaderField.devMinorOffset..<(TarHeaderField.devMinorOffset + TarHeaderField.devMinorSize)] + + let typeFlag = block[TarHeaderField.typeFlagOffset] + let entryType = TarEntryType(rawValue: typeFlag) ?? .regular + + return TarHeader( + path: path, + mode: UInt32(parseOctal(modeSlice)), + uid: UInt32(parseOctal(uidSlice)), + gid: UInt32(parseOctal(gidSlice)), + size: parseOctal(sizeSlice), + mtime: parseOctal(mtimeSlice), + entryType: entryType, + linkName: parseString(linkNameSlice), + userName: parseString(unameSlice), + groupName: parseString(gnameSlice), + deviceMajor: UInt32(parseOctal(devMajorSlice)), + deviceMinor: UInt32(parseOctal(devMinorSlice)) + ) + } + + /// Verify the checksum of a TAR header block. + private static func verifyChecksum(_ block: [UInt8]) -> Bool { + // Get the stored checksum + let checksumSlice = block[TarHeaderField.checksumOffset..<(TarHeaderField.checksumOffset + TarHeaderField.checksumSize)] + let storedChecksum = parseOctal(checksumSlice) + + // Calculate checksum (treating checksum field as spaces) + var calculatedChecksum = 0 + for (i, byte) in block.enumerated() { + if i >= TarHeaderField.checksumOffset && i < TarHeaderField.checksumOffset + TarHeaderField.checksumSize { + calculatedChecksum += 0x20 // space + } else { + calculatedChecksum += Int(byte) + } + } + + return storedChecksum == Int64(calculatedChecksum) + } +} diff --git a/Sources/ContainerizationTar/TarPax.swift b/Sources/ContainerizationTar/TarPax.swift new file mode 100644 index 00000000..59a17a80 --- /dev/null +++ b/Sources/ContainerizationTar/TarPax.swift @@ -0,0 +1,328 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +/// PAX extended header support for TAR archives. +/// +/// PAX headers allow storing extended metadata that doesn't fit in the +/// traditional TAR header format: +/// - Arbitrary length file paths +/// - File sizes > 8GB +/// - Sub-second timestamps +/// - Large UID/GID values +/// - UTF-8 file names +/// +/// Format: Each record is `LENGTH KEY=VALUE\n` where LENGTH includes itself. +package enum TarPax { + /// Standard PAX keywords. + package enum Keyword { + package static let path = "path" + package static let linkpath = "linkpath" + package static let size = "size" + package static let uid = "uid" + package static let gid = "gid" + package static let uname = "uname" + package static let gname = "gname" + package static let mtime = "mtime" + package static let atime = "atime" + package static let ctime = "ctime" + } + + /// Create a PAX record with the format: "LENGTH KEY=VALUE\n" + /// The length includes the length field itself, which requires iteration to compute. + package static func makeRecord(key: String, value: String) -> [UInt8] { + // Content is: " key=value\n" (note leading space after length) + let content = " \(key)=\(value)\n" + let contentBytes = Array(content.utf8) + + // Calculate the total length including the length field itself. + // This requires iteration because the length field's size affects the total. + var lengthDigits = 1 + var totalLength = contentBytes.count + lengthDigits + + while String(totalLength).count > lengthDigits { + lengthDigits = String(totalLength).count + totalLength = contentBytes.count + lengthDigits + } + + // Build the final record + let lengthString = String(totalLength) + return Array(lengthString.utf8) + contentBytes + } + + /// Parse PAX extended header data into key-value pairs. + package static func parseRecords(_ data: [UInt8]) -> [String: String] { + var result: [String: String] = [:] + var offset = 0 + + while offset < data.count { + // Parse length + var lengthEnd = offset + while lengthEnd < data.count && data[lengthEnd] != 0x20 { // space + lengthEnd += 1 + } + + guard lengthEnd < data.count else { break } + + let lengthBytes = data[offset.. recordStart else { break } + + // Record format is "key=value\n" + let recordBytes = data[recordStart.. TarConstants.maxNameLength + TarConstants.maxPrefixLength { + // Too long even with prefix + return true + } + // Check if there's a valid split point + if TarHeader.findPathSplit(pathBytes) == nil { + // No valid split point, need PAX + return true + } + } + + // Link name too long + if header.linkName.utf8.count > TarHeaderField.linkNameSize { + return true + } + + // File size too large + if header.size > TarConstants.maxTraditionalSize { + return true + } + + // UID/GID too large (max 7 octal digits = 2097151) + if header.uid > 2_097_151 || header.gid > 2_097_151 { + return true + } + + return false + } + + /// Build PAX extended header data for a given header. + package static func buildExtendedData(for header: TarHeader) -> [UInt8] { + var records: [UInt8] = [] + + // Path (always include if PAX is needed, regardless of why) + if header.path.utf8.count > TarConstants.maxNameLength { + records.append(contentsOf: makeRecord(key: Keyword.path, value: header.path)) + } + + // Link path + if header.linkName.utf8.count > TarHeaderField.linkNameSize { + records.append(contentsOf: makeRecord(key: Keyword.linkpath, value: header.linkName)) + } + + // Size + if header.size > TarConstants.maxTraditionalSize { + records.append(contentsOf: makeRecord(key: Keyword.size, value: String(header.size))) + } + + // UID + if header.uid > 2_097_151 { + records.append(contentsOf: makeRecord(key: Keyword.uid, value: String(header.uid))) + } + + // GID + if header.gid > 2_097_151 { + records.append(contentsOf: makeRecord(key: Keyword.gid, value: String(header.gid))) + } + + return records + } + + /// Create a PAX extended header entry. + /// Returns the complete header block(s) including the PAX data. + package static func createPaxEntry(for header: TarHeader) -> [UInt8] { + let paxData = buildExtendedData(for: header) + + guard !paxData.isEmpty else { + return [] + } + + let paxHeader = TarHeader( + path: TarConstants.paxHeaderName, + mode: 0o644, + uid: 0, + gid: 0, + size: Int64(paxData.count), + mtime: header.mtime, + entryType: .paxExtended, + userName: header.userName, + groupName: header.groupName + ) + + let headerBlock: [UInt8] + if let serialized = paxHeader.serialize() { + headerBlock = serialized + } else { + // Fallback: create minimal header manually + headerBlock = createMinimalPaxHeader(size: paxData.count, mtime: header.mtime) + } + + let paddedData = padToBlockBoundary(paxData) + + return headerBlock + paddedData + } + + /// Create a minimal PAX header block when normal serialization fails. + private static func createMinimalPaxHeader(size: Int, mtime: Int64) -> [UInt8] { + var header = [UInt8](repeating: 0, count: TarConstants.blockSize) + + // Name: ././@PaxHeader + let name = Array(TarConstants.paxHeaderName.utf8) + for (i, byte) in name.prefix(TarHeaderField.nameSize).enumerated() { + header[TarHeaderField.nameOffset + i] = byte + } + + // Mode: 0644 + let modeOctal = TarHeader.formatOctal(0o644, width: TarHeaderField.modeSize) + for (i, byte) in modeOctal.enumerated() { + header[TarHeaderField.modeOffset + i] = byte + } + + // UID: 0 + let uidOctal = TarHeader.formatOctal(0, width: TarHeaderField.uidSize) + for (i, byte) in uidOctal.enumerated() { + header[TarHeaderField.uidOffset + i] = byte + } + + // GID: 0 + let gidOctal = TarHeader.formatOctal(0, width: TarHeaderField.gidSize) + for (i, byte) in gidOctal.enumerated() { + header[TarHeaderField.gidOffset + i] = byte + } + + // Size + let sizeOctal = TarHeader.formatOctal(Int64(size), width: TarHeaderField.sizeSize) + for (i, byte) in sizeOctal.enumerated() { + header[TarHeaderField.sizeOffset + i] = byte + } + + // Mtime + let mtimeOctal = TarHeader.formatOctal(mtime, width: TarHeaderField.mtimeSize) + for (i, byte) in mtimeOctal.enumerated() { + header[TarHeaderField.mtimeOffset + i] = byte + } + + // Checksum placeholder (spaces) + for i in 0.. [UInt8] { + let remainder = data.count % TarConstants.blockSize + if remainder == 0 { + return data + } + + let paddingNeeded = TarConstants.blockSize - remainder + return data + [UInt8](repeating: 0, count: paddingNeeded) + } + + /// Apply PAX overrides to a parsed header. + package static func applyOverrides(_ paxData: [String: String], to header: inout TarHeader) { + if let path = paxData[Keyword.path] { + header.path = path + } + + if let linkpath = paxData[Keyword.linkpath] { + header.linkName = linkpath + } + + if let sizeString = paxData[Keyword.size], let size = Int64(sizeString) { + header.size = size + } + + if let uidString = paxData[Keyword.uid], let uid = UInt32(uidString) { + header.uid = uid + } + + if let gidString = paxData[Keyword.gid], let gid = UInt32(gidString) { + header.gid = gid + } + + if let uname = paxData[Keyword.uname] { + header.userName = uname + } + + if let gname = paxData[Keyword.gname] { + header.groupName = gname + } + } +} diff --git a/Sources/ContainerizationTar/TarReader.swift b/Sources/ContainerizationTar/TarReader.swift new file mode 100644 index 00000000..a2dc5f95 --- /dev/null +++ b/Sources/ContainerizationTar/TarReader.swift @@ -0,0 +1,340 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import SystemPackage + +#if canImport(_NIOFileSystem) +import NIOCore +import _NIOFileSystem +#endif + +/// Errors that can occur during TAR reading. +public enum TarReaderError: Error, Sendable { + /// Unexpected end of archive. + case unexpectedEndOfArchive + + /// Invalid header (checksum failed or corrupt). + case invalidHeader + + /// Failed to parse PAX extended data. + case invalidPaxData + + /// PAX data exceeds maximum allowed size. + case paxDataTooLarge(Int) + + /// I/O error during reading. + case ioError(Errno) + + /// Invalid state. + case invalidState(String) + + /// Entry type not supported. + case unsupportedEntryType(UInt8) + + /// Path traversal attempt detected. + case pathTraversal(String) +} + +/// A TAR archive reader with PAX support. +/// +/// Example usage: +/// ```swift +/// let reader = try TarReader(fileDescriptor: fd) +/// +/// let buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: 64 * 1024, alignment: 1) +/// defer { buffer.deallocate() } +/// +/// while let header = try reader.nextHeader() { +/// print("Entry: \(header.path)") +/// +/// if header.entryType.isRegularFile { +/// while reader.contentBytesRemaining > 0 { +/// let bytesRead = try reader.readContent(into: buffer) +/// // Process buffer[0.. TarHeader? { + if endOfArchive { + return nil + } + + // Skip any remaining content/padding from previous entry + try skipRemainingContent() + + while true { + try readExactInternal(into: &internalBuffer, count: TarConstants.blockSize) + + if internalBuffer[0.. TarConstants.maxPaxSize { + throw TarReaderError.paxDataTooLarge(paxSize) + } + if paxSize > internalBuffer.count { + internalBuffer = [UInt8](repeating: 0, count: paxSize) + } + try readExactInternal(into: &internalBuffer, count: paxSize) + paxOverrides = TarPax.parseRecords(Array(internalBuffer[0.. TarConstants.maxPaxSize { + throw TarReaderError.paxDataTooLarge(paxSize) + } + try skipBytes(paxSize) + try skipPadding(for: header.size) + continue + } + + // Apply PAX overrides if any + if !paxOverrides.isEmpty { + TarPax.applyOverrides(paxOverrides, to: &header) + paxOverrides.removeAll() + } + + currentHeader = header + contentBytesRemaining = header.size + + // Calculate padding that will need to be skipped + let remainder = Int(header.size % Int64(TarConstants.blockSize)) + paddingBytesRemaining = remainder == 0 ? 0 : TarConstants.blockSize - remainder + + return header + } + } + + /// Read content from the current entry into the provided buffer. + /// - Parameter buffer: The buffer to read into. Reads up to buffer.count bytes. + /// - Returns: The number of bytes read. Returns 0 when no content remains. + public func readContent(into buffer: UnsafeMutableRawBufferPointer) throws -> Int { + guard currentHeader != nil else { + throw TarReaderError.invalidState("No current entry - call nextHeader() first") + } + + guard contentBytesRemaining > 0, buffer.count > 0 else { + return 0 + } + + let toRead = min(Int(contentBytesRemaining), buffer.count) + var totalRead = 0 + + guard let baseAddress = buffer.baseAddress else { + return 0 + } + + while totalRead < toRead { + let remaining = UnsafeMutableRawBufferPointer( + start: baseAddress.advanced(by: totalRead), + count: toRead - totalRead + ) + let bytesRead = try fileDescriptor.read(into: remaining) + if bytesRead == 0 { + throw TarReaderError.unexpectedEndOfArchive + } + totalRead += bytesRead + } + + contentBytesRemaining -= Int64(totalRead) + + // If we've read all content, skip padding automatically + if contentBytesRemaining == 0 && paddingBytesRemaining > 0 { + try skipBytes(paddingBytesRemaining) + paddingBytesRemaining = 0 + } + + return totalRead + } + + /// Skip the remaining content of the current entry. + /// Call this if you don't need the content and want to move to the next entry. + public func skipRemainingContent() throws { + while contentBytesRemaining > 0 { + let toSkip = min(Int(contentBytesRemaining), internalBuffer.count) + try readExactInternal(into: &internalBuffer, count: toSkip) + contentBytesRemaining -= Int64(toSkip) + } + + if paddingBytesRemaining > 0 { + try skipBytes(paddingBytesRemaining) + paddingBytesRemaining = 0 + } + + currentHeader = nil + } + + /// Copy the current entry's content to a destination file descriptor. + /// - Parameter destination: The file descriptor to write content to. + /// - Throws: `TarReaderError.invalidState` if no current entry exists. + public func readFile(to destination: FileDescriptor) throws { + guard currentHeader != nil else { + throw TarReaderError.invalidState("No current entry - call nextHeader() first") + } + + while contentBytesRemaining > 0 { + let toRead = min(Int(contentBytesRemaining), copyBuffer.count) + let bytesRead = try readContent(into: UnsafeMutableRawBufferPointer(rebasing: copyBuffer[0.. 0 { + let toRead = min(Int(contentBytesRemaining), copyBuffer.count) + let bytesRead = try readContent(into: UnsafeMutableRawBufferPointer(rebasing: copyBuffer[0.. 0 { + let paddingSize = TarConstants.blockSize - remainder + try skipBytes(paddingSize) + } + } + + /// Skip the specified number of bytes. + private func skipBytes(_ count: Int) throws { + var remaining = count + while remaining > 0 { + let toSkip = min(remaining, internalBuffer.count) + try readExactInternal(into: &internalBuffer, count: toSkip) + remaining -= toSkip + } + } +} diff --git a/Sources/ContainerizationTar/TarWriter.swift b/Sources/ContainerizationTar/TarWriter.swift new file mode 100644 index 00000000..2acd6ea4 --- /dev/null +++ b/Sources/ContainerizationTar/TarWriter.swift @@ -0,0 +1,481 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import SystemPackage + +#if canImport(_NIOFileSystem) +import NIOCore +import _NIOFileSystem +#endif + +#if canImport(Musl) +import Musl +#elseif canImport(Glibc) +import Glibc +#elseif canImport(Darwin) +import Darwin +#endif + +/// Errors that can occur during TAR writing. +public enum TarWriterError: Error, Sendable { + /// The path is too long and cannot be represented. + case pathTooLong(String) + + /// Failed to serialize header. + case headerSerializationFailed + + /// File size mismatch - wrote different amount than declared. + case sizeMismatch(expected: Int64, actual: Int64) + + /// I/O error during writing. + case ioError(Int32) + + /// Write returned zero bytes unexpectedly. + case writeZeroBytes + + /// Invalid entry state. + case invalidState(String) +} + +/// A TAR archive writer with PAX support. +/// +/// Example usage: +/// ```swift +/// let writer = try TarWriter(fileDescriptor: fd) +/// +/// // Write a directory +/// try writer.writeDirectory(path: "mydir", mode: 0o755) +/// +/// // Write a file with content from a buffer +/// try writer.beginFile(path: "mydir/hello.txt", size: 13) +/// try buffer.withUnsafeBytes { ptr in +/// try writer.writeContent(ptr) +/// } +/// try writer.finalizeEntry() +/// +/// // Write a symlink +/// try writer.writeSymlink(path: "mydir/link", target: "hello.txt") +/// +/// // Finalize the archive +/// try writer.finalize() +/// ``` +public final class TarWriter { + private let fileDescriptor: FileDescriptor + private let ownsFileDescriptor: Bool + + /// Reusable buffer for streaming file content. + private let copyBuffer: UnsafeMutableRawBufferPointer + + /// Track bytes written for current entry (for size validation). + private var currentEntryBytesWritten: Int64 = 0 + private var currentEntryExpectedSize: Int64 = 0 + private var writingEntryContent = false + + private var finalized = false + + /// Create a TAR writer from a file descriptor. + /// - Parameters: + /// - fileDescriptor: The file descriptor to write to. + /// - ownsFileDescriptor: If true, the writer will close the file descriptor when done. + public init(fileDescriptor: FileDescriptor, ownsFileDescriptor: Bool = false) { + self.fileDescriptor = fileDescriptor + self.ownsFileDescriptor = ownsFileDescriptor + self.copyBuffer = UnsafeMutableRawBufferPointer.allocate(byteCount: 128 * 1024, alignment: 1) + } + + /// Create a TAR writer from a file path. + /// - Parameter path: The path to the TAR file to create. + public convenience init(path: FilePath) throws { + let fd = try FileDescriptor.open( + path, + .writeOnly, + options: [.create, .truncate], + permissions: [.ownerReadWrite, .groupRead, .otherRead] + ) + self.init(fileDescriptor: fd, ownsFileDescriptor: true) + } + + deinit { + copyBuffer.deallocate() + if ownsFileDescriptor { + try? fileDescriptor.close() + } + } + + /// Write a directory entry. + public func writeDirectory( + path: String, + mode: UInt32 = 0o755, + uid: UInt32 = 0, + gid: UInt32 = 0, + mtime: Int64? = nil, + userName: String = "root", + groupName: String = "root" + ) throws { + try ensureNotFinalized() + try ensureNotWritingContent() + + // Ensure path ends with / + var dirPath = path + if !dirPath.hasSuffix("/") { + dirPath += "/" + } + + let header = TarHeader( + path: dirPath, + mode: mode, + uid: uid, + gid: gid, + size: 0, + mtime: mtime ?? currentTimestamp(), + entryType: .directory, + userName: userName, + groupName: groupName + ) + + try writeHeader(header) + } + + /// Write a file entry header, preparing for streaming content. + /// After calling this, use `writeContent` to write the file data, + /// then call `finalizeEntry` when done. + public func beginFile( + path: String, + size: Int64, + mode: UInt32 = 0o644, + uid: UInt32 = 0, + gid: UInt32 = 0, + mtime: Int64? = nil, + userName: String = "root", + groupName: String = "root" + ) throws { + try ensureNotFinalized() + try ensureNotWritingContent() + + let header = TarHeader( + path: path, + mode: mode, + uid: uid, + gid: gid, + size: size, + mtime: mtime ?? currentTimestamp(), + entryType: .regular, + userName: userName, + groupName: groupName + ) + + try writeHeader(header) + + currentEntryExpectedSize = size + currentEntryBytesWritten = 0 + writingEntryContent = true + } + + /// Write content for the current file entry. + /// Must be called after `beginFile` and before `finalizeEntry`. + /// - Parameter buffer: The buffer containing data to write. + public func writeContent(_ buffer: UnsafeRawBufferPointer) throws { + try ensureNotFinalized() + + guard writingEntryContent else { + throw TarWriterError.invalidState("Not currently writing file content") + } + + try writeAll(buffer) + currentEntryBytesWritten += Int64(buffer.count) + } + + /// Finalize the current entry, adding padding if needed. + public func finalizeEntry() throws { + try ensureNotFinalized() + + guard writingEntryContent else { + throw TarWriterError.invalidState("Not currently writing file content") + } + + if currentEntryBytesWritten != currentEntryExpectedSize { + throw TarWriterError.sizeMismatch( + expected: currentEntryExpectedSize, + actual: currentEntryBytesWritten + ) + } + + try writePadding(for: currentEntryBytesWritten) + + writingEntryContent = false + currentEntryBytesWritten = 0 + currentEntryExpectedSize = 0 + } + + /// Write a symbolic link entry. + public func writeSymlink( + path: String, + target: String, + uid: UInt32 = 0, + gid: UInt32 = 0, + mtime: Int64? = nil, + userName: String = "root", + groupName: String = "root" + ) throws { + try ensureNotFinalized() + try ensureNotWritingContent() + + let header = TarHeader( + path: path, + mode: 0o777, + uid: uid, + gid: gid, + size: 0, + mtime: mtime ?? currentTimestamp(), + entryType: .symbolicLink, + linkName: target, + userName: userName, + groupName: groupName + ) + + try writeHeader(header) + } + + /// Write a hard link entry. + public func writeHardLink( + path: String, + target: String, + uid: UInt32 = 0, + gid: UInt32 = 0, + mtime: Int64? = nil, + userName: String = "root", + groupName: String = "root" + ) throws { + try ensureNotFinalized() + try ensureNotWritingContent() + + let header = TarHeader( + path: path, + mode: 0o644, + uid: uid, + gid: gid, + size: 0, + mtime: mtime ?? currentTimestamp(), + entryType: .hardLink, + linkName: target, + userName: userName, + groupName: groupName + ) + + try writeHeader(header) + } + + /// Write a file entry by reading content from a file descriptor. + /// The file size is determined automatically via fstat. + /// - Parameters: + /// - path: The path for the entry in the archive. + /// - source: The file descriptor to read content from. + /// - mode: File mode/permissions (default: 0o644). + /// - uid: Owner user ID (default: 0). + /// - gid: Owner group ID (default: 0). + /// - mtime: Modification time as Unix timestamp (default: current time). + /// - userName: Owner user name (default: "root"). + /// - groupName: Owner group name (default: "root"). + public func writeFile( + path: String, + from source: FileDescriptor, + mode: UInt32 = 0o644, + uid: UInt32 = 0, + gid: UInt32 = 0, + mtime: Int64? = nil, + userName: String = "root", + groupName: String = "root" + ) throws { + try ensureNotFinalized() + try ensureNotWritingContent() + + var statBuf = stat() + guard fstat(source.rawValue, &statBuf) == 0 else { + throw TarWriterError.ioError(errno) + } + let size = Int64(statBuf.st_size) + + let header = TarHeader( + path: path, + mode: mode, + uid: uid, + gid: gid, + size: size, + mtime: mtime ?? currentTimestamp(), + entryType: .regular, + userName: userName, + groupName: groupName + ) + try writeHeader(header) + + var remaining = size + while remaining > 0 { + let toRead = min(Int(remaining), copyBuffer.count) + let readBuffer = UnsafeMutableRawBufferPointer(rebasing: copyBuffer[0.. TarConstants.maxNameLength { + // Truncate path to last 100 chars for fallback + let pathBytes = Array(header.path.utf8) + truncatedHeader.path = String(decoding: pathBytes.suffix(TarConstants.maxNameLength), as: UTF8.self) + } + if header.linkName.utf8.count > TarHeaderField.linkNameSize { + let linkBytes = Array(header.linkName.utf8) + truncatedHeader.linkName = String(decoding: linkBytes.suffix(TarHeaderField.linkNameSize), as: UTF8.self) + } + if header.size > TarConstants.maxTraditionalSize { + truncatedHeader.size = TarConstants.maxTraditionalSize + } + + guard let headerBlock = truncatedHeader.serialize() else { + throw TarWriterError.headerSerializationFailed + } + try headerBlock.withUnsafeBytes { ptr in + try writeAll(ptr) + } + } else { + // No PAX needed, write regular header + guard let headerBlock = header.serialize() else { + throw TarWriterError.headerSerializationFailed + } + try headerBlock.withUnsafeBytes { ptr in + try writeAll(ptr) + } + } + } + + /// Write padding to align to 512-byte boundary. + private func writePadding(for size: Int64) throws { + let remainder = Int(size % Int64(TarConstants.blockSize)) + if remainder > 0 { + let padding = [UInt8](repeating: 0, count: TarConstants.blockSize - remainder) + try padding.withUnsafeBytes { ptr in + try writeAll(ptr) + } + } + } + + /// Write all bytes from the buffer to the file descriptor. + private func writeAll(_ buffer: UnsafeRawBufferPointer) throws { + var totalWritten = 0 + while totalWritten < buffer.count { + let remaining = UnsafeRawBufferPointer(rebasing: buffer[totalWritten...]) + let written = try fileDescriptor.write(remaining) + if written == 0 { + throw TarWriterError.writeZeroBytes + } + totalWritten += written + } + } + + private func ensureNotFinalized() throws { + if finalized { + throw TarWriterError.invalidState("Archive has been finalized") + } + } + + private func ensureNotWritingContent() throws { + if writingEntryContent { + throw TarWriterError.invalidState("Must call finalizeEntry() before writing another entry") + } + } + + private func currentTimestamp() -> Int64 { + var tv = timeval() + gettimeofday(&tv, nil) + return Int64(tv.tv_sec) + } +} diff --git a/Tests/ContainerizationTarTests/TarTests.swift b/Tests/ContainerizationTarTests/TarTests.swift new file mode 100644 index 00000000..fc84ffac --- /dev/null +++ b/Tests/ContainerizationTarTests/TarTests.swift @@ -0,0 +1,1236 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation +import SystemPackage +import Testing + +@testable import _ContainerizationTar + +#if canImport(_NIOFileSystem) +import NIOCore +import _NIOFileSystem +#endif + +// MARK: - TarHeader Tests + +@Suite("TarHeader Tests") +struct TarHeaderTests { + + // MARK: - Octal Encoding Tests + + @Test("Format octal - zero") + func formatOctalZero() { + let result = TarHeader.formatOctal(0, width: 8) + // Should be "0000000\0" + #expect(result.count == 8) + #expect(result[0] == 0x30) // '0' + #expect(result[6] == 0x30) // '0' + #expect(result[7] == 0x00) // null terminator + } + + @Test("Format octal - small number") + func formatOctalSmall() { + let result = TarHeader.formatOctal(0o755, width: 8) + let string = String(decoding: result.dropLast(), as: UTF8.self) + #expect(string == "0000755") + } + + @Test("Format octal - file size") + func formatOctalFileSize() { + let result = TarHeader.formatOctal(1234, width: 12) + // 1234 in octal is 2322 + let string = String(decoding: result.dropLast(), as: UTF8.self) + #expect(string.contains("2322")) + } + + @Test("Format octal - max traditional size") + func formatOctalMaxSize() { + let maxSize: Int64 = 0o77777777777 // ~8GB + let result = TarHeader.formatOctal(maxSize, width: 12) + let string = String(decoding: result.dropLast(), as: UTF8.self) + #expect(string == "77777777777") + } + + // MARK: - Octal Parsing Tests + + @Test("Parse octal - zero") + func parseOctalZero() { + let bytes: [UInt8] = Array("0000000\0".utf8) + let result = TarHeader.parseOctal(bytes[...]) + #expect(result == 0) + } + + @Test("Parse octal - permissions") + func parseOctalPermissions() { + let bytes: [UInt8] = Array("0000755\0".utf8) + let result = TarHeader.parseOctal(bytes[...]) + #expect(result == 0o755) + } + + @Test("Parse octal - with spaces") + func parseOctalWithSpaces() { + let bytes: [UInt8] = Array(" 755 \0".utf8) + let result = TarHeader.parseOctal(bytes[...]) + #expect(result == 0o755) + } + + @Test("Parse octal - file size") + func parseOctalFileSize() { + let bytes: [UInt8] = Array("00000002322\0".utf8) + let result = TarHeader.parseOctal(bytes[...]) + #expect(result == 1234) + } + + // MARK: - String Parsing Tests + + @Test("Parse string - null terminated") + func parseStringNullTerminated() { + var bytes: [UInt8] = Array("hello.txt".utf8) + bytes.append(0) + bytes.append(contentsOf: [0, 0, 0]) // Padding + let result = TarHeader.parseString(bytes[...]) + #expect(result == "hello.txt") + } + + @Test("Parse string - full field") + func parseStringFullField() { + let bytes: [UInt8] = Array("thisisaverylongfilename".utf8) + let result = TarHeader.parseString(bytes[...]) + #expect(result == "thisisaverylongfilename") + } + + // MARK: - Header Serialization Tests + + @Test("Serialize simple header") + func serializeSimpleHeader() throws { + let header = TarHeader( + path: "hello.txt", + mode: 0o644, + uid: 1000, + gid: 1000, + size: 13, + mtime: 1_704_067_200, // 2024-01-01 00:00:00 UTC + entryType: .regular, + userName: "user", + groupName: "group" + ) + + let serialized = try #require(header.serialize()) + #expect(serialized.count == 512) + + // Verify name field + let name = TarHeader.parseString(serialized[0..<100]) + #expect(name == "hello.txt") + + // Verify magic + let magic = Array(serialized[257..<263]) + #expect(magic == TarConstants.magic) + + // Verify version + let version = Array(serialized[263..<265]) + #expect(version == TarConstants.version) + } + + @Test("Serialize directory header") + func serializeDirectoryHeader() throws { + let header = TarHeader( + path: "mydir/", + mode: 0o755, + entryType: .directory + ) + + let serialized = try #require(header.serialize()) + + // Verify type flag + #expect(serialized[156] == TarEntryType.directory.rawValue) + + // Verify name ends with / + let name = TarHeader.parseString(serialized[0..<100]) + #expect(name.hasSuffix("/")) + } + + @Test("Serialize returns nil for long path") + func serializeLongPathReturnsNil() { + // Path longer than 255 bytes (100 name + 155 prefix) + let longPath = String(repeating: "a", count: 300) + let header = TarHeader(path: longPath) + + let serialized = header.serialize() + #expect(serialized == nil) + } + + // MARK: - Header Parsing Tests + + @Test("Parse serialized header roundtrip") + func parseSerializedHeaderRoundtrip() throws { + let original = TarHeader( + path: "test/file.txt", + mode: 0o644, + uid: 1000, + gid: 1000, + size: 12345, + mtime: 1_704_067_200, + entryType: .regular, + userName: "testuser", + groupName: "testgroup" + ) + + let serialized = try #require(original.serialize()) + let parsed = try #require(TarHeader.parse(from: serialized)) + + #expect(parsed.path == original.path) + #expect(parsed.mode == original.mode) + #expect(parsed.uid == original.uid) + #expect(parsed.gid == original.gid) + #expect(parsed.size == original.size) + #expect(parsed.mtime == original.mtime) + #expect(parsed.entryType == original.entryType) + #expect(parsed.userName == original.userName) + #expect(parsed.groupName == original.groupName) + } + + @Test("Parse empty block returns nil") + func parseEmptyBlockReturnsNil() { + let emptyBlock = [UInt8](repeating: 0, count: 512) + let result = TarHeader.parse(from: emptyBlock) + #expect(result == nil) + } + + @Test("Parse corrupted header returns nil") + func parseCorruptedHeaderReturnsNil() throws { + let header = TarHeader(path: "test.txt", size: 100) + var serialized = try #require(header.serialize()) + + // Corrupt the checksum + serialized[148] = 0xFF + serialized[149] = 0xFF + + let result = TarHeader.parse(from: serialized) + #expect(result == nil) + } + + // MARK: - Entry Type Tests + + @Test("Entry type regular file detection") + func entryTypeRegularFile() { + #expect(TarEntryType.regular.isRegularFile) + #expect(TarEntryType.regularAlt.isRegularFile) + #expect(!TarEntryType.directory.isRegularFile) + #expect(!TarEntryType.symbolicLink.isRegularFile) + } +} + +// MARK: - TarPax Tests + +@Suite("TarPax Tests") +struct TarPaxTests { + + @Test("Make PAX record - short value") + func makePaxRecordShort() { + let record = TarPax.makeRecord(key: "path", value: "test.txt") + let string = String(decoding: record, as: UTF8.self) + + // Format: "LENGTH path=test.txt\n" + #expect(string.hasSuffix("\n")) + #expect(string.contains("path=test.txt")) + + // Verify length is correct + let parts = string.split(separator: " ", maxSplits: 1) + let declaredLength = Int(parts[0])! + #expect(declaredLength == record.count) + } + + @Test("Make PAX record - long value") + func makePaxRecordLong() { + let longPath = String(repeating: "a", count: 200) + let record = TarPax.makeRecord(key: "path", value: longPath) + let string = String(decoding: record, as: UTF8.self) + + // Verify length is correct (length field will be 3 digits) + let parts = string.split(separator: " ", maxSplits: 1) + let declaredLength = Int(parts[0])! + #expect(declaredLength == record.count) + } + + @Test("Make PAX record - length crosses digit boundary") + func makePaxRecordLengthCrossesBoundary() { + // Create a value that causes the length to cross from 1 to 2 digits + // "9 k=v\n" = 6 bytes, but if we add one more byte to value... + let record = TarPax.makeRecord(key: "a", value: "bb") + let string = String(decoding: record, as: UTF8.self) + + let parts = string.split(separator: " ", maxSplits: 1) + let declaredLength = Int(parts[0])! + #expect(declaredLength == record.count) + } + + @Test("Parse PAX records - single record") + func parsePaxRecordsSingle() { + let record = TarPax.makeRecord(key: "path", value: "/long/path/to/file.txt") + let parsed = TarPax.parseRecords(record) + + #expect(parsed["path"] == "/long/path/to/file.txt") + } + + @Test("Parse PAX records - multiple records") + func parsePaxRecordsMultiple() { + var data: [UInt8] = [] + data.append(contentsOf: TarPax.makeRecord(key: "path", value: "/some/path")) + data.append(contentsOf: TarPax.makeRecord(key: "size", value: "9999999999")) + data.append(contentsOf: TarPax.makeRecord(key: "uid", value: "65534")) + + let parsed = TarPax.parseRecords(data) + + #expect(parsed["path"] == "/some/path") + #expect(parsed["size"] == "9999999999") + #expect(parsed["uid"] == "65534") + } + + @Test("Requires PAX - short path") + func requiresPaxShortPath() { + let header = TarHeader(path: "short.txt", size: 100) + #expect(!TarPax.requiresPax(header)) + } + + @Test("Requires PAX - long path") + func requiresPaxLongPath() { + let longPath = String(repeating: "a", count: 150) + let header = TarHeader(path: longPath, size: 100) + #expect(TarPax.requiresPax(header)) + } + + @Test("Requires PAX - large size") + func requiresPaxLargeSize() { + let header = TarHeader(path: "file.txt", size: 10_000_000_000) + #expect(TarPax.requiresPax(header)) + } + + @Test("Requires PAX - large UID") + func requiresPaxLargeUid() { + let header = TarHeader(path: "file.txt", uid: 3_000_000) + #expect(TarPax.requiresPax(header)) + } + + @Test("Apply overrides") + func applyOverrides() { + var header = TarHeader( + path: "truncated.txt", + uid: 0, + size: 100 + ) + + let overrides = [ + "path": "/very/long/path/to/file.txt", + "size": "999999999999", + "uid": "65534", + ] + + TarPax.applyOverrides(overrides, to: &header) + + #expect(header.path == "/very/long/path/to/file.txt") + #expect(header.size == 999_999_999_999) + #expect(header.uid == 65534) + } +} + +// MARK: - TarWriter/TarReader Roundtrip Tests + +@Suite("Tar Roundtrip Tests") +struct TarRoundtripTests { + + /// Helper to create a temporary file path. + func temporaryFilePath(name: String = "test.tar") -> FilePath { + let tempDir = FileManager.default.temporaryDirectory.path + let uuid = UUID().uuidString + return FilePath("\(tempDir)/\(uuid)-\(name)") + } + + /// Helper to clean up a temporary file. + func cleanup(_ path: FilePath) { + try? FileManager.default.removeItem(atPath: path.string) + } + + @Test("Write and read single file") + func writeAndReadSingleFile() throws { + let path = temporaryFilePath() + defer { cleanup(path) } + + let content = Array("Hello, World!".utf8) + + do { + let writer = try TarWriter(path: path) + try writer.beginFile(path: "hello.txt", size: Int64(content.count), mode: 0o644) + try content.withUnsafeBytes { ptr in + try writer.writeContent(ptr) + } + try writer.finalizeEntry() + try writer.finalize() + } + + let reader = try TarReader(path: path) + let header = try #require(try reader.nextHeader()) + + try #require(header.path == "hello.txt") + try #require(header.size == Int64(content.count)) + try #require(header.mode == 0o644) + try #require(header.entryType == .regular) + + // Read content + let buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: 1024, alignment: 1) + defer { buffer.deallocate() } + + let bytesRead = try reader.readContent(into: buffer) + try #require(bytesRead == content.count) + + let readContent = Array(UnsafeRawBufferPointer(buffer)[0.. 0 { + let bytesRead = try reader.readContent(into: buffer) + readContent.append(contentsOf: UnsafeRawBufferPointer(buffer)[0.. 0 { + let bytesRead = try reader.readContent(into: buffer) + readData.append(contentsOf: UnsafeRawBufferPointer(buffer)[0.. FilePath { + let tempDir = FileManager.default.temporaryDirectory.path + let uuid = UUID().uuidString + return FilePath("\(tempDir)/\(uuid)-\(name)") + } + + func cleanup(_ path: FilePath) { + try? FileManager.default.removeItem(atPath: path.string) + } + + @Test("Async write file from readable handle") + func asyncWriteFileFromHandle() async throws { + let tarPath = temporaryFilePath() + defer { cleanup(tarPath) } + + let sourceDir = FileManager.default.temporaryDirectory.path + let sourceFile = "\(sourceDir)/\(UUID().uuidString)-source.txt" + defer { try? FileManager.default.removeItem(atPath: sourceFile) } + + let content = "Hello from async source file!\nMultiple lines here.\n" + try content.write(toFile: sourceFile, atomically: true, encoding: .utf8) + + let writer = try TarWriter(path: tarPath) + try await FileSystem.shared.withFileHandle(forReadingAt: FilePath(sourceFile)) { handle in + try await writer.writeFile(path: "async-copied.txt", from: handle, mode: 0o600) + } + try writer.finalize() + + let reader = try TarReader(path: tarPath) + let header = try #require(try reader.nextHeader()) + + #expect(header.path == "async-copied.txt") + #expect(header.size == Int64(content.utf8.count)) + #expect(header.mode == 0o600) + + let buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: 4096, alignment: 1) + defer { buffer.deallocate() } + + var readData = [UInt8]() + while reader.contentBytesRemaining > 0 { + let bytesRead = try reader.readContent(into: buffer) + readData.append(contentsOf: UnsafeRawBufferPointer(buffer)[0.. FilePath { + let tempDir = FileManager.default.temporaryDirectory.path + let uuid = UUID().uuidString + return FilePath("\(tempDir)/\(uuid)-\(name)") + } + + func cleanup(_ path: FilePath) { + try? FileManager.default.removeItem(atPath: path.string) + } + + @Test("Long path triggers PAX header") + func longPathTriggersPax() throws { + let path = temporaryFilePath() + defer { cleanup(path) } + + // Create a path with no valid split point (filename alone is > 100 chars) + let longFilename = String(repeating: "a", count: 120) + ".txt" + let longPath = "dir/" + longFilename + #expect(longPath.utf8.count > 100) + + let content = Array("content".utf8) + + do { + let writer = try TarWriter(path: path) + try writer.beginFile(path: longPath, size: Int64(content.count)) + try content.withUnsafeBytes { try writer.writeContent($0) } + try writer.finalizeEntry() + try writer.finalize() + } + + let reader = try TarReader(path: path) + let header = try #require(try reader.nextHeader()) + + // The full path should be preserved via PAX + #expect(header.path == longPath) + } + + @Test("Very long path with PAX") + func veryLongPathWithPax() throws { + let path = temporaryFilePath() + defer { cleanup(path) } + + // Create a path longer than 255 characters (traditional max with prefix) + let longPath = + String(repeating: "a", count: 50) + "/" + String(repeating: "b", count: 50) + "/" + String(repeating: "c", count: 50) + "/" + String(repeating: "d", count: 50) + "/" + + String(repeating: "e", count: 50) + "/file.txt" + #expect(longPath.utf8.count > 255) + + let content = Array("test".utf8) + + do { + let writer = try TarWriter(path: path) + try writer.beginFile(path: longPath, size: Int64(content.count)) + try content.withUnsafeBytes { try writer.writeContent($0) } + try writer.finalizeEntry() + try writer.finalize() + } + + let reader = try TarReader(path: path) + let header = try #require(try reader.nextHeader()) + + #expect(header.path == longPath) + } + + @Test("UTF-8 path preserved") + func utf8PathPreserved() throws { + let path = temporaryFilePath() + defer { cleanup(path) } + + let unicodePath = "目录/文件.txt" + let content = Array("内容".utf8) + + do { + let writer = try TarWriter(path: path) + try writer.beginFile(path: unicodePath, size: Int64(content.count)) + try content.withUnsafeBytes { try writer.writeContent($0) } + try writer.finalizeEntry() + try writer.finalize() + } + + let reader = try TarReader(path: path) + let header = try #require(try reader.nextHeader()) + + #expect(header.path == unicodePath) + } + + @Test("Long symlink target with PAX") + func longSymlinkTargetWithPax() throws { + let path = temporaryFilePath() + defer { cleanup(path) } + + // Create a symlink target longer than 100 characters + let longTarget = String(repeating: "x", count: 150) + "/target.txt" + + do { + let writer = try TarWriter(path: path) + try writer.writeSymlink(path: "link", target: longTarget) + try writer.finalize() + } + + let reader = try TarReader(path: path) + let header = try #require(try reader.nextHeader()) + + #expect(header.path == "link") + #expect(header.linkName == longTarget) + #expect(header.entryType == .symbolicLink) + } +} + +// MARK: - Error Handling Tests + +@Suite("Tar Error Handling Tests") +struct TarErrorTests { + + @Test("Size mismatch error") + func sizeMismatchError() throws { + let tempPath = FilePath(FileManager.default.temporaryDirectory.path + "/\(UUID().uuidString).tar") + defer { try? FileManager.default.removeItem(atPath: tempPath.string) } + + let writer = try TarWriter(path: tempPath) + try writer.beginFile(path: "file.txt", size: 100) + + // Only write 50 bytes + let smallContent = [UInt8](repeating: 0x41, count: 50) + try smallContent.withUnsafeBytes { try writer.writeContent($0) } + + // Should throw size mismatch + #expect(throws: TarWriterError.self) { + try writer.finalizeEntry() + } + } + + @Test("Write after finalize error") + func writeAfterFinalizeError() throws { + let tempPath = FilePath(FileManager.default.temporaryDirectory.path + "/\(UUID().uuidString).tar") + defer { try? FileManager.default.removeItem(atPath: tempPath.string) } + + let writer = try TarWriter(path: tempPath) + try writer.finalize() + + #expect(throws: TarWriterError.self) { + try writer.writeDirectory(path: "dir") + } + } + + @Test("Reader invalid state error") + func readerInvalidStateError() throws { + let tempPath = FilePath(FileManager.default.temporaryDirectory.path + "/\(UUID().uuidString).tar") + defer { try? FileManager.default.removeItem(atPath: tempPath.string) } + + // Create empty tar + do { + let writer = try TarWriter(path: tempPath) + try writer.finalize() + } + + let reader = try TarReader(path: tempPath) + let buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: 100, alignment: 1) + defer { buffer.deallocate() } + + // Try to read content without calling nextHeader first + #expect(throws: TarReaderError.self) { + _ = try reader.readContent(into: buffer) + } + } +} + +// MARK: - Metadata Preservation Tests + +@Suite("Metadata Preservation Tests") +struct MetadataTests { + + func temporaryFilePath() -> FilePath { + let tempDir = FileManager.default.temporaryDirectory.path + return FilePath("\(tempDir)/\(UUID().uuidString).tar") + } + + func cleanup(_ path: FilePath) { + try? FileManager.default.removeItem(atPath: path.string) + } + + @Test("UID and GID preserved") + func uidGidPreserved() throws { + let path = temporaryFilePath() + defer { cleanup(path) } + + do { + let writer = try TarWriter(path: path) + try writer.beginFile(path: "file.txt", size: 0, uid: 1000, gid: 2000) + try writer.finalizeEntry() + try writer.finalize() + } + + let reader = try TarReader(path: path) + let header = try #require(try reader.nextHeader()) + + #expect(header.uid == 1000) + #expect(header.gid == 2000) + } + + @Test("Mtime preserved") + func mtimePreserved() throws { + let path = temporaryFilePath() + defer { cleanup(path) } + + let mtime: Int64 = 1_704_067_200 // 2024-01-01 00:00:00 UTC + + do { + let writer = try TarWriter(path: path) + try writer.beginFile(path: "file.txt", size: 0, mtime: mtime) + try writer.finalizeEntry() + try writer.finalize() + } + + let reader = try TarReader(path: path) + let header = try #require(try reader.nextHeader()) + + #expect(header.mtime == mtime) + } + + @Test("User and group name preserved") + func userGroupNamePreserved() throws { + let path = temporaryFilePath() + defer { cleanup(path) } + + do { + let writer = try TarWriter(path: path) + try writer.beginFile(path: "file.txt", size: 0, userName: "testuser", groupName: "testgroup") + try writer.finalizeEntry() + try writer.finalize() + } + + let reader = try TarReader(path: path) + let header = try #require(try reader.nextHeader()) + + #expect(header.userName == "testuser") + #expect(header.groupName == "testgroup") + } + + @Test("Different file modes") + func differentFileModes() throws { + let path = temporaryFilePath() + defer { cleanup(path) } + + let modes: [UInt32] = [0o644, 0o755, 0o600, 0o777, 0o400] + + do { + let writer = try TarWriter(path: path) + for (i, mode) in modes.enumerated() { + try writer.beginFile(path: "file\(i).txt", size: 0, mode: mode) + try writer.finalizeEntry() + } + try writer.finalize() + } + + let reader = try TarReader(path: path) + for (i, expectedMode) in modes.enumerated() { + let header = try #require(try reader.nextHeader()) + #expect(header.path == "file\(i).txt") + #expect(header.mode == expectedMode) + } + } +} + +// MARK: - System Tar Interoperability Tests + +@Suite("System Tar Interoperability Tests") +struct SystemTarTests { + + func temporaryDirectory() -> String { + let tempDir = FileManager.default.temporaryDirectory.path + let uuid = UUID().uuidString + let path = "\(tempDir)/\(uuid)" + try? FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true) + return path + } + + func cleanup(_ path: String) { + try? FileManager.default.removeItem(atPath: path) + } + + @Test("Read tar created by system tar") + func readSystemTar() throws { + let workDir = temporaryDirectory() + defer { cleanup(workDir) } + + let sourceDir = "\(workDir)/source" + try FileManager.default.createDirectory(atPath: sourceDir, withIntermediateDirectories: true) + + let file1Content = "Hello from file1" + let file2Content = "Content of file2 with more text" + try file1Content.write(toFile: "\(sourceDir)/file1.txt", atomically: true, encoding: .utf8) + try file2Content.write(toFile: "\(sourceDir)/file2.txt", atomically: true, encoding: .utf8) + try FileManager.default.createDirectory(atPath: "\(sourceDir)/subdir", withIntermediateDirectories: true) + try "nested".write(toFile: "\(sourceDir)/subdir/nested.txt", atomically: true, encoding: .utf8) + + let tarPath = "\(workDir)/test.tar" + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/tar") + process.arguments = ["-cf", tarPath, "-C", sourceDir, "."] + try process.run() + process.waitUntilExit() + #expect(process.terminationStatus == 0) + + let reader = try TarReader(path: FilePath(tarPath)) + let buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: 4096, alignment: 1) + defer { buffer.deallocate() } + + var entries: [String: (TarEntryType, String)] = [:] + + while let header = try reader.nextHeader() { + var content = "" + if header.entryType.isRegularFile && header.size > 0 { + var data = [UInt8]() + while reader.contentBytesRemaining > 0 { + let bytesRead = try reader.readContent(into: buffer) + data.append(contentsOf: UnsafeRawBufferPointer(buffer)[0.. Date: Thu, 26 Feb 2026 21:03:41 +0100 Subject: [PATCH 2/4] Add CopyDirIn and CopyDirOut support with _ContainerizationTar --- Package.swift | 1 + Sources/Containerization/LinuxContainer.swift | 48 +++ .../SandboxContext/SandboxContext.grpc.swift | 213 ++++++++++++++ .../SandboxContext/SandboxContext.pb.swift | 274 ++++++++++++++++++ .../SandboxContext/SandboxContext.proto | 31 +- .../VirtualMachineAgent.swift | 38 +++ Sources/Containerization/Vminitd.swift | 148 ++++++++++ Sources/Integration/ContainerTests.swift | 126 ++++++++ Sources/Integration/Suite.swift | 2 + vminitd/Sources/vminitd/Server+GRPC.swift | 160 ++++++++++ 10 files changed, 1040 insertions(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 94acbf2e..202424e1 100644 --- a/Package.swift +++ b/Package.swift @@ -65,6 +65,7 @@ let package = Package( "ContainerizationOS", "ContainerizationIO", "ContainerizationExtras", + "_ContainerizationTar", .target(name: "ContainerizationEXT4", condition: .when(platforms: [.macOS])), ], exclude: [ diff --git a/Sources/Containerization/LinuxContainer.swift b/Sources/Containerization/LinuxContainer.swift index beeacdfd..ad656633 100644 --- a/Sources/Containerization/LinuxContainer.swift +++ b/Sources/Containerization/LinuxContainer.swift @@ -1089,6 +1089,54 @@ extension LinuxContainer { } } } + + /// Copy a directory from the host into the container. + public func copyDirIn( + from source: URL, + to destination: URL, + createParents: Bool = true, + chunkSize: Int = defaultCopyChunkSize, + progress: ProgressHandler? = nil + ) async throws { + try await self.state.withLock { + let state = try $0.startedState("copyDirIn") + + let guestPath = URL(filePath: self.root).appending(path: destination.path) + try await state.vm.withAgent { agent in + try await agent.copyDirIn( + from: source, + to: guestPath, + createParents: createParents, + chunkSize: chunkSize, + progress: progress + ) + } + } + } + + /// Copy a directory from the container to the host. + public func copyDirOut( + from source: URL, + to destination: URL, + createParents: Bool = true, + chunkSize: Int = defaultCopyChunkSize, + progress: ProgressHandler? = nil + ) async throws { + try await self.state.withLock { + let state = try $0.startedState("copyDirOut") + + let guestPath = URL(filePath: self.root).appending(path: source.path) + try await state.vm.withAgent { agent in + try await agent.copyDirOut( + from: guestPath, + to: destination, + createParents: createParents, + chunkSize: chunkSize, + progress: progress + ) + } + } + } } extension VirtualMachineInstance { diff --git a/Sources/Containerization/SandboxContext/SandboxContext.grpc.swift b/Sources/Containerization/SandboxContext/SandboxContext.grpc.swift index 39b2bd69..801e5d6c 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.grpc.swift +++ b/Sources/Containerization/SandboxContext/SandboxContext.grpc.swift @@ -89,6 +89,16 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextClientProtoc handler: @escaping (Com_Apple_Containerization_Sandbox_V3_CopyOutChunk) -> Void ) -> ServerStreamingCall + func copyDirIn( + callOptions: CallOptions? + ) -> ClientStreamingCall + + func copyDirOut( + _ request: Com_Apple_Containerization_Sandbox_V3_CopyDirOutRequest, + callOptions: CallOptions?, + handler: @escaping (Com_Apple_Containerization_Sandbox_V3_CopyDirOutChunk) -> Void + ) -> ServerStreamingCall + func createProcess( _ request: Com_Apple_Containerization_Sandbox_V3_CreateProcessRequest, callOptions: CallOptions? @@ -386,6 +396,45 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContextClientProtocol { ) } + /// Copy a dir from the host into the guest + /// + /// Callers should use the `send` method on the returned object to send messages + /// to the server. The caller should send an `.end` after the final message has been sent. + /// + /// - Parameters: + /// - callOptions: Call options. + /// - Returns: A `ClientStreamingCall` with futures for the metadata, status and response. + public func copyDirIn( + callOptions: CallOptions? = nil + ) -> ClientStreamingCall { + return self.makeClientStreamingCall( + path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyDirIn.path, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeCopyDirInInterceptors() ?? [] + ) + } + + /// Copy a dir from the guest to the host. + /// + /// - Parameters: + /// - request: Request to send to CopyDirOut. + /// - callOptions: Call options. + /// - handler: A closure called when each response is received from the server. + /// - Returns: A `ServerStreamingCall` with futures for the metadata and status. + public func copyDirOut( + _ request: Com_Apple_Containerization_Sandbox_V3_CopyDirOutRequest, + callOptions: CallOptions? = nil, + handler: @escaping (Com_Apple_Containerization_Sandbox_V3_CopyDirOutChunk) -> Void + ) -> ServerStreamingCall { + return self.makeServerStreamingCall( + path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyDirOut.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeCopyDirOutInterceptors() ?? [], + handler: handler + ) + } + /// Create a new process inside the container. /// /// - Parameters: @@ -829,6 +878,15 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncClientP callOptions: CallOptions? ) -> GRPCAsyncServerStreamingCall + func makeCopyDirInCall( + callOptions: CallOptions? + ) -> GRPCAsyncClientStreamingCall + + func makeCopyDirOutCall( + _ request: Com_Apple_Containerization_Sandbox_V3_CopyDirOutRequest, + callOptions: CallOptions? + ) -> GRPCAsyncServerStreamingCall + func makeCreateProcessCall( _ request: Com_Apple_Containerization_Sandbox_V3_CreateProcessRequest, callOptions: CallOptions? @@ -1060,6 +1118,28 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncClientProtoco ) } + public func makeCopyDirInCall( + callOptions: CallOptions? = nil + ) -> GRPCAsyncClientStreamingCall { + return self.makeAsyncClientStreamingCall( + path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyDirIn.path, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeCopyDirInInterceptors() ?? [] + ) + } + + public func makeCopyDirOutCall( + _ request: Com_Apple_Containerization_Sandbox_V3_CopyDirOutRequest, + callOptions: CallOptions? = nil + ) -> GRPCAsyncServerStreamingCall { + return self.makeAsyncServerStreamingCall( + path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyDirOut.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeCopyDirOutInterceptors() ?? [] + ) + } + public func makeCreateProcessCall( _ request: Com_Apple_Containerization_Sandbox_V3_CreateProcessRequest, callOptions: CallOptions? = nil @@ -1423,6 +1503,42 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncClientProtoco ) } + public func copyDirIn( + _ requests: RequestStream, + callOptions: CallOptions? = nil + ) async throws -> Com_Apple_Containerization_Sandbox_V3_CopyDirInResponse where RequestStream: Sequence, RequestStream.Element == Com_Apple_Containerization_Sandbox_V3_CopyDirInChunk { + return try await self.performAsyncClientStreamingCall( + path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyDirIn.path, + requests: requests, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeCopyDirInInterceptors() ?? [] + ) + } + + public func copyDirIn( + _ requests: RequestStream, + callOptions: CallOptions? = nil + ) async throws -> Com_Apple_Containerization_Sandbox_V3_CopyDirInResponse where RequestStream: AsyncSequence & Sendable, RequestStream.Element == Com_Apple_Containerization_Sandbox_V3_CopyDirInChunk { + return try await self.performAsyncClientStreamingCall( + path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyDirIn.path, + requests: requests, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeCopyDirInInterceptors() ?? [] + ) + } + + public func copyDirOut( + _ request: Com_Apple_Containerization_Sandbox_V3_CopyDirOutRequest, + callOptions: CallOptions? = nil + ) -> GRPCAsyncResponseStream { + return self.performAsyncServerStreamingCall( + path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyDirOut.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeCopyDirOutInterceptors() ?? [] + ) + } + public func createProcess( _ request: Com_Apple_Containerization_Sandbox_V3_CreateProcessRequest, callOptions: CallOptions? = nil @@ -1692,6 +1808,12 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextClientInterc /// - Returns: Interceptors to use when invoking 'copyOut'. func makeCopyOutInterceptors() -> [ClientInterceptor] + /// - Returns: Interceptors to use when invoking 'copyDirIn'. + func makeCopyDirInInterceptors() -> [ClientInterceptor] + + /// - Returns: Interceptors to use when invoking 'copyDirOut'. + func makeCopyDirOutInterceptors() -> [ClientInterceptor] + /// - Returns: Interceptors to use when invoking 'createProcess'. func makeCreateProcessInterceptors() -> [ClientInterceptor] @@ -1763,6 +1885,8 @@ public enum Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata { Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.writeFile, Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyIn, Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyOut, + Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyDirIn, + Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyDirOut, Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.createProcess, Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.deleteProcess, Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.startProcess, @@ -1851,6 +1975,18 @@ public enum Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata { type: GRPCCallType.serverStreaming ) + public static let copyDirIn = GRPCMethodDescriptor( + name: "CopyDirIn", + path: "/com.apple.containerization.sandbox.v3.SandboxContext/CopyDirIn", + type: GRPCCallType.clientStreaming + ) + + public static let copyDirOut = GRPCMethodDescriptor( + name: "CopyDirOut", + path: "/com.apple.containerization.sandbox.v3.SandboxContext/CopyDirOut", + type: GRPCCallType.serverStreaming + ) + public static let createProcess = GRPCMethodDescriptor( name: "CreateProcess", path: "/com.apple.containerization.sandbox.v3.SandboxContext/CreateProcess", @@ -2000,6 +2136,12 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextProvider: Ca /// Copy a file from the guest to the host. func copyOut(request: Com_Apple_Containerization_Sandbox_V3_CopyOutRequest, context: StreamingResponseCallContext) -> EventLoopFuture + /// Copy a dir from the host into the guest + func copyDirIn(context: UnaryResponseCallContext) -> EventLoopFuture<(StreamEvent) -> Void> + + /// Copy a dir from the guest to the host. + func copyDirOut(request: Com_Apple_Containerization_Sandbox_V3_CopyDirOutRequest, context: StreamingResponseCallContext) -> EventLoopFuture + /// Create a new process inside the container. func createProcess(request: Com_Apple_Containerization_Sandbox_V3_CreateProcessRequest, context: StatusOnlyCallContext) -> EventLoopFuture @@ -2167,6 +2309,24 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContextProvider { userFunction: self.copyOut(request:context:) ) + case "CopyDirIn": + return ClientStreamingServerHandler( + context: context, + requestDeserializer: ProtobufDeserializer(), + responseSerializer: ProtobufSerializer(), + interceptors: self.interceptors?.makeCopyDirInInterceptors() ?? [], + observerFactory: self.copyDirIn(context:) + ) + + case "CopyDirOut": + return ServerStreamingServerHandler( + context: context, + requestDeserializer: ProtobufDeserializer(), + responseSerializer: ProtobufSerializer(), + interceptors: self.interceptors?.makeCopyDirOutInterceptors() ?? [], + userFunction: self.copyDirOut(request:context:) + ) + case "CreateProcess": return UnaryServerHandler( context: context, @@ -2410,6 +2570,19 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncProvide context: GRPCAsyncServerCallContext ) async throws + /// Copy a dir from the host into the guest + func copyDirIn( + requestStream: GRPCAsyncRequestStream, + context: GRPCAsyncServerCallContext + ) async throws -> Com_Apple_Containerization_Sandbox_V3_CopyDirInResponse + + /// Copy a dir from the guest to the host. + func copyDirOut( + request: Com_Apple_Containerization_Sandbox_V3_CopyDirOutRequest, + responseStream: GRPCAsyncResponseStreamWriter, + context: GRPCAsyncServerCallContext + ) async throws + /// Create a new process inside the container. func createProcess( request: Com_Apple_Containerization_Sandbox_V3_CreateProcessRequest, @@ -2638,6 +2811,24 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncProvider { wrapping: { try await self.copyOut(request: $0, responseStream: $1, context: $2) } ) + case "CopyDirIn": + return GRPCAsyncServerHandler( + context: context, + requestDeserializer: ProtobufDeserializer(), + responseSerializer: ProtobufSerializer(), + interceptors: self.interceptors?.makeCopyDirInInterceptors() ?? [], + wrapping: { try await self.copyDirIn(requestStream: $0, context: $1) } + ) + + case "CopyDirOut": + return GRPCAsyncServerHandler( + context: context, + requestDeserializer: ProtobufDeserializer(), + responseSerializer: ProtobufSerializer(), + interceptors: self.interceptors?.makeCopyDirOutInterceptors() ?? [], + wrapping: { try await self.copyDirOut(request: $0, responseStream: $1, context: $2) } + ) + case "CreateProcess": return GRPCAsyncServerHandler( context: context, @@ -2852,6 +3043,14 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextServerInterc /// Defaults to calling `self.makeInterceptors()`. func makeCopyOutInterceptors() -> [ServerInterceptor] + /// - Returns: Interceptors to use when handling 'copyDirIn'. + /// Defaults to calling `self.makeInterceptors()`. + func makeCopyDirInInterceptors() -> [ServerInterceptor] + + /// - Returns: Interceptors to use when handling 'copyDirOut'. + /// Defaults to calling `self.makeInterceptors()`. + func makeCopyDirOutInterceptors() -> [ServerInterceptor] + /// - Returns: Interceptors to use when handling 'createProcess'. /// Defaults to calling `self.makeInterceptors()`. func makeCreateProcessInterceptors() -> [ServerInterceptor] @@ -2941,6 +3140,8 @@ public enum Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata { Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.writeFile, Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.copyIn, Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.copyOut, + Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.copyDirIn, + Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.copyDirOut, Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.createProcess, Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.deleteProcess, Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.startProcess, @@ -3029,6 +3230,18 @@ public enum Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata { type: GRPCCallType.serverStreaming ) + public static let copyDirIn = GRPCMethodDescriptor( + name: "CopyDirIn", + path: "/com.apple.containerization.sandbox.v3.SandboxContext/CopyDirIn", + type: GRPCCallType.clientStreaming + ) + + public static let copyDirOut = GRPCMethodDescriptor( + name: "CopyDirOut", + path: "/com.apple.containerization.sandbox.v3.SandboxContext/CopyDirOut", + type: GRPCCallType.serverStreaming + ) + public static let createProcess = GRPCMethodDescriptor( name: "CreateProcess", path: "/com.apple.containerization.sandbox.v3.SandboxContext/CreateProcess", diff --git a/Sources/Containerization/SandboxContext/SandboxContext.pb.swift b/Sources/Containerization/SandboxContext/SandboxContext.pb.swift index 600331a0..63e00b42 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.pb.swift +++ b/Sources/Containerization/SandboxContext/SandboxContext.pb.swift @@ -942,6 +942,94 @@ public struct Com_Apple_Containerization_Sandbox_V3_CopyOutInit: Sendable { public init() {} } +public struct Com_Apple_Containerization_Sandbox_V3_CopyDirInChunk: @unchecked Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var content: Com_Apple_Containerization_Sandbox_V3_CopyDirInChunk.OneOf_Content? = nil + + /// Initialization message (must be first). + public var init_p: Com_Apple_Containerization_Sandbox_V3_CopyDirInInit { + get { + if case .init_p(let v)? = content {return v} + return Com_Apple_Containerization_Sandbox_V3_CopyDirInInit() + } + set {content = .init_p(newValue)} + } + + /// File adta chunk + public var data: Data { + get { + if case .data(let v)? = content {return v} + return Data() + } + set {content = .data(newValue)} + } + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public enum OneOf_Content: Equatable, @unchecked Sendable { + /// Initialization message (must be first). + case init_p(Com_Apple_Containerization_Sandbox_V3_CopyDirInInit) + /// File adta chunk + case data(Data) + + } + + public init() {} +} + +public struct Com_Apple_Containerization_Sandbox_V3_CopyDirInInit: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// Destination path in the guest. + public var path: String = String() + + /// Create parent directories if they don't exist. + public var createParents: Bool = false + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Com_Apple_Containerization_Sandbox_V3_CopyDirInResponse: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Com_Apple_Containerization_Sandbox_V3_CopyDirOutRequest: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var path: String = String() + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Com_Apple_Containerization_Sandbox_V3_CopyDirOutChunk: @unchecked Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var data: Data = Data() + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + public struct Com_Apple_Containerization_Sandbox_V3_IpLinkSetRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for @@ -3065,6 +3153,192 @@ extension Com_Apple_Containerization_Sandbox_V3_CopyOutInit: SwiftProtobuf.Messa } } +extension Com_Apple_Containerization_Sandbox_V3_CopyDirInChunk: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".CopyDirInChunk" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "init"), + 2: .same(proto: "data"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { + var v: Com_Apple_Containerization_Sandbox_V3_CopyDirInInit? + var hadOneofValue = false + if let current = self.content { + hadOneofValue = true + if case .init_p(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.content = .init_p(v) + } + }() + case 2: try { + var v: Data? + try decoder.decodeSingularBytesField(value: &v) + if let v = v { + if self.content != nil {try decoder.handleConflictingOneOf()} + self.content = .data(v) + } + }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + switch self.content { + case .init_p?: try { + guard case .init_p(let v)? = self.content else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 1) + }() + case .data?: try { + guard case .data(let v)? = self.content else { preconditionFailure() } + try visitor.visitSingularBytesField(value: v, fieldNumber: 2) + }() + case nil: break + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_CopyDirInChunk, rhs: Com_Apple_Containerization_Sandbox_V3_CopyDirInChunk) -> Bool { + if lhs.content != rhs.content {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Com_Apple_Containerization_Sandbox_V3_CopyDirInInit: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".CopyDirInInit" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "path"), + 2: .standard(proto: "create_parents"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.path) }() + case 2: try { try decoder.decodeSingularBoolField(value: &self.createParents) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.path.isEmpty { + try visitor.visitSingularStringField(value: self.path, fieldNumber: 1) + } + if self.createParents != false { + try visitor.visitSingularBoolField(value: self.createParents, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_CopyDirInInit, rhs: Com_Apple_Containerization_Sandbox_V3_CopyDirInInit) -> Bool { + if lhs.path != rhs.path {return false} + if lhs.createParents != rhs.createParents {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Com_Apple_Containerization_Sandbox_V3_CopyDirInResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".CopyDirInResponse" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + public mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + public func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_CopyDirInResponse, rhs: Com_Apple_Containerization_Sandbox_V3_CopyDirInResponse) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Com_Apple_Containerization_Sandbox_V3_CopyDirOutRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".CopyDirOutRequest" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "path"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.path) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.path.isEmpty { + try visitor.visitSingularStringField(value: self.path, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_CopyDirOutRequest, rhs: Com_Apple_Containerization_Sandbox_V3_CopyDirOutRequest) -> Bool { + if lhs.path != rhs.path {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Com_Apple_Containerization_Sandbox_V3_CopyDirOutChunk: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".CopyDirOutChunk" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "data"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularBytesField(value: &self.data) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.data.isEmpty { + try visitor.visitSingularBytesField(value: self.data, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_CopyDirOutChunk, rhs: Com_Apple_Containerization_Sandbox_V3_CopyDirOutChunk) -> Bool { + if lhs.data != rhs.data {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + extension Com_Apple_Containerization_Sandbox_V3_IpLinkSetRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".IpLinkSetRequest" public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ diff --git a/Sources/Containerization/SandboxContext/SandboxContext.proto b/Sources/Containerization/SandboxContext/SandboxContext.proto index 6e2965ef..cba82242 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.proto +++ b/Sources/Containerization/SandboxContext/SandboxContext.proto @@ -28,7 +28,10 @@ service SandboxContext { rpc CopyIn(stream CopyInChunk) returns (CopyInResponse); // Copy a file from the guest to the host. rpc CopyOut(CopyOutRequest) returns (stream CopyOutChunk); - + // Copy a dir from the host into the guest + rpc CopyDirIn(stream CopyDirInChunk) returns (CopyDirInResponse); + // Copy a dir from the guest to the host. + rpc CopyDirOut(CopyDirOutRequest) returns (stream CopyDirOutChunk); // Create a new process inside the container. rpc CreateProcess(CreateProcessRequest) returns (CreateProcessResponse); // Delete an existing process inside the container. @@ -268,6 +271,32 @@ message CopyOutInit { uint64 total_size = 1; } +message CopyDirInChunk { + oneof content { + // Initialization message (must be first). + CopyDirInInit init = 1; + // File adta chunk + bytes data = 2; + } +} + +message CopyDirInInit { + // Destination path in the guest. + string path = 1; + // Create parent directories if they don't exist. + bool create_parents = 2; +} + +message CopyDirInResponse {} + +message CopyDirOutRequest { + string path = 1; +} + +message CopyDirOutChunk { + bytes data = 1; +} + message IpLinkSetRequest { string interface = 1; bool up = 2; diff --git a/Sources/Containerization/VirtualMachineAgent.swift b/Sources/Containerization/VirtualMachineAgent.swift index b7ce1086..a144c474 100644 --- a/Sources/Containerization/VirtualMachineAgent.swift +++ b/Sources/Containerization/VirtualMachineAgent.swift @@ -66,6 +66,24 @@ public protocol VirtualMachineAgent: Sendable { chunkSize: Int, progress: ProgressHandler? ) async throws + + /// Copy a dir from the host into the guest. + func copyDirIn( + from source: URL, + to destination: URL, + createParents: Bool, + chunkSize: Int, + progress: ProgressHandler? + ) async throws + + /// Copy a dir from the guest to the host. + func copyDirOut( + from source: URL, + to destination: URL, + createParents: Bool, + chunkSize: Int, + progress: ProgressHandler? + ) async throws // Process lifecycle func createProcess( @@ -139,4 +157,24 @@ extension VirtualMachineAgent { ) async throws { throw ContainerizationError(.unsupported, message: "copyOut") } + + public func copyDirIn( + from source: URL, + to destination: URL, + createParents: Bool, + chunkSize: Int, + progress: ProgressHandler? + ) async throws { + throw ContainerizationError(.unsupported, message: "copyDirIn") + } + + public func copyDirOut( + from source: URL, + to destination: URL, + createParents: Bool, + chunkSize: Int, + progress: ProgressHandler? + ) async throws { + throw ContainerizationError(.unsupported, message: "copyDirOut") + } } diff --git a/Sources/Containerization/Vminitd.swift b/Sources/Containerization/Vminitd.swift index 0ac47b7a..9165b6e0 100644 --- a/Sources/Containerization/Vminitd.swift +++ b/Sources/Containerization/Vminitd.swift @@ -22,6 +22,8 @@ import Foundation import GRPC import NIOCore import NIOPosix +import SystemPackage +import _ContainerizationTar /// A remote connection into the vminitd Linux guest agent via a port (vsock). /// Used to modify the runtime environment of the Linux sandbox. @@ -528,6 +530,152 @@ extension Vminitd { } } } + + /// Copy a directory from the host into the guest by streaming a tar archive. + public func copyDirIn( + from source: URL, + to destination: URL, + createParents: Bool, + chunkSize: Int, + progress: ProgressHandler? + ) async throws { + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString + ".tar") + defer { try? FileManager.default.removeItem(at: tempURL) } + + let writeFD = try FileDescriptor.open( + FilePath(tempURL.path), + .writeOnly, + options: [.create, .truncate], + permissions: [.ownerReadWrite] + ) + let writer = TarWriter(fileDescriptor: writeFD, ownsFileDescriptor: false) + try Vminitd.tarDirectory(source: source, writer: writer, basePath: "") + try writer.finalize() + try writeFD.close() + + let call = client.makeCopyDirInCall() + + try await call.requestStream.send(.with { + $0.content = .init_p(.with { + $0.path = destination.path + $0.createParents = createParents + }) + }) + + let readHandle = try FileHandle(forReadingFrom: tempURL) + defer { try? readHandle.close() } + + while true { + guard let data = try readHandle.read(upToCount: chunkSize), !data.isEmpty else { + break + } + try await call.requestStream.send(.with { $0.content = .data(data) }) + } + + call.requestStream.finish() + _ = try await call.response + } + + /// Copy a directory from the guest to the host by receiving a tar archive. + public func copyDirOut( + from source: URL, + to destination: URL, + createParents: Bool, + chunkSize: Int, + progress: ProgressHandler? + ) async throws { + if createParents { + try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) + } + + let request = Com_Apple_Containerization_Sandbox_V3_CopyDirOutRequest.with { + $0.path = source.path + } + + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString + ".tar") + defer { try? FileManager.default.removeItem(at: tempURL) } + + let fd = open(tempURL.path, O_WRONLY | O_CREAT | O_TRUNC, 0o644) + guard fd != -1 else { + throw ContainerizationError( + .internalError, + message: "copyDirOut: failed to create temp file '\(tempURL.path)': \(String(cString: strerror(errno)))" + ) + } + let writeHandle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) + + let stream = client.copyDirOut(request) + for try await chunk in stream { + writeHandle.write(chunk.data) + } + try writeHandle.close() + + let readFD = try FileDescriptor.open(FilePath(tempURL.path), .readOnly) + defer { try? readFD.close() } + let reader = TarReader(fileDescriptor: readFD, ownsFileDescriptor: false) + try Vminitd.extractTar(reader: reader, to: destination) + } + + /// Recursively walk a directory and write entries to a TarWriter. + private static func tarDirectory(source: URL, writer: TarWriter, basePath: String) throws { + let fm = FileManager.default + let items = try fm.contentsOfDirectory(at: source, includingPropertiesForKeys: nil) + for item in items { + let name = item.lastPathComponent + let entryPath = basePath.isEmpty ? name : "\(basePath)/\(name)" + let attrs = try fm.attributesOfItem(atPath: item.path) + let mode = (attrs[.posixPermissions] as? Int).map { UInt32($0) } + let fileType = attrs[.type] as? FileAttributeType + + if fileType == .typeSymbolicLink { + let target = try fm.destinationOfSymbolicLink(atPath: item.path) + try writer.writeSymlink(path: entryPath, target: target) + } else if fileType == .typeDirectory { + try writer.writeDirectory(path: entryPath, mode: mode ?? 0o755) + try tarDirectory(source: item, writer: writer, basePath: entryPath) + } else { + let fd = try FileDescriptor.open(FilePath(item.path), .readOnly) + defer { try? fd.close() } + try writer.writeFile(path: entryPath, from: fd, mode: mode ?? 0o644) + } + } + } + + /// Extract a tar archive to a destination directory. + private static func extractTar(reader: TarReader, to destURL: URL) throws { + let fm = FileManager.default + while let header = try reader.nextHeader() { + let relativePath = header.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard !relativePath.contains("..") else { + try reader.skipRemainingContent() + continue + } + let fullURL = relativePath.isEmpty ? destURL : destURL.appending(path: relativePath) + + switch header.entryType { + case .directory: + try fm.createDirectory(at: fullURL, withIntermediateDirectories: true) + case .regular, .regularAlt, .contiguous: + let parentDir = fullURL.deletingLastPathComponent() + try fm.createDirectory(at: parentDir, withIntermediateDirectories: true) + let fd = open(fullURL.path, O_WRONLY | O_CREAT | O_TRUNC, mode_t(header.mode > 0 ? header.mode : 0o644)) + guard fd != -1 else { + try reader.skipRemainingContent() + continue + } + let fileFD = FileDescriptor(rawValue: fd) + defer { try? fileFD.close() } + try reader.readFile(to: fileFD) + case .symbolicLink: + try? fm.removeItem(at: fullURL) + try fm.createSymbolicLink(atPath: fullURL.path, withDestinationPath: header.linkName) + default: + try reader.skipRemainingContent() + } + } + } } extension Hosts { diff --git a/Sources/Integration/ContainerTests.swift b/Sources/Integration/ContainerTests.swift index 1b1f0d65..a7b52426 100644 --- a/Sources/Integration/ContainerTests.swift +++ b/Sources/Integration/ContainerTests.swift @@ -3140,4 +3140,130 @@ extension IntegrationSuite { msg: "expected 'input through init', got '\(String(data: buffer.data, encoding: .utf8) ?? "nil")'") } } + + func testCopyDirIn() async throws { + let id = "test-copy-dir-in" + let bs = try await bootstrap(id) + + let hostDir = FileManager.default.uniqueTemporaryDirectory(create: true) + .appendingPathComponent("mydir") + try FileManager.default.createDirectory(at: hostDir, withIntermediateDirectories: true) + try "hello from file1".write(to: hostDir.appendingPathComponent("file1.txt"), atomically: true, encoding: .utf8) + try "hello from file2".write(to: hostDir.appendingPathComponent("file2.txt"), atomically: true, encoding: .utf8) + + let subdir = hostDir.appendingPathComponent("subdir") + try FileManager.default.createDirectory(at: subdir, withIntermediateDirectories: true) + try "hello from nested".write(to: subdir.appendingPathComponent("nested.txt"), atomically: true, encoding: .utf8) + + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.arguments = ["sleep", "100"] + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + + try await container.copyDirIn( + from: hostDir, + to: URL(filePath: "/tmp/mydir") + ) + + let checks: [(path: String, expected: String)] = [ + ("/tmp/mydir/file1.txt", "hello from file1"), + ("/tmp/mydir/file2.txt", "hello from file2"), + ("/tmp/mydir/subdir/nested.txt", "hello from nested"), + ] + + for check in checks { + let buf = BufferWriter() + let exec = try await container.exec("check-\(check.path.split(separator: "/").last!)") { config in + config.arguments = ["cat", check.path] + config.stdout = buf + } + try await exec.start() + let status = try await exec.wait() + try await exec.delete() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "cat \(check.path) failed with status \(status)") + } + let got = String(data: buf.data, encoding: .utf8) ?? "" + guard got == check.expected else { + throw IntegrationError.assert( + msg: "content mismatch for \(check.path): expected '\(check.expected)', got '\(got)'" + ) + } + } + + try await container.kill(SIGKILL) + try await container.wait() + try await container.stop() + } catch { + try? await container.stop() + throw error + } + } + + func testCopyDirOut() async throws { + let id = "test-copy-dir-out" + let bs = try await bootstrap(id) + + let hostDestination = FileManager.default.uniqueTemporaryDirectory(create: true) + .appendingPathComponent("out") + + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.arguments = ["sleep", "100"] + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + + let setup = try await container.exec("setup") { config in + config.arguments = ["sh", "-c", """ + mkdir -p /tmp/guestdir/subdir && \ + echo -n 'alpha' > /tmp/guestdir/alpha.txt && \ + echo -n 'beta' > /tmp/guestdir/beta.txt && \ + echo -n 'nested' > /tmp/guestdir/subdir/nested.txt + """] + } + try await setup.start() + let setupStatus = try await setup.wait() + try await setup.delete() + + guard setupStatus.exitCode == 0 else { + throw IntegrationError.assert(msg: "guest dir setup failed with status \(setupStatus)") + } + + try await container.copyDirOut( + from: URL(filePath: "/tmp/guestdir"), + to: hostDestination + ) + + let checks: [(path: String, expected: String)] = [ + ("alpha.txt", "alpha"), + ("beta.txt", "beta"), + ("subdir/nested.txt", "nested"), + ] + + for check in checks { + let fileURL = hostDestination.appendingPathComponent(check.path) + let got = try String(contentsOf: fileURL, encoding: .utf8) + guard got == check.expected else { + throw IntegrationError.assert( + msg: "content mismatch for \(check.path): expected '\(check.expected)', got '\(got)'" + ) + } + } + + try await container.kill(SIGKILL) + try await container.wait() + try await container.stop() + } catch { + try? await container.stop() + throw error + } + } } diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index ce3c3cfb..583bff6e 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -323,6 +323,8 @@ struct IntegrationSuite: AsyncParsableCommand { Test("container copy in", testCopyIn), Test("container copy out", testCopyOut), Test("container copy large file", testCopyLargeFile), + Test("container copy dir in", testCopyDirIn), + Test("container copy dir out", testCopyDirOut), Test("container read-only rootfs", testReadOnlyRootfs), Test("container read-only rootfs hosts file", testReadOnlyRootfsHostsFileWritten), Test("container read-only rootfs DNS", testReadOnlyRootfsDNSConfigured), diff --git a/vminitd/Sources/vminitd/Server+GRPC.swift b/vminitd/Sources/vminitd/Server+GRPC.swift index f7b8112e..4c4e734c 100644 --- a/vminitd/Sources/vminitd/Server+GRPC.swift +++ b/vminitd/Sources/vminitd/Server+GRPC.swift @@ -27,6 +27,8 @@ import Logging import NIOCore import NIOPosix import SwiftProtobuf +import SystemPackage +import _ContainerizationTar private let _setenv = Foundation.setenv @@ -1296,6 +1298,164 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncProvid } } + func copyDirIn( + requestStream: GRPCAsyncRequestStream, + context: GRPC.GRPCAsyncServerCallContext + ) async throws -> Com_Apple_Containerization_Sandbox_V3_CopyDirInResponse { + var destPath = "" + var createParents = false + var writeHandle: FileHandle? + + let tempPath = "/tmp/\(UUID().uuidString).tar" + defer { unlink(tempPath) } + + for try await chunk in requestStream { + switch chunk.content { + case .init_p(let initMsg): + destPath = initMsg.path + createParents = initMsg.createParents + + log.debug("copyDirIn", metadata: ["path": "\(destPath)", "createParents": "\(createParents)"]) + + if createParents { + try FileManager.default.createDirectory( + at: URL(fileURLWithPath: destPath), + withIntermediateDirectories: true + ) + } + + let fd = open(tempPath, O_WRONLY | O_CREAT | O_TRUNC, 0o644) + guard fd != -1 else { + throw GRPCStatus( + code: .internalError, + message: "copyDirIn: failed to create temp file: \(swiftErrno("open"))" + ) + } + writeHandle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) + + case .data(let bytes): + if let wh = writeHandle, !bytes.isEmpty { + wh.write(bytes) + } + + case .none: + break + } + } + + guard !destPath.isEmpty else { + throw GRPCStatus(code: .failedPrecondition, message: "copyDirIn: missing init message") + } + + try writeHandle?.close() + + let readFD = try FileDescriptor.open(FilePath(tempPath), .readOnly) + defer { try? readFD.close() } + let reader = TarReader(fileDescriptor: readFD, ownsFileDescriptor: false) + try Self.extractTar(reader: reader, to: URL(fileURLWithPath: destPath)) + + log.debug("copyDirIn complete", metadata: ["path": "\(destPath)"]) + return .init() + } + + func copyDirOut( + request: Com_Apple_Containerization_Sandbox_V3_CopyDirOutRequest, + responseStream: GRPCAsyncResponseStreamWriter, + context: GRPC.GRPCAsyncServerCallContext + ) async throws { + let path = request.path + log.debug("copyDirOut", metadata: ["path": "\(path)"]) + + // Write tar to a temp file, then stream it. + let tempPath = "/tmp/\(UUID().uuidString).tar" + defer { unlink(tempPath) } + + let sourceURL = URL(fileURLWithPath: path) + + let writeFD = try FileDescriptor.open( + FilePath(tempPath), + .writeOnly, + options: [.create, .truncate], + permissions: [.ownerReadWrite] + ) + let writer = TarWriter(fileDescriptor: writeFD, ownsFileDescriptor: false) + try Self.tarDirectory(source: sourceURL, writer: writer, basePath: "") + try writer.finalize() + try writeFD.close() + + let readHandle = try FileHandle(forReadingFrom: URL(fileURLWithPath: tempPath)) + defer { try? readHandle.close() } + + while true { + guard let data = try readHandle.read(upToCount: Self.copyChunkSize), !data.isEmpty else { + break + } + try await responseStream.send(.with { $0.data = data }) + } + + log.debug("copyDirOut complete", metadata: ["path": "\(path)"]) + } + + /// Recursively walk a directory and write entries to a TarWriter. + private static func tarDirectory(source: URL, writer: TarWriter, basePath: String) throws { + let fm = FileManager.default + let items = try fm.contentsOfDirectory(at: source, includingPropertiesForKeys: nil) + for item in items { + let name = item.lastPathComponent + let entryPath = basePath.isEmpty ? name : "\(basePath)/\(name)" + let attrs = try fm.attributesOfItem(atPath: item.path) + let mode = (attrs[.posixPermissions] as? Int).map { UInt32($0) } + let fileType = attrs[.type] as? FileAttributeType + + if fileType == .typeSymbolicLink { + let target = try fm.destinationOfSymbolicLink(atPath: item.path) + try writer.writeSymlink(path: entryPath, target: target) + } else if fileType == .typeDirectory { + try writer.writeDirectory(path: entryPath, mode: mode ?? 0o755) + try tarDirectory(source: item, writer: writer, basePath: entryPath) + } else { + let fd = try FileDescriptor.open(FilePath(item.path), .readOnly) + defer { try? fd.close() } + try writer.writeFile(path: entryPath, from: fd, mode: mode ?? 0o644) + } + } + } + + /// Extract a tar archive into a destination directory. + private static func extractTar(reader: TarReader, to destURL: URL) throws { + let fm = FileManager.default + while let header = try reader.nextHeader() { + let relativePath = header.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard !relativePath.contains("..") else { + try reader.skipRemainingContent() + continue + } + let fullURL = relativePath.isEmpty ? destURL : destURL.appending(path: relativePath) + + switch header.entryType { + case .directory: + try fm.createDirectory(at: fullURL, withIntermediateDirectories: true) + case .regular, .regularAlt, .contiguous: + let parentDir = fullURL.deletingLastPathComponent() + try fm.createDirectory(at: parentDir, withIntermediateDirectories: true) + let mode = mode_t(header.mode > 0 ? header.mode : 0o644) + let fd = open(fullURL.path, O_WRONLY | O_CREAT | O_TRUNC, mode) + guard fd != -1 else { + try reader.skipRemainingContent() + continue + } + let fileFD = FileDescriptor(rawValue: fd) + defer { try? fileFD.close() } + try reader.readFile(to: fileFD) + case .symbolicLink: + try? fm.removeItem(at: fullURL) + try fm.createSymbolicLink(atPath: fullURL.path, withDestinationPath: header.linkName) + default: + try reader.skipRemainingContent() + } + } + } + private func swiftErrno(_ msg: Logger.Message) -> POSIXError { let error = POSIXError(.init(rawValue: errno)!) log.error( From e7cd61133d74aec7b2b81127ecc9ee13f0668f97 Mon Sep 17 00:00:00 2001 From: Simone Panico Date: Sun, 1 Mar 2026 20:56:55 +0100 Subject: [PATCH 3/4] Remove unnecessary RPCs --- .../SandboxContext/SandboxContext.grpc.swift | 229 ------------- .../SandboxContext/SandboxContext.pb.swift | 308 +----------------- .../SandboxContext/SandboxContext.proto | 34 +- Sources/Containerization/Vminitd.swift | 15 +- vminitd/Sources/vminitd/Server+GRPC.swift | 240 ++++++-------- 5 files changed, 135 insertions(+), 691 deletions(-) diff --git a/Sources/Containerization/SandboxContext/SandboxContext.grpc.swift b/Sources/Containerization/SandboxContext/SandboxContext.grpc.swift index 801e5d6c..04c4a167 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.grpc.swift +++ b/Sources/Containerization/SandboxContext/SandboxContext.grpc.swift @@ -1,19 +1,3 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025-2026 Apple Inc. and the Containerization project authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// https://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -//===----------------------------------------------------------------------===// - // // DO NOT EDIT. // swift-format-ignore-file @@ -89,16 +73,6 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextClientProtoc handler: @escaping (Com_Apple_Containerization_Sandbox_V3_CopyOutChunk) -> Void ) -> ServerStreamingCall - func copyDirIn( - callOptions: CallOptions? - ) -> ClientStreamingCall - - func copyDirOut( - _ request: Com_Apple_Containerization_Sandbox_V3_CopyDirOutRequest, - callOptions: CallOptions?, - handler: @escaping (Com_Apple_Containerization_Sandbox_V3_CopyDirOutChunk) -> Void - ) -> ServerStreamingCall - func createProcess( _ request: Com_Apple_Containerization_Sandbox_V3_CreateProcessRequest, callOptions: CallOptions? @@ -396,45 +370,6 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContextClientProtocol { ) } - /// Copy a dir from the host into the guest - /// - /// Callers should use the `send` method on the returned object to send messages - /// to the server. The caller should send an `.end` after the final message has been sent. - /// - /// - Parameters: - /// - callOptions: Call options. - /// - Returns: A `ClientStreamingCall` with futures for the metadata, status and response. - public func copyDirIn( - callOptions: CallOptions? = nil - ) -> ClientStreamingCall { - return self.makeClientStreamingCall( - path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyDirIn.path, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeCopyDirInInterceptors() ?? [] - ) - } - - /// Copy a dir from the guest to the host. - /// - /// - Parameters: - /// - request: Request to send to CopyDirOut. - /// - callOptions: Call options. - /// - handler: A closure called when each response is received from the server. - /// - Returns: A `ServerStreamingCall` with futures for the metadata and status. - public func copyDirOut( - _ request: Com_Apple_Containerization_Sandbox_V3_CopyDirOutRequest, - callOptions: CallOptions? = nil, - handler: @escaping (Com_Apple_Containerization_Sandbox_V3_CopyDirOutChunk) -> Void - ) -> ServerStreamingCall { - return self.makeServerStreamingCall( - path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyDirOut.path, - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeCopyDirOutInterceptors() ?? [], - handler: handler - ) - } - /// Create a new process inside the container. /// /// - Parameters: @@ -878,15 +813,6 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncClientP callOptions: CallOptions? ) -> GRPCAsyncServerStreamingCall - func makeCopyDirInCall( - callOptions: CallOptions? - ) -> GRPCAsyncClientStreamingCall - - func makeCopyDirOutCall( - _ request: Com_Apple_Containerization_Sandbox_V3_CopyDirOutRequest, - callOptions: CallOptions? - ) -> GRPCAsyncServerStreamingCall - func makeCreateProcessCall( _ request: Com_Apple_Containerization_Sandbox_V3_CreateProcessRequest, callOptions: CallOptions? @@ -1118,28 +1044,6 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncClientProtoco ) } - public func makeCopyDirInCall( - callOptions: CallOptions? = nil - ) -> GRPCAsyncClientStreamingCall { - return self.makeAsyncClientStreamingCall( - path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyDirIn.path, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeCopyDirInInterceptors() ?? [] - ) - } - - public func makeCopyDirOutCall( - _ request: Com_Apple_Containerization_Sandbox_V3_CopyDirOutRequest, - callOptions: CallOptions? = nil - ) -> GRPCAsyncServerStreamingCall { - return self.makeAsyncServerStreamingCall( - path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyDirOut.path, - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeCopyDirOutInterceptors() ?? [] - ) - } - public func makeCreateProcessCall( _ request: Com_Apple_Containerization_Sandbox_V3_CreateProcessRequest, callOptions: CallOptions? = nil @@ -1503,42 +1407,6 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncClientProtoco ) } - public func copyDirIn( - _ requests: RequestStream, - callOptions: CallOptions? = nil - ) async throws -> Com_Apple_Containerization_Sandbox_V3_CopyDirInResponse where RequestStream: Sequence, RequestStream.Element == Com_Apple_Containerization_Sandbox_V3_CopyDirInChunk { - return try await self.performAsyncClientStreamingCall( - path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyDirIn.path, - requests: requests, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeCopyDirInInterceptors() ?? [] - ) - } - - public func copyDirIn( - _ requests: RequestStream, - callOptions: CallOptions? = nil - ) async throws -> Com_Apple_Containerization_Sandbox_V3_CopyDirInResponse where RequestStream: AsyncSequence & Sendable, RequestStream.Element == Com_Apple_Containerization_Sandbox_V3_CopyDirInChunk { - return try await self.performAsyncClientStreamingCall( - path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyDirIn.path, - requests: requests, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeCopyDirInInterceptors() ?? [] - ) - } - - public func copyDirOut( - _ request: Com_Apple_Containerization_Sandbox_V3_CopyDirOutRequest, - callOptions: CallOptions? = nil - ) -> GRPCAsyncResponseStream { - return self.performAsyncServerStreamingCall( - path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyDirOut.path, - request: request, - callOptions: callOptions ?? self.defaultCallOptions, - interceptors: self.interceptors?.makeCopyDirOutInterceptors() ?? [] - ) - } - public func createProcess( _ request: Com_Apple_Containerization_Sandbox_V3_CreateProcessRequest, callOptions: CallOptions? = nil @@ -1808,12 +1676,6 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextClientInterc /// - Returns: Interceptors to use when invoking 'copyOut'. func makeCopyOutInterceptors() -> [ClientInterceptor] - /// - Returns: Interceptors to use when invoking 'copyDirIn'. - func makeCopyDirInInterceptors() -> [ClientInterceptor] - - /// - Returns: Interceptors to use when invoking 'copyDirOut'. - func makeCopyDirOutInterceptors() -> [ClientInterceptor] - /// - Returns: Interceptors to use when invoking 'createProcess'. func makeCreateProcessInterceptors() -> [ClientInterceptor] @@ -1885,8 +1747,6 @@ public enum Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata { Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.writeFile, Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyIn, Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyOut, - Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyDirIn, - Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.copyDirOut, Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.createProcess, Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.deleteProcess, Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.startProcess, @@ -1975,18 +1835,6 @@ public enum Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata { type: GRPCCallType.serverStreaming ) - public static let copyDirIn = GRPCMethodDescriptor( - name: "CopyDirIn", - path: "/com.apple.containerization.sandbox.v3.SandboxContext/CopyDirIn", - type: GRPCCallType.clientStreaming - ) - - public static let copyDirOut = GRPCMethodDescriptor( - name: "CopyDirOut", - path: "/com.apple.containerization.sandbox.v3.SandboxContext/CopyDirOut", - type: GRPCCallType.serverStreaming - ) - public static let createProcess = GRPCMethodDescriptor( name: "CreateProcess", path: "/com.apple.containerization.sandbox.v3.SandboxContext/CreateProcess", @@ -2136,12 +1984,6 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextProvider: Ca /// Copy a file from the guest to the host. func copyOut(request: Com_Apple_Containerization_Sandbox_V3_CopyOutRequest, context: StreamingResponseCallContext) -> EventLoopFuture - /// Copy a dir from the host into the guest - func copyDirIn(context: UnaryResponseCallContext) -> EventLoopFuture<(StreamEvent) -> Void> - - /// Copy a dir from the guest to the host. - func copyDirOut(request: Com_Apple_Containerization_Sandbox_V3_CopyDirOutRequest, context: StreamingResponseCallContext) -> EventLoopFuture - /// Create a new process inside the container. func createProcess(request: Com_Apple_Containerization_Sandbox_V3_CreateProcessRequest, context: StatusOnlyCallContext) -> EventLoopFuture @@ -2309,24 +2151,6 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContextProvider { userFunction: self.copyOut(request:context:) ) - case "CopyDirIn": - return ClientStreamingServerHandler( - context: context, - requestDeserializer: ProtobufDeserializer(), - responseSerializer: ProtobufSerializer(), - interceptors: self.interceptors?.makeCopyDirInInterceptors() ?? [], - observerFactory: self.copyDirIn(context:) - ) - - case "CopyDirOut": - return ServerStreamingServerHandler( - context: context, - requestDeserializer: ProtobufDeserializer(), - responseSerializer: ProtobufSerializer(), - interceptors: self.interceptors?.makeCopyDirOutInterceptors() ?? [], - userFunction: self.copyDirOut(request:context:) - ) - case "CreateProcess": return UnaryServerHandler( context: context, @@ -2570,19 +2394,6 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncProvide context: GRPCAsyncServerCallContext ) async throws - /// Copy a dir from the host into the guest - func copyDirIn( - requestStream: GRPCAsyncRequestStream, - context: GRPCAsyncServerCallContext - ) async throws -> Com_Apple_Containerization_Sandbox_V3_CopyDirInResponse - - /// Copy a dir from the guest to the host. - func copyDirOut( - request: Com_Apple_Containerization_Sandbox_V3_CopyDirOutRequest, - responseStream: GRPCAsyncResponseStreamWriter, - context: GRPCAsyncServerCallContext - ) async throws - /// Create a new process inside the container. func createProcess( request: Com_Apple_Containerization_Sandbox_V3_CreateProcessRequest, @@ -2811,24 +2622,6 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncProvider { wrapping: { try await self.copyOut(request: $0, responseStream: $1, context: $2) } ) - case "CopyDirIn": - return GRPCAsyncServerHandler( - context: context, - requestDeserializer: ProtobufDeserializer(), - responseSerializer: ProtobufSerializer(), - interceptors: self.interceptors?.makeCopyDirInInterceptors() ?? [], - wrapping: { try await self.copyDirIn(requestStream: $0, context: $1) } - ) - - case "CopyDirOut": - return GRPCAsyncServerHandler( - context: context, - requestDeserializer: ProtobufDeserializer(), - responseSerializer: ProtobufSerializer(), - interceptors: self.interceptors?.makeCopyDirOutInterceptors() ?? [], - wrapping: { try await self.copyDirOut(request: $0, responseStream: $1, context: $2) } - ) - case "CreateProcess": return GRPCAsyncServerHandler( context: context, @@ -3043,14 +2836,6 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextServerInterc /// Defaults to calling `self.makeInterceptors()`. func makeCopyOutInterceptors() -> [ServerInterceptor] - /// - Returns: Interceptors to use when handling 'copyDirIn'. - /// Defaults to calling `self.makeInterceptors()`. - func makeCopyDirInInterceptors() -> [ServerInterceptor] - - /// - Returns: Interceptors to use when handling 'copyDirOut'. - /// Defaults to calling `self.makeInterceptors()`. - func makeCopyDirOutInterceptors() -> [ServerInterceptor] - /// - Returns: Interceptors to use when handling 'createProcess'. /// Defaults to calling `self.makeInterceptors()`. func makeCreateProcessInterceptors() -> [ServerInterceptor] @@ -3140,8 +2925,6 @@ public enum Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata { Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.writeFile, Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.copyIn, Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.copyOut, - Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.copyDirIn, - Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.copyDirOut, Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.createProcess, Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.deleteProcess, Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.startProcess, @@ -3230,18 +3013,6 @@ public enum Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata { type: GRPCCallType.serverStreaming ) - public static let copyDirIn = GRPCMethodDescriptor( - name: "CopyDirIn", - path: "/com.apple.containerization.sandbox.v3.SandboxContext/CopyDirIn", - type: GRPCCallType.clientStreaming - ) - - public static let copyDirOut = GRPCMethodDescriptor( - name: "CopyDirOut", - path: "/com.apple.containerization.sandbox.v3.SandboxContext/CopyDirOut", - type: GRPCCallType.serverStreaming - ) - public static let createProcess = GRPCMethodDescriptor( name: "CreateProcess", path: "/com.apple.containerization.sandbox.v3.SandboxContext/CreateProcess", diff --git a/Sources/Containerization/SandboxContext/SandboxContext.pb.swift b/Sources/Containerization/SandboxContext/SandboxContext.pb.swift index 63e00b42..dbd59d40 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.pb.swift +++ b/Sources/Containerization/SandboxContext/SandboxContext.pb.swift @@ -1,19 +1,3 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025-2026 Apple Inc. and the Containerization project authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// https://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -//===----------------------------------------------------------------------===// - // DO NOT EDIT. // swift-format-ignore-file // swiftlint:disable all @@ -863,6 +847,9 @@ public struct Com_Apple_Containerization_Sandbox_V3_CopyInInit: Sendable { /// Create parent directories if they don't exist. public var createParents: Bool = false + /// If true, the payload is a TAR archive to extract at the destination. + public var isDirectory: Bool = false + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} @@ -886,6 +873,9 @@ public struct Com_Apple_Containerization_Sandbox_V3_CopyOutRequest: Sendable { /// Source path in the guest. public var path: String = String() + /// If true, create a TAR archive of the directory and stream it. + public var isDirectory: Bool = false + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} @@ -942,94 +932,6 @@ public struct Com_Apple_Containerization_Sandbox_V3_CopyOutInit: Sendable { public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_CopyDirInChunk: @unchecked Sendable { - // SwiftProtobuf.Message conformance is added in an extension below. See the - // `Message` and `Message+*Additions` files in the SwiftProtobuf library for - // methods supported on all messages. - - public var content: Com_Apple_Containerization_Sandbox_V3_CopyDirInChunk.OneOf_Content? = nil - - /// Initialization message (must be first). - public var init_p: Com_Apple_Containerization_Sandbox_V3_CopyDirInInit { - get { - if case .init_p(let v)? = content {return v} - return Com_Apple_Containerization_Sandbox_V3_CopyDirInInit() - } - set {content = .init_p(newValue)} - } - - /// File adta chunk - public var data: Data { - get { - if case .data(let v)? = content {return v} - return Data() - } - set {content = .data(newValue)} - } - - public var unknownFields = SwiftProtobuf.UnknownStorage() - - public enum OneOf_Content: Equatable, @unchecked Sendable { - /// Initialization message (must be first). - case init_p(Com_Apple_Containerization_Sandbox_V3_CopyDirInInit) - /// File adta chunk - case data(Data) - - } - - public init() {} -} - -public struct Com_Apple_Containerization_Sandbox_V3_CopyDirInInit: Sendable { - // SwiftProtobuf.Message conformance is added in an extension below. See the - // `Message` and `Message+*Additions` files in the SwiftProtobuf library for - // methods supported on all messages. - - /// Destination path in the guest. - public var path: String = String() - - /// Create parent directories if they don't exist. - public var createParents: Bool = false - - public var unknownFields = SwiftProtobuf.UnknownStorage() - - public init() {} -} - -public struct Com_Apple_Containerization_Sandbox_V3_CopyDirInResponse: Sendable { - // SwiftProtobuf.Message conformance is added in an extension below. See the - // `Message` and `Message+*Additions` files in the SwiftProtobuf library for - // methods supported on all messages. - - public var unknownFields = SwiftProtobuf.UnknownStorage() - - public init() {} -} - -public struct Com_Apple_Containerization_Sandbox_V3_CopyDirOutRequest: Sendable { - // SwiftProtobuf.Message conformance is added in an extension below. See the - // `Message` and `Message+*Additions` files in the SwiftProtobuf library for - // methods supported on all messages. - - public var path: String = String() - - public var unknownFields = SwiftProtobuf.UnknownStorage() - - public init() {} -} - -public struct Com_Apple_Containerization_Sandbox_V3_CopyDirOutChunk: @unchecked Sendable { - // SwiftProtobuf.Message conformance is added in an extension below. See the - // `Message` and `Message+*Additions` files in the SwiftProtobuf library for - // methods supported on all messages. - - public var data: Data = Data() - - public var unknownFields = SwiftProtobuf.UnknownStorage() - - public init() {} -} - public struct Com_Apple_Containerization_Sandbox_V3_IpLinkSetRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for @@ -2967,6 +2869,7 @@ extension Com_Apple_Containerization_Sandbox_V3_CopyInInit: SwiftProtobuf.Messag 1: .same(proto: "path"), 2: .same(proto: "mode"), 3: .standard(proto: "create_parents"), + 4: .standard(proto: "is_directory"), ] public mutating func decodeMessage(decoder: inout D) throws { @@ -2978,6 +2881,7 @@ extension Com_Apple_Containerization_Sandbox_V3_CopyInInit: SwiftProtobuf.Messag case 1: try { try decoder.decodeSingularStringField(value: &self.path) }() case 2: try { try decoder.decodeSingularUInt32Field(value: &self.mode) }() case 3: try { try decoder.decodeSingularBoolField(value: &self.createParents) }() + case 4: try { try decoder.decodeSingularBoolField(value: &self.isDirectory) }() default: break } } @@ -2993,6 +2897,9 @@ extension Com_Apple_Containerization_Sandbox_V3_CopyInInit: SwiftProtobuf.Messag if self.createParents != false { try visitor.visitSingularBoolField(value: self.createParents, fieldNumber: 3) } + if self.isDirectory != false { + try visitor.visitSingularBoolField(value: self.isDirectory, fieldNumber: 4) + } try unknownFields.traverse(visitor: &visitor) } @@ -3000,6 +2907,7 @@ extension Com_Apple_Containerization_Sandbox_V3_CopyInInit: SwiftProtobuf.Messag if lhs.path != rhs.path {return false} if lhs.mode != rhs.mode {return false} if lhs.createParents != rhs.createParents {return false} + if lhs.isDirectory != rhs.isDirectory {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } @@ -3028,6 +2936,7 @@ extension Com_Apple_Containerization_Sandbox_V3_CopyOutRequest: SwiftProtobuf.Me public static let protoMessageName: String = _protobuf_package + ".CopyOutRequest" public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 1: .same(proto: "path"), + 2: .standard(proto: "is_directory"), ] public mutating func decodeMessage(decoder: inout D) throws { @@ -3037,6 +2946,7 @@ extension Com_Apple_Containerization_Sandbox_V3_CopyOutRequest: SwiftProtobuf.Me // enabled. https://github.com/apple/swift-protobuf/issues/1034 switch fieldNumber { case 1: try { try decoder.decodeSingularStringField(value: &self.path) }() + case 2: try { try decoder.decodeSingularBoolField(value: &self.isDirectory) }() default: break } } @@ -3046,11 +2956,15 @@ extension Com_Apple_Containerization_Sandbox_V3_CopyOutRequest: SwiftProtobuf.Me if !self.path.isEmpty { try visitor.visitSingularStringField(value: self.path, fieldNumber: 1) } + if self.isDirectory != false { + try visitor.visitSingularBoolField(value: self.isDirectory, fieldNumber: 2) + } try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_CopyOutRequest, rhs: Com_Apple_Containerization_Sandbox_V3_CopyOutRequest) -> Bool { if lhs.path != rhs.path {return false} + if lhs.isDirectory != rhs.isDirectory {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } @@ -3153,192 +3067,6 @@ extension Com_Apple_Containerization_Sandbox_V3_CopyOutInit: SwiftProtobuf.Messa } } -extension Com_Apple_Containerization_Sandbox_V3_CopyDirInChunk: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = _protobuf_package + ".CopyDirInChunk" - public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "init"), - 2: .same(proto: "data"), - ] - - public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - // The use of inline closures is to circumvent an issue where the compiler - // allocates stack space for every case branch when no optimizations are - // enabled. https://github.com/apple/swift-protobuf/issues/1034 - switch fieldNumber { - case 1: try { - var v: Com_Apple_Containerization_Sandbox_V3_CopyDirInInit? - var hadOneofValue = false - if let current = self.content { - hadOneofValue = true - if case .init_p(let m) = current {v = m} - } - try decoder.decodeSingularMessageField(value: &v) - if let v = v { - if hadOneofValue {try decoder.handleConflictingOneOf()} - self.content = .init_p(v) - } - }() - case 2: try { - var v: Data? - try decoder.decodeSingularBytesField(value: &v) - if let v = v { - if self.content != nil {try decoder.handleConflictingOneOf()} - self.content = .data(v) - } - }() - default: break - } - } - } - - public func traverse(visitor: inout V) throws { - // The use of inline closures is to circumvent an issue where the compiler - // allocates stack space for every if/case branch local when no optimizations - // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and - // https://github.com/apple/swift-protobuf/issues/1182 - switch self.content { - case .init_p?: try { - guard case .init_p(let v)? = self.content else { preconditionFailure() } - try visitor.visitSingularMessageField(value: v, fieldNumber: 1) - }() - case .data?: try { - guard case .data(let v)? = self.content else { preconditionFailure() } - try visitor.visitSingularBytesField(value: v, fieldNumber: 2) - }() - case nil: break - } - try unknownFields.traverse(visitor: &visitor) - } - - public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_CopyDirInChunk, rhs: Com_Apple_Containerization_Sandbox_V3_CopyDirInChunk) -> Bool { - if lhs.content != rhs.content {return false} - if lhs.unknownFields != rhs.unknownFields {return false} - return true - } -} - -extension Com_Apple_Containerization_Sandbox_V3_CopyDirInInit: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = _protobuf_package + ".CopyDirInInit" - public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "path"), - 2: .standard(proto: "create_parents"), - ] - - public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - // The use of inline closures is to circumvent an issue where the compiler - // allocates stack space for every case branch when no optimizations are - // enabled. https://github.com/apple/swift-protobuf/issues/1034 - switch fieldNumber { - case 1: try { try decoder.decodeSingularStringField(value: &self.path) }() - case 2: try { try decoder.decodeSingularBoolField(value: &self.createParents) }() - default: break - } - } - } - - public func traverse(visitor: inout V) throws { - if !self.path.isEmpty { - try visitor.visitSingularStringField(value: self.path, fieldNumber: 1) - } - if self.createParents != false { - try visitor.visitSingularBoolField(value: self.createParents, fieldNumber: 2) - } - try unknownFields.traverse(visitor: &visitor) - } - - public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_CopyDirInInit, rhs: Com_Apple_Containerization_Sandbox_V3_CopyDirInInit) -> Bool { - if lhs.path != rhs.path {return false} - if lhs.createParents != rhs.createParents {return false} - if lhs.unknownFields != rhs.unknownFields {return false} - return true - } -} - -extension Com_Apple_Containerization_Sandbox_V3_CopyDirInResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = _protobuf_package + ".CopyDirInResponse" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap() - - public mutating func decodeMessage(decoder: inout D) throws { - // Load everything into unknown fields - while try decoder.nextFieldNumber() != nil {} - } - - public func traverse(visitor: inout V) throws { - try unknownFields.traverse(visitor: &visitor) - } - - public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_CopyDirInResponse, rhs: Com_Apple_Containerization_Sandbox_V3_CopyDirInResponse) -> Bool { - if lhs.unknownFields != rhs.unknownFields {return false} - return true - } -} - -extension Com_Apple_Containerization_Sandbox_V3_CopyDirOutRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = _protobuf_package + ".CopyDirOutRequest" - public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "path"), - ] - - public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - // The use of inline closures is to circumvent an issue where the compiler - // allocates stack space for every case branch when no optimizations are - // enabled. https://github.com/apple/swift-protobuf/issues/1034 - switch fieldNumber { - case 1: try { try decoder.decodeSingularStringField(value: &self.path) }() - default: break - } - } - } - - public func traverse(visitor: inout V) throws { - if !self.path.isEmpty { - try visitor.visitSingularStringField(value: self.path, fieldNumber: 1) - } - try unknownFields.traverse(visitor: &visitor) - } - - public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_CopyDirOutRequest, rhs: Com_Apple_Containerization_Sandbox_V3_CopyDirOutRequest) -> Bool { - if lhs.path != rhs.path {return false} - if lhs.unknownFields != rhs.unknownFields {return false} - return true - } -} - -extension Com_Apple_Containerization_Sandbox_V3_CopyDirOutChunk: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - public static let protoMessageName: String = _protobuf_package + ".CopyDirOutChunk" - public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "data"), - ] - - public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - // The use of inline closures is to circumvent an issue where the compiler - // allocates stack space for every case branch when no optimizations are - // enabled. https://github.com/apple/swift-protobuf/issues/1034 - switch fieldNumber { - case 1: try { try decoder.decodeSingularBytesField(value: &self.data) }() - default: break - } - } - } - - public func traverse(visitor: inout V) throws { - if !self.data.isEmpty { - try visitor.visitSingularBytesField(value: self.data, fieldNumber: 1) - } - try unknownFields.traverse(visitor: &visitor) - } - - public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_CopyDirOutChunk, rhs: Com_Apple_Containerization_Sandbox_V3_CopyDirOutChunk) -> Bool { - if lhs.data != rhs.data {return false} - if lhs.unknownFields != rhs.unknownFields {return false} - return true - } -} - extension Com_Apple_Containerization_Sandbox_V3_IpLinkSetRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".IpLinkSetRequest" public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ diff --git a/Sources/Containerization/SandboxContext/SandboxContext.proto b/Sources/Containerization/SandboxContext/SandboxContext.proto index cba82242..8dedd2ca 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.proto +++ b/Sources/Containerization/SandboxContext/SandboxContext.proto @@ -28,10 +28,6 @@ service SandboxContext { rpc CopyIn(stream CopyInChunk) returns (CopyInResponse); // Copy a file from the guest to the host. rpc CopyOut(CopyOutRequest) returns (stream CopyOutChunk); - // Copy a dir from the host into the guest - rpc CopyDirIn(stream CopyDirInChunk) returns (CopyDirInResponse); - // Copy a dir from the guest to the host. - rpc CopyDirOut(CopyDirOutRequest) returns (stream CopyDirOutChunk); // Create a new process inside the container. rpc CreateProcess(CreateProcessRequest) returns (CreateProcessResponse); // Delete an existing process inside the container. @@ -248,6 +244,8 @@ message CopyInInit { uint32 mode = 2; // Create parent directories if they don't exist. bool create_parents = 3; + // If true, the payload is a TAR archive to extract at the destination. + bool is_directory = 4; } message CopyInResponse {} @@ -255,6 +253,8 @@ message CopyInResponse {} message CopyOutRequest { // Source path in the guest. string path = 1; + // If true, create a TAR archive of the directory and stream it. + bool is_directory = 2; } message CopyOutChunk { @@ -271,32 +271,6 @@ message CopyOutInit { uint64 total_size = 1; } -message CopyDirInChunk { - oneof content { - // Initialization message (must be first). - CopyDirInInit init = 1; - // File adta chunk - bytes data = 2; - } -} - -message CopyDirInInit { - // Destination path in the guest. - string path = 1; - // Create parent directories if they don't exist. - bool create_parents = 2; -} - -message CopyDirInResponse {} - -message CopyDirOutRequest { - string path = 1; -} - -message CopyDirOutChunk { - bytes data = 1; -} - message IpLinkSetRequest { string interface = 1; bool up = 2; diff --git a/Sources/Containerization/Vminitd.swift b/Sources/Containerization/Vminitd.swift index 9165b6e0..376634b7 100644 --- a/Sources/Containerization/Vminitd.swift +++ b/Sources/Containerization/Vminitd.swift @@ -554,12 +554,13 @@ extension Vminitd { try writer.finalize() try writeFD.close() - let call = client.makeCopyDirInCall() + let call = client.makeCopyInCall() try await call.requestStream.send(.with { $0.content = .init_p(.with { $0.path = destination.path $0.createParents = createParents + $0.isDirectory = true }) }) @@ -589,8 +590,9 @@ extension Vminitd { try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) } - let request = Com_Apple_Containerization_Sandbox_V3_CopyDirOutRequest.with { + let request = Com_Apple_Containerization_Sandbox_V3_CopyOutRequest.with { $0.path = source.path + $0.isDirectory = true } let tempURL = FileManager.default.temporaryDirectory @@ -606,9 +608,14 @@ extension Vminitd { } let writeHandle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) - let stream = client.copyDirOut(request) + let stream = client.copyOut(request) for try await chunk in stream { - writeHandle.write(chunk.data) + switch chunk.content { + case .data(let data): + writeHandle.write(data) + case .init_p, .none: + break + } } try writeHandle.close() diff --git a/vminitd/Sources/vminitd/Server+GRPC.swift b/vminitd/Sources/vminitd/Server+GRPC.swift index 4c4e734c..85cb709b 100644 --- a/vminitd/Sources/vminitd/Server+GRPC.swift +++ b/vminitd/Sources/vminitd/Server+GRPC.swift @@ -366,38 +366,60 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncProvid var fileHandle: FileHandle? var path: String = "" var totalBytes: Int = 0 + var isDirectory = false + var tempPath: String? do { for try await chunk in requestStream { switch chunk.content { case .init_p(let initMsg): path = initMsg.path + isDirectory = initMsg.isDirectory log.debug( "copyIn", metadata: [ "path": "\(path)", "mode": "\(initMsg.mode)", "createParents": "\(initMsg.createParents)", + "isDirectory": "\(isDirectory)", ]) - if initMsg.createParents { - let fileURL = URL(fileURLWithPath: path) - let parentDir = fileURL.deletingLastPathComponent() - try FileManager.default.createDirectory( - at: parentDir, - withIntermediateDirectories: true - ) - } - - let mode = initMsg.mode > 0 ? mode_t(initMsg.mode) : mode_t(0o644) - let fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, mode) - guard fd != -1 else { - throw GRPCStatus( - code: .internalError, - message: "copyIn: failed to open file '\(path)': \(swiftErrno("open"))" - ) + if isDirectory { + if initMsg.createParents { + try FileManager.default.createDirectory( + at: URL(fileURLWithPath: path), + withIntermediateDirectories: true + ) + } + let tmp = "/tmp/\(UUID().uuidString).tar" + tempPath = tmp + let fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0o644) + guard fd != -1 else { + throw GRPCStatus( + code: .internalError, + message: "copyIn: failed to create temp file: \(swiftErrno("open"))" + ) + } + fileHandle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) + } else { + if initMsg.createParents { + let fileURL = URL(fileURLWithPath: path) + let parentDir = fileURL.deletingLastPathComponent() + try FileManager.default.createDirectory( + at: parentDir, + withIntermediateDirectories: true + ) + } + let mode = initMsg.mode > 0 ? mode_t(initMsg.mode) : mode_t(0o644) + let fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, mode) + guard fd != -1 else { + throw GRPCStatus( + code: .internalError, + message: "copyIn: failed to open file '\(path)': \(swiftErrno("open"))" + ) + } + fileHandle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) } - fileHandle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) case .data(let bytes): guard let fh = fileHandle else { throw GRPCStatus( @@ -414,6 +436,17 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncProvid } } + if isDirectory, let tmp = tempPath { + defer { unlink(tmp) } + try fileHandle?.close() + fileHandle = nil + + let readFD = try FileDescriptor.open(FilePath(tmp), .readOnly) + defer { try? readFD.close() } + let reader = TarReader(fileDescriptor: readFD, ownsFileDescriptor: false) + try Self.extractTar(reader: reader, to: URL(fileURLWithPath: path)) + } + log.debug( "copyIn complete", metadata: [ @@ -423,6 +456,7 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncProvid return .init() } catch { + if let tmp = tempPath { unlink(tmp) } log.error( "copyIn", metadata: [ @@ -445,47 +479,75 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncProvid context: GRPC.GRPCAsyncServerCallContext ) async throws { let path = request.path + let isDirectory = request.isDirectory log.debug( "copyOut", metadata: [ - "path": "\(path)" + "path": "\(path)", + "isDirectory": "\(isDirectory)", ]) do { - let fileURL = URL(fileURLWithPath: path) - let attrs = try FileManager.default.attributesOfItem(atPath: path) - guard let fileSize = attrs[.size] as? UInt64 else { - throw GRPCStatus( - code: .internalError, - message: "copyOut: failed to get file size for '\(path)'" + if isDirectory { + let tempPath = "/tmp/\(UUID().uuidString).tar" + defer { unlink(tempPath) } + + let sourceURL = URL(fileURLWithPath: path) + let writeFD = try FileDescriptor.open( + FilePath(tempPath), + .writeOnly, + options: [.create, .truncate], + permissions: [.ownerReadWrite] ) - } + let writer = TarWriter(fileDescriptor: writeFD, ownsFileDescriptor: false) + try Self.tarDirectory(source: sourceURL, writer: writer, basePath: "") + try writer.finalize() + try writeFD.close() - let fileHandle = try FileHandle(forReadingFrom: fileURL) - defer { try? fileHandle.close() } + let readHandle = try FileHandle(forReadingFrom: URL(fileURLWithPath: tempPath)) + defer { try? readHandle.close() } - // Send init message with total size. - try await responseStream.send( - .with { - $0.content = .init_p(.with { $0.totalSize = fileSize }) + while true { + guard let data = try readHandle.read(upToCount: Self.copyChunkSize), !data.isEmpty else { + break + } + try await responseStream.send(.with { $0.content = .data(data) }) } - ) - - var totalSent: UInt64 = 0 - while true { - guard let data = try fileHandle.read(upToCount: Self.copyChunkSize), !data.isEmpty else { - break + } else { + let fileURL = URL(fileURLWithPath: path) + let attrs = try FileManager.default.attributesOfItem(atPath: path) + guard let fileSize = attrs[.size] as? UInt64 else { + throw GRPCStatus( + code: .internalError, + message: "copyOut: failed to get file size for '\(path)'" + ) } - try await responseStream.send(.with { $0.content = .data(data) }) - totalSent += UInt64(data.count) + let fileHandle = try FileHandle(forReadingFrom: fileURL) + defer { try? fileHandle.close() } + + // Send init message with total size. + try await responseStream.send( + .with { + $0.content = .init_p(.with { $0.totalSize = fileSize }) + } + ) + + var totalSent: UInt64 = 0 + while true { + guard let data = try fileHandle.read(upToCount: Self.copyChunkSize), !data.isEmpty else { + break + } + + try await responseStream.send(.with { $0.content = .data(data) }) + totalSent += UInt64(data.count) + } } log.debug( "copyOut complete", metadata: [ "path": "\(path)", - "totalBytes": "\(totalSent)", ]) } catch { log.error( @@ -1298,104 +1360,6 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncProvid } } - func copyDirIn( - requestStream: GRPCAsyncRequestStream, - context: GRPC.GRPCAsyncServerCallContext - ) async throws -> Com_Apple_Containerization_Sandbox_V3_CopyDirInResponse { - var destPath = "" - var createParents = false - var writeHandle: FileHandle? - - let tempPath = "/tmp/\(UUID().uuidString).tar" - defer { unlink(tempPath) } - - for try await chunk in requestStream { - switch chunk.content { - case .init_p(let initMsg): - destPath = initMsg.path - createParents = initMsg.createParents - - log.debug("copyDirIn", metadata: ["path": "\(destPath)", "createParents": "\(createParents)"]) - - if createParents { - try FileManager.default.createDirectory( - at: URL(fileURLWithPath: destPath), - withIntermediateDirectories: true - ) - } - - let fd = open(tempPath, O_WRONLY | O_CREAT | O_TRUNC, 0o644) - guard fd != -1 else { - throw GRPCStatus( - code: .internalError, - message: "copyDirIn: failed to create temp file: \(swiftErrno("open"))" - ) - } - writeHandle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) - - case .data(let bytes): - if let wh = writeHandle, !bytes.isEmpty { - wh.write(bytes) - } - - case .none: - break - } - } - - guard !destPath.isEmpty else { - throw GRPCStatus(code: .failedPrecondition, message: "copyDirIn: missing init message") - } - - try writeHandle?.close() - - let readFD = try FileDescriptor.open(FilePath(tempPath), .readOnly) - defer { try? readFD.close() } - let reader = TarReader(fileDescriptor: readFD, ownsFileDescriptor: false) - try Self.extractTar(reader: reader, to: URL(fileURLWithPath: destPath)) - - log.debug("copyDirIn complete", metadata: ["path": "\(destPath)"]) - return .init() - } - - func copyDirOut( - request: Com_Apple_Containerization_Sandbox_V3_CopyDirOutRequest, - responseStream: GRPCAsyncResponseStreamWriter, - context: GRPC.GRPCAsyncServerCallContext - ) async throws { - let path = request.path - log.debug("copyDirOut", metadata: ["path": "\(path)"]) - - // Write tar to a temp file, then stream it. - let tempPath = "/tmp/\(UUID().uuidString).tar" - defer { unlink(tempPath) } - - let sourceURL = URL(fileURLWithPath: path) - - let writeFD = try FileDescriptor.open( - FilePath(tempPath), - .writeOnly, - options: [.create, .truncate], - permissions: [.ownerReadWrite] - ) - let writer = TarWriter(fileDescriptor: writeFD, ownsFileDescriptor: false) - try Self.tarDirectory(source: sourceURL, writer: writer, basePath: "") - try writer.finalize() - try writeFD.close() - - let readHandle = try FileHandle(forReadingFrom: URL(fileURLWithPath: tempPath)) - defer { try? readHandle.close() } - - while true { - guard let data = try readHandle.read(upToCount: Self.copyChunkSize), !data.isEmpty else { - break - } - try await responseStream.send(.with { $0.data = data }) - } - - log.debug("copyDirOut complete", metadata: ["path": "\(path)"]) - } - /// Recursively walk a directory and write entries to a TarWriter. private static func tarDirectory(source: URL, writer: TarWriter, basePath: String) throws { let fm = FileManager.default From 7e4ee7507a06d53ce41f06887c596e8ef4b8bbe2 Mon Sep 17 00:00:00 2001 From: Simone Date: Thu, 5 Mar 2026 07:55:13 +0100 Subject: [PATCH 4/4] Harden extractTar against path traversal and symlink attacks --- Sources/Containerization/Vminitd.swift | 56 +++++++++++++++++--------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/Sources/Containerization/Vminitd.swift b/Sources/Containerization/Vminitd.swift index 376634b7..c84b27c5 100644 --- a/Sources/Containerization/Vminitd.swift +++ b/Sources/Containerization/Vminitd.swift @@ -653,32 +653,48 @@ extension Vminitd { /// Extract a tar archive to a destination directory. private static func extractTar(reader: TarReader, to destURL: URL) throws { let fm = FileManager.default + try fm.createDirectory(atPath: destURL.path, withIntermediateDirectories: true) + let fd = try FileDescriptor.open(FilePath(destURL.path), .readOnly) + defer { try? fd.close() } + while let header = try reader.nextHeader() { - let relativePath = header.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - guard !relativePath.contains("..") else { + let memberPath = FilePath(header.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))) + guard let lastComponent = memberPath.lastComponent else { try reader.skipRemainingContent() continue } - let fullURL = relativePath.isEmpty ? destURL : destURL.appending(path: relativePath) - - switch header.entryType { - case .directory: - try fm.createDirectory(at: fullURL, withIntermediateDirectories: true) - case .regular, .regularAlt, .contiguous: - let parentDir = fullURL.deletingLastPathComponent() - try fm.createDirectory(at: parentDir, withIntermediateDirectories: true) - let fd = open(fullURL.path, O_WRONLY | O_CREAT | O_TRUNC, mode_t(header.mode > 0 ? header.mode : 0o644)) - guard fd != -1 else { + let relativePath = memberPath.removingLastComponent() + + do { + switch header.entryType { + case .directory: + try fd.mkdirSecure(memberPath, makeIntermediates: true) + case .regular, .regularAlt, .contiguous: + try fd.mkdirSecure(relativePath, makeIntermediates: true) { dirFd in + try? dirFd.unlinkRecursiveSecure(filename: lastComponent) + + let maskedMode = mode_t(header.mode & 0o777) + let fileMode = maskedMode > 0 ? maskedMode : mode_t(0o644) + let fileFd = openat(dirFd.rawValue, lastComponent.string, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, fileMode) + guard fileFd >= 0 else { + try reader.skipRemainingContent() + return + } + let fileDescriptor = FileDescriptor(rawValue: fileFd) + defer { try? fileDescriptor.close() } + try reader.readFile(to: fileDescriptor) + } + case .symbolicLink: + try fd.mkdirSecure(relativePath, makeIntermediates: true) { dirFd in + try? dirFd.unlinkRecursiveSecure(filename: lastComponent) + guard symlinkat(header.linkName, dirFd.rawValue, lastComponent.string) == 0 else { + return + } + } + default: try reader.skipRemainingContent() - continue } - let fileFD = FileDescriptor(rawValue: fd) - defer { try? fileFD.close() } - try reader.readFile(to: fileFD) - case .symbolicLink: - try? fm.removeItem(at: fullURL) - try fm.createSymbolicLink(atPath: fullURL.path, withDestinationPath: header.linkName) - default: + } catch is SecurePathError { try reader.skipRemainingContent() } }