Skip to content

Commit 1b50c81

Browse files
Adds zstd decompression for layer content blobs. (#508)
- Closes apple/container#988. - macOS libarchive is not built with zstd support, so the workaround in ArchiveReader is to attempt to decompress every archive as zstd. If decompression fails, we pass the original archive to libarchive. If it succeeds, we pass the uncompressed archive. - Adds blob media type recognition for zstd to EXT4Unpacker. Tested zstd blob unpack using `image pull tonistiigi/hello-world:zstd-docker`. Co-authored-by: Aditya Ramani <a_ramani@apple.com>
1 parent 92b02fc commit 1b50c81

8 files changed

Lines changed: 199 additions & 6 deletions

File tree

Package.resolved

Lines changed: 10 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ let package = Package(
4747
.package(url: "https://github.com/apple/swift-system.git", from: "1.4.0"),
4848
.package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.1.0"),
4949
.package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.36.0"),
50+
.package(url: "https://github.com/facebook/zstd.git", exact: "1.5.7"),
5051
],
5152
targets: [
5253
.target(
@@ -143,17 +144,26 @@ let package = Package(
143144
name: "ContainerizationArchiveTests",
144145
dependencies: [
145146
"ContainerizationArchive"
147+
],
148+
resources: [
149+
.copy("Resources/test.tar.zst")
146150
]
147151
),
148152
.target(
149153
name: "CArchive",
150-
dependencies: [],
154+
dependencies: [
155+
.product(name: "libzstd", package: "zstd")
156+
],
151157
path: "Sources/ContainerizationArchive/CArchive",
158+
sources: [
159+
"archive_swift_bridge.c"
160+
],
152161
cSettings: [
153162
.define(
154163
"PLATFORM_CONFIG_H", to: "\"config_darwin.h\"",
155164
.when(platforms: [.iOS, .macOS, .macCatalyst, .watchOS, .driverKit, .tvOS])),
156165
.define("PLATFORM_CONFIG_H", to: "\"config_linux.h\"", .when(platforms: [.linux])),
166+
.unsafeFlags(["-fno-modules"]),
157167
],
158168
linkerSettings: [
159169
.linkedLibrary("z"),

Sources/Containerization/Image/Unpacker/EXT4Unpacker.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ public struct EXT4Unpacker: Unpacker {
9494
compression = .none
9595
case MediaTypes.imageLayerGzip, MediaTypes.dockerImageLayerGzip:
9696
compression = .gzip
97+
case MediaTypes.imageLayerZstd, MediaTypes.dockerImageLayerZstd:
98+
compression = .zstd
9799
default:
98100
throw ContainerizationError(.unsupported, message: "media type \(layer.mediaType) not supported.")
99101
}

Sources/ContainerizationArchive/ArchiveReader.swift

Lines changed: 110 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@
1515
//===----------------------------------------------------------------------===//
1616

1717
import CArchive
18+
import ContainerizationError
1819
import ContainerizationOS
1920
import Foundation
2021
import SystemPackage
22+
import libzstd
2123

2224
/// A protocol for reading data in chunks, compatible with both `InputStream` and zero-allocation archive readers.
2325
public protocol ReadableStream {
@@ -53,13 +55,32 @@ public final class ArchiveReader {
5355
var underlying: OpaquePointer?
5456
/// The file handle associated with the archive file being read.
5557
let fileHandle: FileHandle?
58+
/// Temporary decompressed file URL if the input was zstd-compressed
59+
private var tempDecompressedFile: URL?
5660

5761
/// Initializes an `ArchiveReader` to read from a specified file URL with an explicit `Format` and `Filter`.
5862
/// Note: This method must be used when it is known that the archive at the specified URL follows the specified
5963
/// `Format` and `Filter`.
6064
public convenience init(format: Format, filter: Filter, file: URL) throws {
61-
let fileHandle = try FileHandle(forReadingFrom: file)
62-
try self.init(format: format, filter: filter, fileHandle: fileHandle)
65+
// If filter is zstd, decompress it and use filter .none
66+
let fileToRead: URL
67+
let tempFile: URL?
68+
let actualFilter: Filter
69+
70+
if filter == .zstd {
71+
let decompressed = try Self.decompressZstd(file)
72+
tempFile = decompressed
73+
fileToRead = decompressed
74+
actualFilter = .none
75+
} else {
76+
tempFile = nil
77+
fileToRead = file
78+
actualFilter = filter
79+
}
80+
81+
let fileHandle = try FileHandle(forReadingFrom: fileToRead)
82+
try self.init(format: format, filter: actualFilter, fileHandle: fileHandle)
83+
self.tempDecompressedFile = tempFile
6384
}
6485

6586
/// Initializes an `ArchiveReader` to read from the provided file descriptor with an explicit `Format` and `Filter`.
@@ -82,8 +103,19 @@ public final class ArchiveReader {
82103
/// Initialize the `ArchiveReader` to read from a specified file URL
83104
/// by trying to auto determine the archives `Format` and `Filter`.
84105
public init(file: URL) throws {
106+
85107
self.underlying = archive_read_new()
86-
let fileHandle = try FileHandle(forReadingFrom: file)
108+
109+
// Try to decompress as zstd first, fall back to original if it fails
110+
let fileToRead: URL
111+
if let decompressed = try? Self.decompressZstd(file) {
112+
self.tempDecompressedFile = decompressed
113+
fileToRead = decompressed
114+
} else {
115+
fileToRead = file
116+
}
117+
118+
let fileHandle = try FileHandle(forReadingFrom: fileToRead)
87119
self.fileHandle = fileHandle
88120
try archive_read_support_filter_all(underlying)
89121
.checkOk(elseThrow: .failedToDetectFilter)
@@ -94,9 +126,84 @@ public final class ArchiveReader {
94126
.checkOk(elseThrow: { .unableToOpenArchive($0) })
95127
}
96128

129+
/// Decompress a zstd file to a temporary location
130+
private static func decompressZstd(_ source: URL) throws -> URL {
131+
guard let inputStream = InputStream(url: source) else {
132+
throw ArchiveError.noUnderlyingArchive
133+
}
134+
inputStream.open()
135+
defer { inputStream.close() }
136+
137+
// Create temp file into which the source zstd archived is decompressed
138+
guard let tempDir = createTemporaryDirectory(baseName: "zstd-decompress") else {
139+
throw ArchiveError.failedToDetectFormat
140+
}
141+
142+
let tempFile = tempDir.appendingPathComponent(
143+
source.deletingPathExtension().lastPathComponent
144+
)
145+
146+
guard let outputStream = OutputStream(url: tempFile, append: false) else {
147+
throw ArchiveError.noUnderlyingArchive
148+
}
149+
outputStream.open()
150+
defer { outputStream.close() }
151+
152+
// Use streaming decompression since content size may be unknown
153+
guard let dstream = ZSTD_createDStream() else {
154+
throw ArchiveError.failedToDetectFormat
155+
}
156+
defer { ZSTD_freeDStream(dstream) }
157+
158+
let initResult = ZSTD_initDStream(dstream)
159+
guard ZSTD_isError(initResult) == 0 else {
160+
throw ArchiveError.failedToDetectFormat
161+
}
162+
163+
let inputBufferSize = ZSTD_DStreamInSize()
164+
let outputBufferSize = ZSTD_DStreamOutSize()
165+
166+
var inputBuffer = [UInt8](repeating: 0, count: inputBufferSize)
167+
168+
while case let amount = inputStream.read(&inputBuffer, maxLength: inputBufferSize), amount > 0 {
169+
try inputBuffer.withUnsafeBufferPointer { ptr in
170+
var input = ZSTD_inBuffer(
171+
src: ptr.baseAddress,
172+
size: amount,
173+
pos: 0
174+
)
175+
while input.pos < input.size {
176+
var outputBuffer = [UInt8](repeating: 0, count: outputBufferSize)
177+
var decompressedBytes = 0
178+
try outputBuffer.withUnsafeMutableBytes { outputBytes in
179+
var output = ZSTD_outBuffer(
180+
dst: outputBytes.baseAddress,
181+
size: outputBufferSize,
182+
pos: 0
183+
)
184+
let result = ZSTD_decompressStream(dstream, &output, &input)
185+
guard ZSTD_isError(result) == 0 else {
186+
throw ArchiveError.failedToDetectFormat
187+
}
188+
decompressedBytes = output.pos
189+
}
190+
if decompressedBytes > 0 {
191+
outputStream.write(outputBuffer, maxLength: decompressedBytes)
192+
}
193+
}
194+
}
195+
}
196+
return tempFile
197+
}
198+
97199
deinit {
98200
archive_read_free(underlying)
99201
try? fileHandle?.close()
202+
203+
// Clean up temp decompressed file
204+
if let tempFile = tempDecompressedFile {
205+
try? FileManager.default.removeItem(at: tempFile.deletingLastPathComponent())
206+
}
100207
}
101208
}
102209

Sources/ContainerizationArchive/ArchiveWriterConfiguration.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ public enum Filter: String, Sendable {
174174
case lzop
175175
case grzip
176176
case lz4
177+
case zstd
177178

178179
internal var code: CInt {
179180
switch self {
@@ -190,6 +191,7 @@ public enum Filter: String, Sendable {
190191
case .lzop: return ARCHIVE_FILTER_LZOP
191192
case .grzip: return ARCHIVE_FILTER_GRZIP
192193
case .lz4: return ARCHIVE_FILTER_LZ4
194+
case .zstd: return ARCHIVE_FILTER_ZSTD
193195
}
194196
}
195197
}

Tests/ContainerizationArchiveTests/ArchiveReaderTests.swift

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,4 +657,58 @@ struct ArchiveReaderTests {
657657
_ = try reader.extractContents(to: extractDir)
658658
}
659659
}
660+
661+
// MARK: - Zstd Compression Tests
662+
663+
@Test func readZstdCompressedArchive() throws {
664+
guard let resourceURL = Bundle.module.url(forResource: "test", withExtension: "tar.zst") else {
665+
Issue.record("Test resource test.tar.zst not found")
666+
return
667+
}
668+
669+
let extractDir = try createExtractionDirectory(name: "zstd-test")
670+
defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }
671+
672+
// Test with explicit filter
673+
let reader = try ArchiveReader(format: .paxRestricted, filter: .zstd, file: resourceURL)
674+
let rejectedPaths = try reader.extractContents(to: extractDir)
675+
676+
#expect(rejectedPaths.isEmpty, "No paths should be rejected")
677+
678+
// Check extracted files
679+
let testFile = extractDir.appendingPathComponent("test.txt")
680+
let file2 = extractDir.appendingPathComponent("file2.txt")
681+
682+
#expect(FileManager.default.fileExists(atPath: testFile.path), "test.txt should exist")
683+
#expect(FileManager.default.fileExists(atPath: file2.path), "file2.txt should exist")
684+
685+
let testContent = try String(contentsOf: testFile, encoding: .utf8)
686+
#expect(testContent == "Hello from zstd compressed archive", "Content should match")
687+
688+
let file2Content = try String(contentsOf: file2, encoding: .utf8)
689+
#expect(file2Content == "Another file", "Content should match")
690+
}
691+
692+
@Test func readZstdCompressedArchiveAutoDetect() throws {
693+
guard let resourceURL = Bundle.module.url(forResource: "test", withExtension: "tar.zst") else {
694+
Issue.record("Test resource test.tar.zst not found")
695+
return
696+
}
697+
698+
let extractDir = try createExtractionDirectory(name: "zstd-auto-test")
699+
defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }
700+
701+
// Test with auto-detect
702+
let reader = try ArchiveReader(file: resourceURL)
703+
let rejectedPaths = try reader.extractContents(to: extractDir)
704+
705+
#expect(rejectedPaths.isEmpty, "No paths should be rejected")
706+
707+
// Check extracted files
708+
let testFile = extractDir.appendingPathComponent("test.txt")
709+
#expect(FileManager.default.fileExists(atPath: testFile.path), "test.txt should exist")
710+
711+
let testContent = try String(contentsOf: testFile, encoding: .utf8)
712+
#expect(testContent == "Hello from zstd compressed archive", "Content should match")
713+
}
660714
}
445 Bytes
Binary file not shown.

vminitd/Package.resolved

Lines changed: 10 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)