Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion Package.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,7 @@ let package = Package(
.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"),
.package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.36.0"),
.package(url: "https://github.com/facebook/zstd.git", exact: "1.5.7"),
],
targets: [
.target(
Expand DownExpand Up@@ -143,17 +144,26 @@ let package = Package(
name: "ContainerizationArchiveTests",
dependencies: [
"ContainerizationArchive"
],
resources: [
.copy("Resources/test.tar.zst")
]
),
.target(
name: "CArchive",
dependencies: [],
dependencies: [
.product(name: "libzstd", package: "zstd")
],
path: "Sources/ContainerizationArchive/CArchive",
sources: [
"archive_swift_bridge.c"
],
cSettings: [
.define(
"PLATFORM_CONFIG_H", to: "\"config_darwin.h\"",
.when(platforms: [.iOS, .macOS, .macCatalyst, .watchOS, .driverKit, .tvOS])),
.define("PLATFORM_CONFIG_H", to: "\"config_linux.h\"", .when(platforms: [.linux])),
.unsafeFlags(["-fno-modules"]),
],
linkerSettings: [
.linkedLibrary("z"),
Expand Down
2 changes: 2 additions & 0 deletions Sources/Containerization/Image/Unpacker/EXT4Unpacker.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,6 +94,8 @@ public struct EXT4Unpacker: Unpacker {
compression = .none
case MediaTypes.imageLayerGzip, MediaTypes.dockerImageLayerGzip:
compression = .gzip
case MediaTypes.imageLayerZstd, MediaTypes.dockerImageLayerZstd:
compression = .zstd
default:
throw ContainerizationError(.unsupported, message: "media type \(layer.mediaType) not supported.")
}
Expand Down
113 changes: 110 additions & 3 deletions Sources/ContainerizationArchive/ArchiveReader.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,9 +15,11 @@
//===----------------------------------------------------------------------===//

import CArchive
import ContainerizationError
import ContainerizationOS
import Foundation
import SystemPackage
import libzstd

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

/// Initializes an `ArchiveReader` to read from a specified file URL with an explicit `Format` and `Filter`.
/// Note: This method must be used when it is known that the archive at the specified URL follows the specified
/// `Format` and `Filter`.
public convenience init(format: Format, filter: Filter, file: URL) throws {
let fileHandle = try FileHandle(forReadingFrom: file)
try self.init(format: format, filter: filter, fileHandle: fileHandle)
// If filter is zstd, decompress it and use filter .none
let fileToRead: URL
let tempFile: URL?
let actualFilter: Filter

if filter == .zstd {
let decompressed = try Self.decompressZstd(file)
tempFile = decompressed
fileToRead = decompressed
actualFilter = .none
} else {
tempFile = nil
fileToRead = file
actualFilter = filter
}

let fileHandle = try FileHandle(forReadingFrom: fileToRead)
try self.init(format: format, filter: actualFilter, fileHandle: fileHandle)
self.tempDecompressedFile = tempFile
}

/// Initializes an `ArchiveReader` to read from the provided file descriptor with an explicit `Format` and `Filter`.
Expand All@@ -82,8 +103,19 @@ public final class ArchiveReader {
/// Initialize the `ArchiveReader` to read from a specified file URL
/// by trying to auto determine the archives `Format` and `Filter`.
public init(file: URL) throws {

self.underlying = archive_read_new()
let fileHandle = try FileHandle(forReadingFrom: file)

// Try to decompress as zstd first, fall back to original if it fails
let fileToRead: URL
if let decompressed = try? Self.decompressZstd(file) {
self.tempDecompressedFile = decompressed
fileToRead = decompressed
} else {
fileToRead = file
}

let fileHandle = try FileHandle(forReadingFrom: fileToRead)
self.fileHandle = fileHandle
try archive_read_support_filter_all(underlying)
.checkOk(elseThrow: .failedToDetectFilter)
Expand All@@ -94,9 +126,84 @@ public final class ArchiveReader {
.checkOk(elseThrow: { .unableToOpenArchive($0) })
}

/// Decompress a zstd file to a temporary location
private static func decompressZstd(_ source: URL) throws -> URL {
guard let inputStream = InputStream(url: source) else {
throw ArchiveError.noUnderlyingArchive
}
inputStream.open()
defer { inputStream.close() }

// Create temp file into which the source zstd archived is decompressed
guard let tempDir = createTemporaryDirectory(baseName: "zstd-decompress") else {
throw ArchiveError.failedToDetectFormat
}

let tempFile = tempDir.appendingPathComponent(
source.deletingPathExtension().lastPathComponent
)

guard let outputStream = OutputStream(url: tempFile, append: false) else {
throw ArchiveError.noUnderlyingArchive
}
outputStream.open()
defer { outputStream.close() }

// Use streaming decompression since content size may be unknown
guard let dstream = ZSTD_createDStream() else {
throw ArchiveError.failedToDetectFormat
}
defer { ZSTD_freeDStream(dstream) }

let initResult = ZSTD_initDStream(dstream)
guard ZSTD_isError(initResult) == 0 else {
throw ArchiveError.failedToDetectFormat
}

let inputBufferSize = ZSTD_DStreamInSize()
let outputBufferSize = ZSTD_DStreamOutSize()

var inputBuffer = [UInt8](repeating: 0, count: inputBufferSize)

while case let amount = inputStream.read(&inputBuffer, maxLength: inputBufferSize), amount > 0 {
try inputBuffer.withUnsafeBufferPointer { ptr in
var input = ZSTD_inBuffer(
src: ptr.baseAddress,
size: amount,
pos: 0
)
while input.pos < input.size {
var outputBuffer = [UInt8](repeating: 0, count: outputBufferSize)
var decompressedBytes = 0
try outputBuffer.withUnsafeMutableBytes { outputBytes in
var output = ZSTD_outBuffer(
dst: outputBytes.baseAddress,
size: outputBufferSize,
pos: 0
)
let result = ZSTD_decompressStream(dstream, &output, &input)
guard ZSTD_isError(result) == 0 else {
throw ArchiveError.failedToDetectFormat
}
decompressedBytes = output.pos
}
if decompressedBytes > 0 {
outputStream.write(outputBuffer, maxLength: decompressedBytes)
}
}
}
}
return tempFile
}

deinit {
archive_read_free(underlying)
try? fileHandle?.close()

// Clean up temp decompressed file
if let tempFile = tempDecompressedFile {
try? FileManager.default.removeItem(at: tempFile.deletingLastPathComponent())
}
}
}

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -174,6 +174,7 @@ public enum Filter: String, Sendable {
case lzop
case grzip
case lz4
case zstd

internal var code: CInt {
switch self {
Expand All@@ -190,6 +191,7 @@ public enum Filter: String, Sendable {
case .lzop: return ARCHIVE_FILTER_LZOP
case .grzip: return ARCHIVE_FILTER_GRZIP
case .lz4: return ARCHIVE_FILTER_LZ4
case .zstd: return ARCHIVE_FILTER_ZSTD
}
}
}
54 changes: 54 additions & 0 deletions Tests/ContainerizationArchiveTests/ArchiveReaderTests.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -657,4 +657,58 @@ struct ArchiveReaderTests {
_ = try reader.extractContents(to: extractDir)
}
}

// MARK: - Zstd Compression Tests

@Test func readZstdCompressedArchive() throws {
guard let resourceURL = Bundle.module.url(forResource: "test", withExtension: "tar.zst") else {
Issue.record("Test resource test.tar.zst not found")
return
}

let extractDir = try createExtractionDirectory(name: "zstd-test")
defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }

// Test with explicit filter
let reader = try ArchiveReader(format: .paxRestricted, filter: .zstd, file: resourceURL)
let rejectedPaths = try reader.extractContents(to: extractDir)

#expect(rejectedPaths.isEmpty, "No paths should be rejected")

// Check extracted files
let testFile = extractDir.appendingPathComponent("test.txt")
let file2 = extractDir.appendingPathComponent("file2.txt")

#expect(FileManager.default.fileExists(atPath: testFile.path), "test.txt should exist")
#expect(FileManager.default.fileExists(atPath: file2.path), "file2.txt should exist")

let testContent = try String(contentsOf: testFile, encoding: .utf8)
#expect(testContent == "Hello from zstd compressed archive", "Content should match")

let file2Content = try String(contentsOf: file2, encoding: .utf8)
#expect(file2Content == "Another file", "Content should match")
}

@Test func readZstdCompressedArchiveAutoDetect() throws {
guard let resourceURL = Bundle.module.url(forResource: "test", withExtension: "tar.zst") else {
Issue.record("Test resource test.tar.zst not found")
return
}

let extractDir = try createExtractionDirectory(name: "zstd-auto-test")
defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }

// Test with auto-detect
let reader = try ArchiveReader(file: resourceURL)
let rejectedPaths = try reader.extractContents(to: extractDir)

#expect(rejectedPaths.isEmpty, "No paths should be rejected")

// Check extracted files
let testFile = extractDir.appendingPathComponent("test.txt")
#expect(FileManager.default.fileExists(atPath: testFile.path), "test.txt should exist")

let testContent = try String(contentsOf: testFile, encoding: .utf8)
#expect(testContent == "Hello from zstd compressed archive", "Content should match")
}
}
Binary file not shown.
11 changes: 10 additions & 1 deletion vminitd/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion Package.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,7 @@ let package = Package(
.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"),
.package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.36.0"),
.package(url: "https://github.com/facebook/zstd.git", exact: "1.5.7"),
],
targets: [
.target(
Expand DownExpand Up@@ -143,17 +144,26 @@ let package = Package(
name: "ContainerizationArchiveTests",
dependencies: [
"ContainerizationArchive"
],
resources: [
.copy("Resources/test.tar.zst")
]
),
.target(
name: "CArchive",
dependencies: [],
dependencies: [
.product(name: "libzstd", package: "zstd")
],
path: "Sources/ContainerizationArchive/CArchive",
sources: [
"archive_swift_bridge.c"
],
cSettings: [
.define(
"PLATFORM_CONFIG_H", to: "\"config_darwin.h\"",
.when(platforms: [.iOS, .macOS, .macCatalyst, .watchOS, .driverKit, .tvOS])),
.define("PLATFORM_CONFIG_H", to: "\"config_linux.h\"", .when(platforms: [.linux])),
.unsafeFlags(["-fno-modules"]),
],
linkerSettings: [
.linkedLibrary("z"),
Expand Down
2 changes: 2 additions & 0 deletions Sources/Containerization/Image/Unpacker/EXT4Unpacker.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,6 +94,8 @@ public struct EXT4Unpacker: Unpacker {
compression = .none
case MediaTypes.imageLayerGzip, MediaTypes.dockerImageLayerGzip:
compression = .gzip
case MediaTypes.imageLayerZstd, MediaTypes.dockerImageLayerZstd:
compression = .zstd
default:
throw ContainerizationError(.unsupported, message: "media type \(layer.mediaType) not supported.")
}
Expand Down
113 changes: 110 additions & 3 deletions Sources/ContainerizationArchive/ArchiveReader.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,9 +15,11 @@
//===----------------------------------------------------------------------===//

import CArchive
import ContainerizationError
import ContainerizationOS
import Foundation
import SystemPackage
import libzstd

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

/// Initializes an `ArchiveReader` to read from a specified file URL with an explicit `Format` and `Filter`.
/// Note: This method must be used when it is known that the archive at the specified URL follows the specified
/// `Format` and `Filter`.
public convenience init(format: Format, filter: Filter, file: URL) throws {
let fileHandle = try FileHandle(forReadingFrom: file)
try self.init(format: format, filter: filter, fileHandle: fileHandle)
// If filter is zstd, decompress it and use filter .none
let fileToRead: URL
let tempFile: URL?
let actualFilter: Filter

if filter == .zstd {
let decompressed = try Self.decompressZstd(file)
tempFile = decompressed
fileToRead = decompressed
actualFilter = .none
} else {
tempFile = nil
fileToRead = file
actualFilter = filter
}

let fileHandle = try FileHandle(forReadingFrom: fileToRead)
try self.init(format: format, filter: actualFilter, fileHandle: fileHandle)
self.tempDecompressedFile = tempFile
}

/// Initializes an `ArchiveReader` to read from the provided file descriptor with an explicit `Format` and `Filter`.
Expand All@@ -82,8 +103,19 @@ public final class ArchiveReader {
/// Initialize the `ArchiveReader` to read from a specified file URL
/// by trying to auto determine the archives `Format` and `Filter`.
public init(file: URL) throws {

self.underlying = archive_read_new()
let fileHandle = try FileHandle(forReadingFrom: file)

// Try to decompress as zstd first, fall back to original if it fails
let fileToRead: URL
if let decompressed = try? Self.decompressZstd(file) {
self.tempDecompressedFile = decompressed
fileToRead = decompressed
} else {
fileToRead = file
}

let fileHandle = try FileHandle(forReadingFrom: fileToRead)
self.fileHandle = fileHandle
try archive_read_support_filter_all(underlying)
.checkOk(elseThrow: .failedToDetectFilter)
Expand All@@ -94,9 +126,84 @@ public final class ArchiveReader {
.checkOk(elseThrow: { .unableToOpenArchive($0) })
}

/// Decompress a zstd file to a temporary location
private static func decompressZstd(_ source: URL) throws -> URL {
guard let inputStream = InputStream(url: source) else {
throw ArchiveError.noUnderlyingArchive
}
inputStream.open()
defer { inputStream.close() }

// Create temp file into which the source zstd archived is decompressed
guard let tempDir = createTemporaryDirectory(baseName: "zstd-decompress") else {
throw ArchiveError.failedToDetectFormat
}

let tempFile = tempDir.appendingPathComponent(
source.deletingPathExtension().lastPathComponent
)

guard let outputStream = OutputStream(url: tempFile, append: false) else {
throw ArchiveError.noUnderlyingArchive
}
outputStream.open()
defer { outputStream.close() }

// Use streaming decompression since content size may be unknown
guard let dstream = ZSTD_createDStream() else {
throw ArchiveError.failedToDetectFormat
}
defer { ZSTD_freeDStream(dstream) }

let initResult = ZSTD_initDStream(dstream)
guard ZSTD_isError(initResult) == 0 else {
throw ArchiveError.failedToDetectFormat
}

let inputBufferSize = ZSTD_DStreamInSize()
let outputBufferSize = ZSTD_DStreamOutSize()

var inputBuffer = [UInt8](repeating: 0, count: inputBufferSize)

while case let amount = inputStream.read(&inputBuffer, maxLength: inputBufferSize), amount > 0 {
try inputBuffer.withUnsafeBufferPointer { ptr in
var input = ZSTD_inBuffer(
src: ptr.baseAddress,
size: amount,
pos: 0
)
while input.pos < input.size {
var outputBuffer = [UInt8](repeating: 0, count: outputBufferSize)
var decompressedBytes = 0
try outputBuffer.withUnsafeMutableBytes { outputBytes in
var output = ZSTD_outBuffer(
dst: outputBytes.baseAddress,
size: outputBufferSize,
pos: 0
)
let result = ZSTD_decompressStream(dstream, &output, &input)
guard ZSTD_isError(result) == 0 else {
throw ArchiveError.failedToDetectFormat
}
decompressedBytes = output.pos
}
if decompressedBytes > 0 {
outputStream.write(outputBuffer, maxLength: decompressedBytes)
}
}
}
}
return tempFile
}

deinit {
archive_read_free(underlying)
try? fileHandle?.close()

// Clean up temp decompressed file
if let tempFile = tempDecompressedFile {
try? FileManager.default.removeItem(at: tempFile.deletingLastPathComponent())
}
}
}

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -174,6 +174,7 @@ public enum Filter: String, Sendable {
case lzop
case grzip
case lz4
case zstd

internal var code: CInt {
switch self {
Expand All@@ -190,6 +191,7 @@ public enum Filter: String, Sendable {
case .lzop: return ARCHIVE_FILTER_LZOP
case .grzip: return ARCHIVE_FILTER_GRZIP
case .lz4: return ARCHIVE_FILTER_LZ4
case .zstd: return ARCHIVE_FILTER_ZSTD
}
}
}
54 changes: 54 additions & 0 deletions Tests/ContainerizationArchiveTests/ArchiveReaderTests.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -657,4 +657,58 @@ struct ArchiveReaderTests {
_ = try reader.extractContents(to: extractDir)
}
}

// MARK: - Zstd Compression Tests

@Test func readZstdCompressedArchive() throws {
guard let resourceURL = Bundle.module.url(forResource: "test", withExtension: "tar.zst") else {
Issue.record("Test resource test.tar.zst not found")
return
}

let extractDir = try createExtractionDirectory(name: "zstd-test")
defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }

// Test with explicit filter
let reader = try ArchiveReader(format: .paxRestricted, filter: .zstd, file: resourceURL)
let rejectedPaths = try reader.extractContents(to: extractDir)

#expect(rejectedPaths.isEmpty, "No paths should be rejected")

// Check extracted files
let testFile = extractDir.appendingPathComponent("test.txt")
let file2 = extractDir.appendingPathComponent("file2.txt")

#expect(FileManager.default.fileExists(atPath: testFile.path), "test.txt should exist")
#expect(FileManager.default.fileExists(atPath: file2.path), "file2.txt should exist")

let testContent = try String(contentsOf: testFile, encoding: .utf8)
#expect(testContent == "Hello from zstd compressed archive", "Content should match")

let file2Content = try String(contentsOf: file2, encoding: .utf8)
#expect(file2Content == "Another file", "Content should match")
}

@Test func readZstdCompressedArchiveAutoDetect() throws {
guard let resourceURL = Bundle.module.url(forResource: "test", withExtension: "tar.zst") else {
Issue.record("Test resource test.tar.zst not found")
return
}

let extractDir = try createExtractionDirectory(name: "zstd-auto-test")
defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }

// Test with auto-detect
let reader = try ArchiveReader(file: resourceURL)
let rejectedPaths = try reader.extractContents(to: extractDir)

#expect(rejectedPaths.isEmpty, "No paths should be rejected")

// Check extracted files
let testFile = extractDir.appendingPathComponent("test.txt")
#expect(FileManager.default.fileExists(atPath: testFile.path), "test.txt should exist")

let testContent = try String(contentsOf: testFile, encoding: .utf8)
#expect(testContent == "Hello from zstd compressed archive", "Content should match")
}
}
Binary file not shown.
11 changes: 10 additions & 1 deletion vminitd/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion Package.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,7 @@ let package = Package(
.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"),
.package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.36.0"),
.package(url: "https://github.com/facebook/zstd.git", exact: "1.5.7"),
],
targets: [
.target(
Expand DownExpand Up@@ -143,17 +144,26 @@ let package = Package(
name: "ContainerizationArchiveTests",
dependencies: [
"ContainerizationArchive"
],
resources: [
.copy("Resources/test.tar.zst")
]
),
.target(
name: "CArchive",
dependencies: [],
dependencies: [
.product(name: "libzstd", package: "zstd")
],
path: "Sources/ContainerizationArchive/CArchive",
sources: [
"archive_swift_bridge.c"
],
cSettings: [
.define(
"PLATFORM_CONFIG_H", to: "\"config_darwin.h\"",
.when(platforms: [.iOS, .macOS, .macCatalyst, .watchOS, .driverKit, .tvOS])),
.define("PLATFORM_CONFIG_H", to: "\"config_linux.h\"", .when(platforms: [.linux])),
.unsafeFlags(["-fno-modules"]),
],
linkerSettings: [
.linkedLibrary("z"),
Expand Down
2 changes: 2 additions & 0 deletions Sources/Containerization/Image/Unpacker/EXT4Unpacker.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,6 +94,8 @@ public struct EXT4Unpacker: Unpacker {
compression = .none
case MediaTypes.imageLayerGzip, MediaTypes.dockerImageLayerGzip:
compression = .gzip
case MediaTypes.imageLayerZstd, MediaTypes.dockerImageLayerZstd:
compression = .zstd
default:
throw ContainerizationError(.unsupported, message: "media type \(layer.mediaType) not supported.")
}
Expand Down
113 changes: 110 additions & 3 deletions Sources/ContainerizationArchive/ArchiveReader.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,9 +15,11 @@
//===----------------------------------------------------------------------===//

import CArchive
import ContainerizationError
import ContainerizationOS
import Foundation
import SystemPackage
import libzstd

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

/// Initializes an `ArchiveReader` to read from a specified file URL with an explicit `Format` and `Filter`.
/// Note: This method must be used when it is known that the archive at the specified URL follows the specified
/// `Format` and `Filter`.
public convenience init(format: Format, filter: Filter, file: URL) throws {
let fileHandle = try FileHandle(forReadingFrom: file)
try self.init(format: format, filter: filter, fileHandle: fileHandle)
// If filter is zstd, decompress it and use filter .none
let fileToRead: URL
let tempFile: URL?
let actualFilter: Filter

if filter == .zstd {
let decompressed = try Self.decompressZstd(file)
tempFile = decompressed
fileToRead = decompressed
actualFilter = .none
} else {
tempFile = nil
fileToRead = file
actualFilter = filter
}

let fileHandle = try FileHandle(forReadingFrom: fileToRead)
try self.init(format: format, filter: actualFilter, fileHandle: fileHandle)
self.tempDecompressedFile = tempFile
}

/// Initializes an `ArchiveReader` to read from the provided file descriptor with an explicit `Format` and `Filter`.
Expand All@@ -82,8 +103,19 @@ public final class ArchiveReader {
/// Initialize the `ArchiveReader` to read from a specified file URL
/// by trying to auto determine the archives `Format` and `Filter`.
public init(file: URL) throws {

self.underlying = archive_read_new()
let fileHandle = try FileHandle(forReadingFrom: file)

// Try to decompress as zstd first, fall back to original if it fails
let fileToRead: URL
if let decompressed = try? Self.decompressZstd(file) {
self.tempDecompressedFile = decompressed
fileToRead = decompressed
} else {
fileToRead = file
}

let fileHandle = try FileHandle(forReadingFrom: fileToRead)
self.fileHandle = fileHandle
try archive_read_support_filter_all(underlying)
.checkOk(elseThrow: .failedToDetectFilter)
Expand All@@ -94,9 +126,84 @@ public final class ArchiveReader {
.checkOk(elseThrow: { .unableToOpenArchive($0) })
}

/// Decompress a zstd file to a temporary location
private static func decompressZstd(_ source: URL) throws -> URL {
guard let inputStream = InputStream(url: source) else {
throw ArchiveError.noUnderlyingArchive
}
inputStream.open()
defer { inputStream.close() }

// Create temp file into which the source zstd archived is decompressed
guard let tempDir = createTemporaryDirectory(baseName: "zstd-decompress") else {
throw ArchiveError.failedToDetectFormat
}

let tempFile = tempDir.appendingPathComponent(
source.deletingPathExtension().lastPathComponent
)

guard let outputStream = OutputStream(url: tempFile, append: false) else {
throw ArchiveError.noUnderlyingArchive
}
outputStream.open()
defer { outputStream.close() }

// Use streaming decompression since content size may be unknown
guard let dstream = ZSTD_createDStream() else {
throw ArchiveError.failedToDetectFormat
}
defer { ZSTD_freeDStream(dstream) }

let initResult = ZSTD_initDStream(dstream)
guard ZSTD_isError(initResult) == 0 else {
throw ArchiveError.failedToDetectFormat
}

let inputBufferSize = ZSTD_DStreamInSize()
let outputBufferSize = ZSTD_DStreamOutSize()

var inputBuffer = [UInt8](repeating: 0, count: inputBufferSize)

while case let amount = inputStream.read(&inputBuffer, maxLength: inputBufferSize), amount > 0 {
try inputBuffer.withUnsafeBufferPointer { ptr in
var input = ZSTD_inBuffer(
src: ptr.baseAddress,
size: amount,
pos: 0
)
while input.pos < input.size {
var outputBuffer = [UInt8](repeating: 0, count: outputBufferSize)
var decompressedBytes = 0
try outputBuffer.withUnsafeMutableBytes { outputBytes in
var output = ZSTD_outBuffer(
dst: outputBytes.baseAddress,
size: outputBufferSize,
pos: 0
)
let result = ZSTD_decompressStream(dstream, &output, &input)
guard ZSTD_isError(result) == 0 else {
throw ArchiveError.failedToDetectFormat
}
decompressedBytes = output.pos
}
if decompressedBytes > 0 {
outputStream.write(outputBuffer, maxLength: decompressedBytes)
}
}
}
}
return tempFile
}

deinit {
archive_read_free(underlying)
try? fileHandle?.close()

// Clean up temp decompressed file
if let tempFile = tempDecompressedFile {
try? FileManager.default.removeItem(at: tempFile.deletingLastPathComponent())
}
}
}

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -174,6 +174,7 @@ public enum Filter: String, Sendable {
case lzop
case grzip
case lz4
case zstd

internal var code: CInt {
switch self {
Expand All@@ -190,6 +191,7 @@ public enum Filter: String, Sendable {
case .lzop: return ARCHIVE_FILTER_LZOP
case .grzip: return ARCHIVE_FILTER_GRZIP
case .lz4: return ARCHIVE_FILTER_LZ4
case .zstd: return ARCHIVE_FILTER_ZSTD
}
}
}
54 changes: 54 additions & 0 deletions Tests/ContainerizationArchiveTests/ArchiveReaderTests.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -657,4 +657,58 @@ struct ArchiveReaderTests {
_ = try reader.extractContents(to: extractDir)
}
}

// MARK: - Zstd Compression Tests

@Test func readZstdCompressedArchive() throws {
guard let resourceURL = Bundle.module.url(forResource: "test", withExtension: "tar.zst") else {
Issue.record("Test resource test.tar.zst not found")
return
}

let extractDir = try createExtractionDirectory(name: "zstd-test")
defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }

// Test with explicit filter
let reader = try ArchiveReader(format: .paxRestricted, filter: .zstd, file: resourceURL)
let rejectedPaths = try reader.extractContents(to: extractDir)

#expect(rejectedPaths.isEmpty, "No paths should be rejected")

// Check extracted files
let testFile = extractDir.appendingPathComponent("test.txt")
let file2 = extractDir.appendingPathComponent("file2.txt")

#expect(FileManager.default.fileExists(atPath: testFile.path), "test.txt should exist")
#expect(FileManager.default.fileExists(atPath: file2.path), "file2.txt should exist")

let testContent = try String(contentsOf: testFile, encoding: .utf8)
#expect(testContent == "Hello from zstd compressed archive", "Content should match")

let file2Content = try String(contentsOf: file2, encoding: .utf8)
#expect(file2Content == "Another file", "Content should match")
}

@Test func readZstdCompressedArchiveAutoDetect() throws {
guard let resourceURL = Bundle.module.url(forResource: "test", withExtension: "tar.zst") else {
Issue.record("Test resource test.tar.zst not found")
return
}

let extractDir = try createExtractionDirectory(name: "zstd-auto-test")
defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }

// Test with auto-detect
let reader = try ArchiveReader(file: resourceURL)
let rejectedPaths = try reader.extractContents(to: extractDir)

#expect(rejectedPaths.isEmpty, "No paths should be rejected")

// Check extracted files
let testFile = extractDir.appendingPathComponent("test.txt")
#expect(FileManager.default.fileExists(atPath: testFile.path), "test.txt should exist")

let testContent = try String(contentsOf: testFile, encoding: .utf8)
#expect(testContent == "Hello from zstd compressed archive", "Content should match")
}
}
Binary file not shown.
11 changes: 10 additions & 1 deletion vminitd/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion Package.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,7 @@ let package = Package(
.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"),
.package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.36.0"),
.package(url: "https://github.com/facebook/zstd.git", exact: "1.5.7"),
],
targets: [
.target(
Expand DownExpand Up@@ -143,17 +144,26 @@ let package = Package(
name: "ContainerizationArchiveTests",
dependencies: [
"ContainerizationArchive"
],
resources: [
.copy("Resources/test.tar.zst")
]
),
.target(
name: "CArchive",
dependencies: [],
dependencies: [
.product(name: "libzstd", package: "zstd")
],
path: "Sources/ContainerizationArchive/CArchive",
sources: [
"archive_swift_bridge.c"
],
cSettings: [
.define(
"PLATFORM_CONFIG_H", to: "\"config_darwin.h\"",
.when(platforms: [.iOS, .macOS, .macCatalyst, .watchOS, .driverKit, .tvOS])),
.define("PLATFORM_CONFIG_H", to: "\"config_linux.h\"", .when(platforms: [.linux])),
.unsafeFlags(["-fno-modules"]),
],
linkerSettings: [
.linkedLibrary("z"),
Expand Down
2 changes: 2 additions & 0 deletions Sources/Containerization/Image/Unpacker/EXT4Unpacker.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,6 +94,8 @@ public struct EXT4Unpacker: Unpacker {
compression = .none
case MediaTypes.imageLayerGzip, MediaTypes.dockerImageLayerGzip:
compression = .gzip
case MediaTypes.imageLayerZstd, MediaTypes.dockerImageLayerZstd:
compression = .zstd
default:
throw ContainerizationError(.unsupported, message: "media type \(layer.mediaType) not supported.")
}
Expand Down
113 changes: 110 additions & 3 deletions Sources/ContainerizationArchive/ArchiveReader.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,9 +15,11 @@
//===----------------------------------------------------------------------===//

import CArchive
import ContainerizationError
import ContainerizationOS
import Foundation
import SystemPackage
import libzstd

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

/// Initializes an `ArchiveReader` to read from a specified file URL with an explicit `Format` and `Filter`.
/// Note: This method must be used when it is known that the archive at the specified URL follows the specified
/// `Format` and `Filter`.
public convenience init(format: Format, filter: Filter, file: URL) throws {
let fileHandle = try FileHandle(forReadingFrom: file)
try self.init(format: format, filter: filter, fileHandle: fileHandle)
// If filter is zstd, decompress it and use filter .none
let fileToRead: URL
let tempFile: URL?
let actualFilter: Filter

if filter == .zstd {
let decompressed = try Self.decompressZstd(file)
tempFile = decompressed
fileToRead = decompressed
actualFilter = .none
} else {
tempFile = nil
fileToRead = file
actualFilter = filter
}

let fileHandle = try FileHandle(forReadingFrom: fileToRead)
try self.init(format: format, filter: actualFilter, fileHandle: fileHandle)
self.tempDecompressedFile = tempFile
}

/// Initializes an `ArchiveReader` to read from the provided file descriptor with an explicit `Format` and `Filter`.
Expand All@@ -82,8 +103,19 @@ public final class ArchiveReader {
/// Initialize the `ArchiveReader` to read from a specified file URL
/// by trying to auto determine the archives `Format` and `Filter`.
public init(file: URL) throws {

self.underlying = archive_read_new()
let fileHandle = try FileHandle(forReadingFrom: file)

// Try to decompress as zstd first, fall back to original if it fails
let fileToRead: URL
if let decompressed = try? Self.decompressZstd(file) {
self.tempDecompressedFile = decompressed
fileToRead = decompressed
} else {
fileToRead = file
}

let fileHandle = try FileHandle(forReadingFrom: fileToRead)
self.fileHandle = fileHandle
try archive_read_support_filter_all(underlying)
.checkOk(elseThrow: .failedToDetectFilter)
Expand All@@ -94,9 +126,84 @@ public final class ArchiveReader {
.checkOk(elseThrow: { .unableToOpenArchive($0) })
}

/// Decompress a zstd file to a temporary location
private static func decompressZstd(_ source: URL) throws -> URL {
guard let inputStream = InputStream(url: source) else {
throw ArchiveError.noUnderlyingArchive
}
inputStream.open()
defer { inputStream.close() }

// Create temp file into which the source zstd archived is decompressed
guard let tempDir = createTemporaryDirectory(baseName: "zstd-decompress") else {
throw ArchiveError.failedToDetectFormat
}

let tempFile = tempDir.appendingPathComponent(
source.deletingPathExtension().lastPathComponent
)

guard let outputStream = OutputStream(url: tempFile, append: false) else {
throw ArchiveError.noUnderlyingArchive
}
outputStream.open()
defer { outputStream.close() }

// Use streaming decompression since content size may be unknown
guard let dstream = ZSTD_createDStream() else {
throw ArchiveError.failedToDetectFormat
}
defer { ZSTD_freeDStream(dstream) }

let initResult = ZSTD_initDStream(dstream)
guard ZSTD_isError(initResult) == 0 else {
throw ArchiveError.failedToDetectFormat
}

let inputBufferSize = ZSTD_DStreamInSize()
let outputBufferSize = ZSTD_DStreamOutSize()

var inputBuffer = [UInt8](repeating: 0, count: inputBufferSize)

while case let amount = inputStream.read(&inputBuffer, maxLength: inputBufferSize), amount > 0 {
try inputBuffer.withUnsafeBufferPointer { ptr in
var input = ZSTD_inBuffer(
src: ptr.baseAddress,
size: amount,
pos: 0
)
while input.pos < input.size {
var outputBuffer = [UInt8](repeating: 0, count: outputBufferSize)
var decompressedBytes = 0
try outputBuffer.withUnsafeMutableBytes { outputBytes in
var output = ZSTD_outBuffer(
dst: outputBytes.baseAddress,
size: outputBufferSize,
pos: 0
)
let result = ZSTD_decompressStream(dstream, &output, &input)
guard ZSTD_isError(result) == 0 else {
throw ArchiveError.failedToDetectFormat
}
decompressedBytes = output.pos
}
if decompressedBytes > 0 {
outputStream.write(outputBuffer, maxLength: decompressedBytes)
}
}
}
}
return tempFile
}

deinit {
archive_read_free(underlying)
try? fileHandle?.close()

// Clean up temp decompressed file
if let tempFile = tempDecompressedFile {
try? FileManager.default.removeItem(at: tempFile.deletingLastPathComponent())
}
}
}

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -174,6 +174,7 @@ public enum Filter: String, Sendable {
case lzop
case grzip
case lz4
case zstd

internal var code: CInt {
switch self {
Expand All@@ -190,6 +191,7 @@ public enum Filter: String, Sendable {
case .lzop: return ARCHIVE_FILTER_LZOP
case .grzip: return ARCHIVE_FILTER_GRZIP
case .lz4: return ARCHIVE_FILTER_LZ4
case .zstd: return ARCHIVE_FILTER_ZSTD
}
}
}
54 changes: 54 additions & 0 deletions Tests/ContainerizationArchiveTests/ArchiveReaderTests.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -657,4 +657,58 @@ struct ArchiveReaderTests {
_ = try reader.extractContents(to: extractDir)
}
}

// MARK: - Zstd Compression Tests

@Test func readZstdCompressedArchive() throws {
guard let resourceURL = Bundle.module.url(forResource: "test", withExtension: "tar.zst") else {
Issue.record("Test resource test.tar.zst not found")
return
}

let extractDir = try createExtractionDirectory(name: "zstd-test")
defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }

// Test with explicit filter
let reader = try ArchiveReader(format: .paxRestricted, filter: .zstd, file: resourceURL)
let rejectedPaths = try reader.extractContents(to: extractDir)

#expect(rejectedPaths.isEmpty, "No paths should be rejected")

// Check extracted files
let testFile = extractDir.appendingPathComponent("test.txt")
let file2 = extractDir.appendingPathComponent("file2.txt")

#expect(FileManager.default.fileExists(atPath: testFile.path), "test.txt should exist")
#expect(FileManager.default.fileExists(atPath: file2.path), "file2.txt should exist")

let testContent = try String(contentsOf: testFile, encoding: .utf8)
#expect(testContent == "Hello from zstd compressed archive", "Content should match")

let file2Content = try String(contentsOf: file2, encoding: .utf8)
#expect(file2Content == "Another file", "Content should match")
}

@Test func readZstdCompressedArchiveAutoDetect() throws {
guard let resourceURL = Bundle.module.url(forResource: "test", withExtension: "tar.zst") else {
Issue.record("Test resource test.tar.zst not found")
return
}

let extractDir = try createExtractionDirectory(name: "zstd-auto-test")
defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }

// Test with auto-detect
let reader = try ArchiveReader(file: resourceURL)
let rejectedPaths = try reader.extractContents(to: extractDir)

#expect(rejectedPaths.isEmpty, "No paths should be rejected")

// Check extracted files
let testFile = extractDir.appendingPathComponent("test.txt")
#expect(FileManager.default.fileExists(atPath: testFile.path), "test.txt should exist")

let testContent = try String(contentsOf: testFile, encoding: .utf8)
#expect(testContent == "Hello from zstd compressed archive", "Content should match")
}
}
Binary file not shown.
11 changes: 10 additions & 1 deletion vminitd/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion Package.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,7 @@ let package = Package(
.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"),
.package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.36.0"),
.package(url: "https://github.com/facebook/zstd.git", exact: "1.5.7"),
],
targets: [
.target(
Expand DownExpand Up@@ -143,17 +144,26 @@ let package = Package(
name: "ContainerizationArchiveTests",
dependencies: [
"ContainerizationArchive"
],
resources: [
.copy("Resources/test.tar.zst")
]
),
.target(
name: "CArchive",
dependencies: [],
dependencies: [
.product(name: "libzstd", package: "zstd")
],
path: "Sources/ContainerizationArchive/CArchive",
sources: [
"archive_swift_bridge.c"
],
cSettings: [
.define(
"PLATFORM_CONFIG_H", to: "\"config_darwin.h\"",
.when(platforms: [.iOS, .macOS, .macCatalyst, .watchOS, .driverKit, .tvOS])),
.define("PLATFORM_CONFIG_H", to: "\"config_linux.h\"", .when(platforms: [.linux])),
.unsafeFlags(["-fno-modules"]),
],
linkerSettings: [
.linkedLibrary("z"),
Expand Down
2 changes: 2 additions & 0 deletions Sources/Containerization/Image/Unpacker/EXT4Unpacker.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,6 +94,8 @@ public struct EXT4Unpacker: Unpacker {
compression = .none
case MediaTypes.imageLayerGzip, MediaTypes.dockerImageLayerGzip:
compression = .gzip
case MediaTypes.imageLayerZstd, MediaTypes.dockerImageLayerZstd:
compression = .zstd
default:
throw ContainerizationError(.unsupported, message: "media type \(layer.mediaType) not supported.")
}
Expand Down
113 changes: 110 additions & 3 deletions Sources/ContainerizationArchive/ArchiveReader.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,9 +15,11 @@
//===----------------------------------------------------------------------===//

import CArchive
import ContainerizationError
import ContainerizationOS
import Foundation
import SystemPackage
import libzstd

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

/// Initializes an `ArchiveReader` to read from a specified file URL with an explicit `Format` and `Filter`.
/// Note: This method must be used when it is known that the archive at the specified URL follows the specified
/// `Format` and `Filter`.
public convenience init(format: Format, filter: Filter, file: URL) throws {
let fileHandle = try FileHandle(forReadingFrom: file)
try self.init(format: format, filter: filter, fileHandle: fileHandle)
// If filter is zstd, decompress it and use filter .none
let fileToRead: URL
let tempFile: URL?
let actualFilter: Filter

if filter == .zstd {
let decompressed = try Self.decompressZstd(file)
tempFile = decompressed
fileToRead = decompressed
actualFilter = .none
} else {
tempFile = nil
fileToRead = file
actualFilter = filter
}

let fileHandle = try FileHandle(forReadingFrom: fileToRead)
try self.init(format: format, filter: actualFilter, fileHandle: fileHandle)
self.tempDecompressedFile = tempFile
}

/// Initializes an `ArchiveReader` to read from the provided file descriptor with an explicit `Format` and `Filter`.
Expand All@@ -82,8 +103,19 @@ public final class ArchiveReader {
/// Initialize the `ArchiveReader` to read from a specified file URL
/// by trying to auto determine the archives `Format` and `Filter`.
public init(file: URL) throws {

self.underlying = archive_read_new()
let fileHandle = try FileHandle(forReadingFrom: file)

// Try to decompress as zstd first, fall back to original if it fails
let fileToRead: URL
if let decompressed = try? Self.decompressZstd(file) {
self.tempDecompressedFile = decompressed
fileToRead = decompressed
} else {
fileToRead = file
}

let fileHandle = try FileHandle(forReadingFrom: fileToRead)
self.fileHandle = fileHandle
try archive_read_support_filter_all(underlying)
.checkOk(elseThrow: .failedToDetectFilter)
Expand All@@ -94,9 +126,84 @@ public final class ArchiveReader {
.checkOk(elseThrow: { .unableToOpenArchive($0) })
}

/// Decompress a zstd file to a temporary location
private static func decompressZstd(_ source: URL) throws -> URL {
guard let inputStream = InputStream(url: source) else {
throw ArchiveError.noUnderlyingArchive
}
inputStream.open()
defer { inputStream.close() }

// Create temp file into which the source zstd archived is decompressed
guard let tempDir = createTemporaryDirectory(baseName: "zstd-decompress") else {
throw ArchiveError.failedToDetectFormat
}

let tempFile = tempDir.appendingPathComponent(
source.deletingPathExtension().lastPathComponent
)

guard let outputStream = OutputStream(url: tempFile, append: false) else {
throw ArchiveError.noUnderlyingArchive
}
outputStream.open()
defer { outputStream.close() }

// Use streaming decompression since content size may be unknown
guard let dstream = ZSTD_createDStream() else {
throw ArchiveError.failedToDetectFormat
}
defer { ZSTD_freeDStream(dstream) }

let initResult = ZSTD_initDStream(dstream)
guard ZSTD_isError(initResult) == 0 else {
throw ArchiveError.failedToDetectFormat
}

let inputBufferSize = ZSTD_DStreamInSize()
let outputBufferSize = ZSTD_DStreamOutSize()

var inputBuffer = [UInt8](repeating: 0, count: inputBufferSize)

while case let amount = inputStream.read(&inputBuffer, maxLength: inputBufferSize), amount > 0 {
try inputBuffer.withUnsafeBufferPointer { ptr in
var input = ZSTD_inBuffer(
src: ptr.baseAddress,
size: amount,
pos: 0
)
while input.pos < input.size {
var outputBuffer = [UInt8](repeating: 0, count: outputBufferSize)
var decompressedBytes = 0
try outputBuffer.withUnsafeMutableBytes { outputBytes in
var output = ZSTD_outBuffer(
dst: outputBytes.baseAddress,
size: outputBufferSize,
pos: 0
)
let result = ZSTD_decompressStream(dstream, &output, &input)
guard ZSTD_isError(result) == 0 else {
throw ArchiveError.failedToDetectFormat
}
decompressedBytes = output.pos
}
if decompressedBytes > 0 {
outputStream.write(outputBuffer, maxLength: decompressedBytes)
}
}
}
}
return tempFile
}

deinit {
archive_read_free(underlying)
try? fileHandle?.close()

// Clean up temp decompressed file
if let tempFile = tempDecompressedFile {
try? FileManager.default.removeItem(at: tempFile.deletingLastPathComponent())
}
}
}

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -174,6 +174,7 @@ public enum Filter: String, Sendable {
case lzop
case grzip
case lz4
case zstd

internal var code: CInt {
switch self {
Expand All@@ -190,6 +191,7 @@ public enum Filter: String, Sendable {
case .lzop: return ARCHIVE_FILTER_LZOP
case .grzip: return ARCHIVE_FILTER_GRZIP
case .lz4: return ARCHIVE_FILTER_LZ4
case .zstd: return ARCHIVE_FILTER_ZSTD
}
}
}
54 changes: 54 additions & 0 deletions Tests/ContainerizationArchiveTests/ArchiveReaderTests.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -657,4 +657,58 @@ struct ArchiveReaderTests {
_ = try reader.extractContents(to: extractDir)
}
}

// MARK: - Zstd Compression Tests

@Test func readZstdCompressedArchive() throws {
guard let resourceURL = Bundle.module.url(forResource: "test", withExtension: "tar.zst") else {
Issue.record("Test resource test.tar.zst not found")
return
}

let extractDir = try createExtractionDirectory(name: "zstd-test")
defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }

// Test with explicit filter
let reader = try ArchiveReader(format: .paxRestricted, filter: .zstd, file: resourceURL)
let rejectedPaths = try reader.extractContents(to: extractDir)

#expect(rejectedPaths.isEmpty, "No paths should be rejected")

// Check extracted files
let testFile = extractDir.appendingPathComponent("test.txt")
let file2 = extractDir.appendingPathComponent("file2.txt")

#expect(FileManager.default.fileExists(atPath: testFile.path), "test.txt should exist")
#expect(FileManager.default.fileExists(atPath: file2.path), "file2.txt should exist")

let testContent = try String(contentsOf: testFile, encoding: .utf8)
#expect(testContent == "Hello from zstd compressed archive", "Content should match")

let file2Content = try String(contentsOf: file2, encoding: .utf8)
#expect(file2Content == "Another file", "Content should match")
}

@Test func readZstdCompressedArchiveAutoDetect() throws {
guard let resourceURL = Bundle.module.url(forResource: "test", withExtension: "tar.zst") else {
Issue.record("Test resource test.tar.zst not found")
return
}

let extractDir = try createExtractionDirectory(name: "zstd-auto-test")
defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }

// Test with auto-detect
let reader = try ArchiveReader(file: resourceURL)
let rejectedPaths = try reader.extractContents(to: extractDir)

#expect(rejectedPaths.isEmpty, "No paths should be rejected")

// Check extracted files
let testFile = extractDir.appendingPathComponent("test.txt")
#expect(FileManager.default.fileExists(atPath: testFile.path), "test.txt should exist")

let testContent = try String(contentsOf: testFile, encoding: .utf8)
#expect(testContent == "Hello from zstd compressed archive", "Content should match")
}
}
Binary file not shown.
11 changes: 10 additions & 1 deletion vminitd/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion Package.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,7 @@ let package = Package(
.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"),
.package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.36.0"),
.package(url: "https://github.com/facebook/zstd.git", exact: "1.5.7"),
],
targets: [
.target(
Expand DownExpand Up@@ -143,17 +144,26 @@ let package = Package(
name: "ContainerizationArchiveTests",
dependencies: [
"ContainerizationArchive"
],
resources: [
.copy("Resources/test.tar.zst")
]
),
.target(
name: "CArchive",
dependencies: [],
dependencies: [
.product(name: "libzstd", package: "zstd")
],
path: "Sources/ContainerizationArchive/CArchive",
sources: [
"archive_swift_bridge.c"
],
cSettings: [
.define(
"PLATFORM_CONFIG_H", to: "\"config_darwin.h\"",
.when(platforms: [.iOS, .macOS, .macCatalyst, .watchOS, .driverKit, .tvOS])),
.define("PLATFORM_CONFIG_H", to: "\"config_linux.h\"", .when(platforms: [.linux])),
.unsafeFlags(["-fno-modules"]),
],
linkerSettings: [
.linkedLibrary("z"),
Expand Down
2 changes: 2 additions & 0 deletions Sources/Containerization/Image/Unpacker/EXT4Unpacker.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,6 +94,8 @@ public struct EXT4Unpacker: Unpacker {
compression = .none
case MediaTypes.imageLayerGzip, MediaTypes.dockerImageLayerGzip:
compression = .gzip
case MediaTypes.imageLayerZstd, MediaTypes.dockerImageLayerZstd:
compression = .zstd
default:
throw ContainerizationError(.unsupported, message: "media type \(layer.mediaType) not supported.")
}
Expand Down
113 changes: 110 additions & 3 deletions Sources/ContainerizationArchive/ArchiveReader.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,9 +15,11 @@
//===----------------------------------------------------------------------===//

import CArchive
import ContainerizationError
import ContainerizationOS
import Foundation
import SystemPackage
import libzstd

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

/// Initializes an `ArchiveReader` to read from a specified file URL with an explicit `Format` and `Filter`.
/// Note: This method must be used when it is known that the archive at the specified URL follows the specified
/// `Format` and `Filter`.
public convenience init(format: Format, filter: Filter, file: URL) throws {
let fileHandle = try FileHandle(forReadingFrom: file)
try self.init(format: format, filter: filter, fileHandle: fileHandle)
// If filter is zstd, decompress it and use filter .none
let fileToRead: URL
let tempFile: URL?
let actualFilter: Filter

if filter == .zstd {
let decompressed = try Self.decompressZstd(file)
tempFile = decompressed
fileToRead = decompressed
actualFilter = .none
} else {
tempFile = nil
fileToRead = file
actualFilter = filter
}

let fileHandle = try FileHandle(forReadingFrom: fileToRead)
try self.init(format: format, filter: actualFilter, fileHandle: fileHandle)
self.tempDecompressedFile = tempFile
}

/// Initializes an `ArchiveReader` to read from the provided file descriptor with an explicit `Format` and `Filter`.
Expand All@@ -82,8 +103,19 @@ public final class ArchiveReader {
/// Initialize the `ArchiveReader` to read from a specified file URL
/// by trying to auto determine the archives `Format` and `Filter`.
public init(file: URL) throws {

self.underlying = archive_read_new()
let fileHandle = try FileHandle(forReadingFrom: file)

// Try to decompress as zstd first, fall back to original if it fails
let fileToRead: URL
if let decompressed = try? Self.decompressZstd(file) {
self.tempDecompressedFile = decompressed
fileToRead = decompressed
} else {
fileToRead = file
}

let fileHandle = try FileHandle(forReadingFrom: fileToRead)
self.fileHandle = fileHandle
try archive_read_support_filter_all(underlying)
.checkOk(elseThrow: .failedToDetectFilter)
Expand All@@ -94,9 +126,84 @@ public final class ArchiveReader {
.checkOk(elseThrow: { .unableToOpenArchive($0) })
}

/// Decompress a zstd file to a temporary location
private static func decompressZstd(_ source: URL) throws -> URL {
guard let inputStream = InputStream(url: source) else {
throw ArchiveError.noUnderlyingArchive
}
inputStream.open()
defer { inputStream.close() }

// Create temp file into which the source zstd archived is decompressed
guard let tempDir = createTemporaryDirectory(baseName: "zstd-decompress") else {
throw ArchiveError.failedToDetectFormat
}

let tempFile = tempDir.appendingPathComponent(
source.deletingPathExtension().lastPathComponent
)

guard let outputStream = OutputStream(url: tempFile, append: false) else {
throw ArchiveError.noUnderlyingArchive
}
outputStream.open()
defer { outputStream.close() }

// Use streaming decompression since content size may be unknown
guard let dstream = ZSTD_createDStream() else {
throw ArchiveError.failedToDetectFormat
}
defer { ZSTD_freeDStream(dstream) }

let initResult = ZSTD_initDStream(dstream)
guard ZSTD_isError(initResult) == 0 else {
throw ArchiveError.failedToDetectFormat
}

let inputBufferSize = ZSTD_DStreamInSize()
let outputBufferSize = ZSTD_DStreamOutSize()

var inputBuffer = [UInt8](repeating: 0, count: inputBufferSize)

while case let amount = inputStream.read(&inputBuffer, maxLength: inputBufferSize), amount > 0 {
try inputBuffer.withUnsafeBufferPointer { ptr in
var input = ZSTD_inBuffer(
src: ptr.baseAddress,
size: amount,
pos: 0
)
while input.pos < input.size {
var outputBuffer = [UInt8](repeating: 0, count: outputBufferSize)
var decompressedBytes = 0
try outputBuffer.withUnsafeMutableBytes { outputBytes in
var output = ZSTD_outBuffer(
dst: outputBytes.baseAddress,
size: outputBufferSize,
pos: 0
)
let result = ZSTD_decompressStream(dstream, &output, &input)
guard ZSTD_isError(result) == 0 else {
throw ArchiveError.failedToDetectFormat
}
decompressedBytes = output.pos
}
if decompressedBytes > 0 {
outputStream.write(outputBuffer, maxLength: decompressedBytes)
}
}
}
}
return tempFile
}

deinit {
archive_read_free(underlying)
try? fileHandle?.close()

// Clean up temp decompressed file
if let tempFile = tempDecompressedFile {
try? FileManager.default.removeItem(at: tempFile.deletingLastPathComponent())
}
}
}

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -174,6 +174,7 @@ public enum Filter: String, Sendable {
case lzop
case grzip
case lz4
case zstd

internal var code: CInt {
switch self {
Expand All@@ -190,6 +191,7 @@ public enum Filter: String, Sendable {
case .lzop: return ARCHIVE_FILTER_LZOP
case .grzip: return ARCHIVE_FILTER_GRZIP
case .lz4: return ARCHIVE_FILTER_LZ4
case .zstd: return ARCHIVE_FILTER_ZSTD
}
}
}
54 changes: 54 additions & 0 deletions Tests/ContainerizationArchiveTests/ArchiveReaderTests.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -657,4 +657,58 @@ struct ArchiveReaderTests {
_ = try reader.extractContents(to: extractDir)
}
}

// MARK: - Zstd Compression Tests

@Test func readZstdCompressedArchive() throws {
guard let resourceURL = Bundle.module.url(forResource: "test", withExtension: "tar.zst") else {
Issue.record("Test resource test.tar.zst not found")
return
}

let extractDir = try createExtractionDirectory(name: "zstd-test")
defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }

// Test with explicit filter
let reader = try ArchiveReader(format: .paxRestricted, filter: .zstd, file: resourceURL)
let rejectedPaths = try reader.extractContents(to: extractDir)

#expect(rejectedPaths.isEmpty, "No paths should be rejected")

// Check extracted files
let testFile = extractDir.appendingPathComponent("test.txt")
let file2 = extractDir.appendingPathComponent("file2.txt")

#expect(FileManager.default.fileExists(atPath: testFile.path), "test.txt should exist")
#expect(FileManager.default.fileExists(atPath: file2.path), "file2.txt should exist")

let testContent = try String(contentsOf: testFile, encoding: .utf8)
#expect(testContent == "Hello from zstd compressed archive", "Content should match")

let file2Content = try String(contentsOf: file2, encoding: .utf8)
#expect(file2Content == "Another file", "Content should match")
}

@Test func readZstdCompressedArchiveAutoDetect() throws {
guard let resourceURL = Bundle.module.url(forResource: "test", withExtension: "tar.zst") else {
Issue.record("Test resource test.tar.zst not found")
return
}

let extractDir = try createExtractionDirectory(name: "zstd-auto-test")
defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }

// Test with auto-detect
let reader = try ArchiveReader(file: resourceURL)
let rejectedPaths = try reader.extractContents(to: extractDir)

#expect(rejectedPaths.isEmpty, "No paths should be rejected")

// Check extracted files
let testFile = extractDir.appendingPathComponent("test.txt")
#expect(FileManager.default.fileExists(atPath: testFile.path), "test.txt should exist")

let testContent = try String(contentsOf: testFile, encoding: .utf8)
#expect(testContent == "Hello from zstd compressed archive", "Content should match")
}
}
Binary file not shown.
11 changes: 10 additions & 1 deletion vminitd/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion Package.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,7 @@ let package = Package(
.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"),
.package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.36.0"),
.package(url: "https://github.com/facebook/zstd.git", exact: "1.5.7"),
],
targets: [
.target(
Expand DownExpand Up@@ -143,17 +144,26 @@ let package = Package(
name: "ContainerizationArchiveTests",
dependencies: [
"ContainerizationArchive"
],
resources: [
.copy("Resources/test.tar.zst")
]
),
.target(
name: "CArchive",
dependencies: [],
dependencies: [
.product(name: "libzstd", package: "zstd")
],
path: "Sources/ContainerizationArchive/CArchive",
sources: [
"archive_swift_bridge.c"
],
cSettings: [
.define(
"PLATFORM_CONFIG_H", to: "\"config_darwin.h\"",
.when(platforms: [.iOS, .macOS, .macCatalyst, .watchOS, .driverKit, .tvOS])),
.define("PLATFORM_CONFIG_H", to: "\"config_linux.h\"", .when(platforms: [.linux])),
.unsafeFlags(["-fno-modules"]),
],
linkerSettings: [
.linkedLibrary("z"),
Expand Down
2 changes: 2 additions & 0 deletions Sources/Containerization/Image/Unpacker/EXT4Unpacker.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,6 +94,8 @@ public struct EXT4Unpacker: Unpacker {
compression = .none
case MediaTypes.imageLayerGzip, MediaTypes.dockerImageLayerGzip:
compression = .gzip
case MediaTypes.imageLayerZstd, MediaTypes.dockerImageLayerZstd:
compression = .zstd
default:
throw ContainerizationError(.unsupported, message: "media type \(layer.mediaType) not supported.")
}
Expand Down
113 changes: 110 additions & 3 deletions Sources/ContainerizationArchive/ArchiveReader.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,9 +15,11 @@
//===----------------------------------------------------------------------===//

import CArchive
import ContainerizationError
import ContainerizationOS
import Foundation
import SystemPackage
import libzstd

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

/// Initializes an `ArchiveReader` to read from a specified file URL with an explicit `Format` and `Filter`.
/// Note: This method must be used when it is known that the archive at the specified URL follows the specified
/// `Format` and `Filter`.
public convenience init(format: Format, filter: Filter, file: URL) throws {
let fileHandle = try FileHandle(forReadingFrom: file)
try self.init(format: format, filter: filter, fileHandle: fileHandle)
// If filter is zstd, decompress it and use filter .none
let fileToRead: URL
let tempFile: URL?
let actualFilter: Filter

if filter == .zstd {
let decompressed = try Self.decompressZstd(file)
tempFile = decompressed
fileToRead = decompressed
actualFilter = .none
} else {
tempFile = nil
fileToRead = file
actualFilter = filter
}

let fileHandle = try FileHandle(forReadingFrom: fileToRead)
try self.init(format: format, filter: actualFilter, fileHandle: fileHandle)
self.tempDecompressedFile = tempFile
}

/// Initializes an `ArchiveReader` to read from the provided file descriptor with an explicit `Format` and `Filter`.
Expand All@@ -82,8 +103,19 @@ public final class ArchiveReader {
/// Initialize the `ArchiveReader` to read from a specified file URL
/// by trying to auto determine the archives `Format` and `Filter`.
public init(file: URL) throws {

self.underlying = archive_read_new()
let fileHandle = try FileHandle(forReadingFrom: file)

// Try to decompress as zstd first, fall back to original if it fails
let fileToRead: URL
if let decompressed = try? Self.decompressZstd(file) {
self.tempDecompressedFile = decompressed
fileToRead = decompressed
} else {
fileToRead = file
}

let fileHandle = try FileHandle(forReadingFrom: fileToRead)
self.fileHandle = fileHandle
try archive_read_support_filter_all(underlying)
.checkOk(elseThrow: .failedToDetectFilter)
Expand All@@ -94,9 +126,84 @@ public final class ArchiveReader {
.checkOk(elseThrow: { .unableToOpenArchive($0) })
}

/// Decompress a zstd file to a temporary location
private static func decompressZstd(_ source: URL) throws -> URL {
guard let inputStream = InputStream(url: source) else {
throw ArchiveError.noUnderlyingArchive
}
inputStream.open()
defer { inputStream.close() }

// Create temp file into which the source zstd archived is decompressed
guard let tempDir = createTemporaryDirectory(baseName: "zstd-decompress") else {
throw ArchiveError.failedToDetectFormat
}

let tempFile = tempDir.appendingPathComponent(
source.deletingPathExtension().lastPathComponent
)

guard let outputStream = OutputStream(url: tempFile, append: false) else {
throw ArchiveError.noUnderlyingArchive
}
outputStream.open()
defer { outputStream.close() }

// Use streaming decompression since content size may be unknown
guard let dstream = ZSTD_createDStream() else {
throw ArchiveError.failedToDetectFormat
}
defer { ZSTD_freeDStream(dstream) }

let initResult = ZSTD_initDStream(dstream)
guard ZSTD_isError(initResult) == 0 else {
throw ArchiveError.failedToDetectFormat
}

let inputBufferSize = ZSTD_DStreamInSize()
let outputBufferSize = ZSTD_DStreamOutSize()

var inputBuffer = [UInt8](repeating: 0, count: inputBufferSize)

while case let amount = inputStream.read(&inputBuffer, maxLength: inputBufferSize), amount > 0 {
try inputBuffer.withUnsafeBufferPointer { ptr in
var input = ZSTD_inBuffer(
src: ptr.baseAddress,
size: amount,
pos: 0
)
while input.pos < input.size {
var outputBuffer = [UInt8](repeating: 0, count: outputBufferSize)
var decompressedBytes = 0
try outputBuffer.withUnsafeMutableBytes { outputBytes in
var output = ZSTD_outBuffer(
dst: outputBytes.baseAddress,
size: outputBufferSize,
pos: 0
)
let result = ZSTD_decompressStream(dstream, &output, &input)
guard ZSTD_isError(result) == 0 else {
throw ArchiveError.failedToDetectFormat
}
decompressedBytes = output.pos
}
if decompressedBytes > 0 {
outputStream.write(outputBuffer, maxLength: decompressedBytes)
}
}
}
}
return tempFile
}

deinit {
archive_read_free(underlying)
try? fileHandle?.close()

// Clean up temp decompressed file
if let tempFile = tempDecompressedFile {
try? FileManager.default.removeItem(at: tempFile.deletingLastPathComponent())
}
}
}

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -174,6 +174,7 @@ public enum Filter: String, Sendable {
case lzop
case grzip
case lz4
case zstd

internal var code: CInt {
switch self {
Expand All@@ -190,6 +191,7 @@ public enum Filter: String, Sendable {
case .lzop: return ARCHIVE_FILTER_LZOP
case .grzip: return ARCHIVE_FILTER_GRZIP
case .lz4: return ARCHIVE_FILTER_LZ4
case .zstd: return ARCHIVE_FILTER_ZSTD
}
}
}
54 changes: 54 additions & 0 deletions Tests/ContainerizationArchiveTests/ArchiveReaderTests.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -657,4 +657,58 @@ struct ArchiveReaderTests {
_ = try reader.extractContents(to: extractDir)
}
}

// MARK: - Zstd Compression Tests

@Test func readZstdCompressedArchive() throws {
guard let resourceURL = Bundle.module.url(forResource: "test", withExtension: "tar.zst") else {
Issue.record("Test resource test.tar.zst not found")
return
}

let extractDir = try createExtractionDirectory(name: "zstd-test")
defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }

// Test with explicit filter
let reader = try ArchiveReader(format: .paxRestricted, filter: .zstd, file: resourceURL)
let rejectedPaths = try reader.extractContents(to: extractDir)

#expect(rejectedPaths.isEmpty, "No paths should be rejected")

// Check extracted files
let testFile = extractDir.appendingPathComponent("test.txt")
let file2 = extractDir.appendingPathComponent("file2.txt")

#expect(FileManager.default.fileExists(atPath: testFile.path), "test.txt should exist")
#expect(FileManager.default.fileExists(atPath: file2.path), "file2.txt should exist")

let testContent = try String(contentsOf: testFile, encoding: .utf8)
#expect(testContent == "Hello from zstd compressed archive", "Content should match")

let file2Content = try String(contentsOf: file2, encoding: .utf8)
#expect(file2Content == "Another file", "Content should match")
}

@Test func readZstdCompressedArchiveAutoDetect() throws {
guard let resourceURL = Bundle.module.url(forResource: "test", withExtension: "tar.zst") else {
Issue.record("Test resource test.tar.zst not found")
return
}

let extractDir = try createExtractionDirectory(name: "zstd-auto-test")
defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }

// Test with auto-detect
let reader = try ArchiveReader(file: resourceURL)
let rejectedPaths = try reader.extractContents(to: extractDir)

#expect(rejectedPaths.isEmpty, "No paths should be rejected")

// Check extracted files
let testFile = extractDir.appendingPathComponent("test.txt")
#expect(FileManager.default.fileExists(atPath: testFile.path), "test.txt should exist")

let testContent = try String(contentsOf: testFile, encoding: .utf8)
#expect(testContent == "Hello from zstd compressed archive", "Content should match")
}
}
Binary file not shown.
11 changes: 10 additions & 1 deletion vminitd/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion Package.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,7 @@ let package = Package(
.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"),
.package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.36.0"),
.package(url: "https://github.com/facebook/zstd.git", exact: "1.5.7"),
],
targets: [
.target(
Expand DownExpand Up@@ -143,17 +144,26 @@ let package = Package(
name: "ContainerizationArchiveTests",
dependencies: [
"ContainerizationArchive"
],
resources: [
.copy("Resources/test.tar.zst")
]
),
.target(
name: "CArchive",
dependencies: [],
dependencies: [
.product(name: "libzstd", package: "zstd")
],
path: "Sources/ContainerizationArchive/CArchive",
sources: [
"archive_swift_bridge.c"
],
cSettings: [
.define(
"PLATFORM_CONFIG_H", to: "\"config_darwin.h\"",
.when(platforms: [.iOS, .macOS, .macCatalyst, .watchOS, .driverKit, .tvOS])),
.define("PLATFORM_CONFIG_H", to: "\"config_linux.h\"", .when(platforms: [.linux])),
.unsafeFlags(["-fno-modules"]),
],
linkerSettings: [
.linkedLibrary("z"),
Expand Down
2 changes: 2 additions & 0 deletions Sources/Containerization/Image/Unpacker/EXT4Unpacker.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,6 +94,8 @@ public struct EXT4Unpacker: Unpacker {
compression = .none
case MediaTypes.imageLayerGzip, MediaTypes.dockerImageLayerGzip:
compression = .gzip
case MediaTypes.imageLayerZstd, MediaTypes.dockerImageLayerZstd:
compression = .zstd
default:
throw ContainerizationError(.unsupported, message: "media type \(layer.mediaType) not supported.")
}
Expand Down
113 changes: 110 additions & 3 deletions Sources/ContainerizationArchive/ArchiveReader.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,9 +15,11 @@
//===----------------------------------------------------------------------===//

import CArchive
import ContainerizationError
import ContainerizationOS
import Foundation
import SystemPackage
import libzstd

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

/// Initializes an `ArchiveReader` to read from a specified file URL with an explicit `Format` and `Filter`.
/// Note: This method must be used when it is known that the archive at the specified URL follows the specified
/// `Format` and `Filter`.
public convenience init(format: Format, filter: Filter, file: URL) throws {
let fileHandle = try FileHandle(forReadingFrom: file)
try self.init(format: format, filter: filter, fileHandle: fileHandle)
// If filter is zstd, decompress it and use filter .none
let fileToRead: URL
let tempFile: URL?
let actualFilter: Filter

if filter == .zstd {
let decompressed = try Self.decompressZstd(file)
tempFile = decompressed
fileToRead = decompressed
actualFilter = .none
} else {
tempFile = nil
fileToRead = file
actualFilter = filter
}

let fileHandle = try FileHandle(forReadingFrom: fileToRead)
try self.init(format: format, filter: actualFilter, fileHandle: fileHandle)
self.tempDecompressedFile = tempFile
}

/// Initializes an `ArchiveReader` to read from the provided file descriptor with an explicit `Format` and `Filter`.
Expand All@@ -82,8 +103,19 @@ public final class ArchiveReader {
/// Initialize the `ArchiveReader` to read from a specified file URL
/// by trying to auto determine the archives `Format` and `Filter`.
public init(file: URL) throws {

self.underlying = archive_read_new()
let fileHandle = try FileHandle(forReadingFrom: file)

// Try to decompress as zstd first, fall back to original if it fails
let fileToRead: URL
if let decompressed = try? Self.decompressZstd(file) {
self.tempDecompressedFile = decompressed
fileToRead = decompressed
} else {
fileToRead = file
}

let fileHandle = try FileHandle(forReadingFrom: fileToRead)
self.fileHandle = fileHandle
try archive_read_support_filter_all(underlying)
.checkOk(elseThrow: .failedToDetectFilter)
Expand All@@ -94,9 +126,84 @@ public final class ArchiveReader {
.checkOk(elseThrow: { .unableToOpenArchive($0) })
}

/// Decompress a zstd file to a temporary location
private static func decompressZstd(_ source: URL) throws -> URL {
guard let inputStream = InputStream(url: source) else {
throw ArchiveError.noUnderlyingArchive
}
inputStream.open()
defer { inputStream.close() }

// Create temp file into which the source zstd archived is decompressed
guard let tempDir = createTemporaryDirectory(baseName: "zstd-decompress") else {
throw ArchiveError.failedToDetectFormat
}

let tempFile = tempDir.appendingPathComponent(
source.deletingPathExtension().lastPathComponent
)

guard let outputStream = OutputStream(url: tempFile, append: false) else {
throw ArchiveError.noUnderlyingArchive
}
outputStream.open()
defer { outputStream.close() }

// Use streaming decompression since content size may be unknown
guard let dstream = ZSTD_createDStream() else {
throw ArchiveError.failedToDetectFormat
}
defer { ZSTD_freeDStream(dstream) }

let initResult = ZSTD_initDStream(dstream)
guard ZSTD_isError(initResult) == 0 else {
throw ArchiveError.failedToDetectFormat
}

let inputBufferSize = ZSTD_DStreamInSize()
let outputBufferSize = ZSTD_DStreamOutSize()

var inputBuffer = [UInt8](repeating: 0, count: inputBufferSize)

while case let amount = inputStream.read(&inputBuffer, maxLength: inputBufferSize), amount > 0 {
try inputBuffer.withUnsafeBufferPointer { ptr in
var input = ZSTD_inBuffer(
src: ptr.baseAddress,
size: amount,
pos: 0
)
while input.pos < input.size {
var outputBuffer = [UInt8](repeating: 0, count: outputBufferSize)
var decompressedBytes = 0
try outputBuffer.withUnsafeMutableBytes { outputBytes in
var output = ZSTD_outBuffer(
dst: outputBytes.baseAddress,
size: outputBufferSize,
pos: 0
)
let result = ZSTD_decompressStream(dstream, &output, &input)
guard ZSTD_isError(result) == 0 else {
throw ArchiveError.failedToDetectFormat
}
decompressedBytes = output.pos
}
if decompressedBytes > 0 {
outputStream.write(outputBuffer, maxLength: decompressedBytes)
}
}
}
}
return tempFile
}

deinit {
archive_read_free(underlying)
try? fileHandle?.close()

// Clean up temp decompressed file
if let tempFile = tempDecompressedFile {
try? FileManager.default.removeItem(at: tempFile.deletingLastPathComponent())
}
}
}

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -174,6 +174,7 @@ public enum Filter: String, Sendable {
case lzop
case grzip
case lz4
case zstd

internal var code: CInt {
switch self {
Expand All@@ -190,6 +191,7 @@ public enum Filter: String, Sendable {
case .lzop: return ARCHIVE_FILTER_LZOP
case .grzip: return ARCHIVE_FILTER_GRZIP
case .lz4: return ARCHIVE_FILTER_LZ4
case .zstd: return ARCHIVE_FILTER_ZSTD
}
}
}
54 changes: 54 additions & 0 deletions Tests/ContainerizationArchiveTests/ArchiveReaderTests.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -657,4 +657,58 @@ struct ArchiveReaderTests {
_ = try reader.extractContents(to: extractDir)
}
}

// MARK: - Zstd Compression Tests

@Test func readZstdCompressedArchive() throws {
guard let resourceURL = Bundle.module.url(forResource: "test", withExtension: "tar.zst") else {
Issue.record("Test resource test.tar.zst not found")
return
}

let extractDir = try createExtractionDirectory(name: "zstd-test")
defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }

// Test with explicit filter
let reader = try ArchiveReader(format: .paxRestricted, filter: .zstd, file: resourceURL)
let rejectedPaths = try reader.extractContents(to: extractDir)

#expect(rejectedPaths.isEmpty, "No paths should be rejected")

// Check extracted files
let testFile = extractDir.appendingPathComponent("test.txt")
let file2 = extractDir.appendingPathComponent("file2.txt")

#expect(FileManager.default.fileExists(atPath: testFile.path), "test.txt should exist")
#expect(FileManager.default.fileExists(atPath: file2.path), "file2.txt should exist")

let testContent = try String(contentsOf: testFile, encoding: .utf8)
#expect(testContent == "Hello from zstd compressed archive", "Content should match")

let file2Content = try String(contentsOf: file2, encoding: .utf8)
#expect(file2Content == "Another file", "Content should match")
}

@Test func readZstdCompressedArchiveAutoDetect() throws {
guard let resourceURL = Bundle.module.url(forResource: "test", withExtension: "tar.zst") else {
Issue.record("Test resource test.tar.zst not found")
return
}

let extractDir = try createExtractionDirectory(name: "zstd-auto-test")
defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }

// Test with auto-detect
let reader = try ArchiveReader(file: resourceURL)
let rejectedPaths = try reader.extractContents(to: extractDir)

#expect(rejectedPaths.isEmpty, "No paths should be rejected")

// Check extracted files
let testFile = extractDir.appendingPathComponent("test.txt")
#expect(FileManager.default.fileExists(atPath: testFile.path), "test.txt should exist")

let testContent = try String(contentsOf: testFile, encoding: .utf8)
#expect(testContent == "Hello from zstd compressed archive", "Content should match")
}
}
Binary file not shown.
11 changes: 10 additions & 1 deletion vminitd/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading