170 lines
6.3 KiB
Swift
170 lines
6.3 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// https://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
//===----------------------------------------------------------------------===//
|
|
|
|
import ContainerizationArchive
|
|
import ContainerizationEXT4
|
|
import ContainerizationError
|
|
import ContainerizationExtras
|
|
import ContainerizationOCI
|
|
import Foundation
|
|
import SystemPackage
|
|
|
|
public struct EXT4Unpacker: Unpacker {
|
|
let blockSizeInBytes: UInt64
|
|
|
|
let journal: EXT4.JournalConfig?
|
|
|
|
/// Creates an unpacker that extracts images into EXT4 filesystems.
|
|
/// - Parameters:
|
|
/// - blockSizeInBytes: The filesystem block size.
|
|
/// - journal: The journal configuration to use, or nil for no journaling.
|
|
public init(blockSizeInBytes: UInt64, journal: EXT4.JournalConfig? = nil) {
|
|
self.blockSizeInBytes = blockSizeInBytes
|
|
self.journal = journal
|
|
}
|
|
|
|
/// Performs the unpacking of a tar archive into a filesystem.
|
|
/// - Parameters:
|
|
/// - archive: The archive to unpack.
|
|
/// - compression: The compression to use when unpacking the image.
|
|
/// - path: The path to the filesystem that will be created.
|
|
public func unpack(
|
|
archive: URL,
|
|
compression: ContainerizationArchive.Filter,
|
|
at path: URL
|
|
) async throws {
|
|
let cleanedPath = try prepareUnpackPath(path: path)
|
|
let filesystem = try EXT4.Formatter(
|
|
FilePath(cleanedPath),
|
|
minDiskSize: blockSizeInBytes,
|
|
journal: journal
|
|
)
|
|
defer { try? filesystem.close() }
|
|
|
|
try await filesystem.unpack(
|
|
source: archive,
|
|
format: .paxRestricted,
|
|
compression: compression
|
|
)
|
|
}
|
|
|
|
/// Returns a `Mount` point after unpacking the image into a filesystem.
|
|
/// - Parameters:
|
|
/// - image: The image to unpack.
|
|
/// - platform: The platform content to unpack.
|
|
/// - path: The path to the directory where the filesystem will be created.
|
|
/// - progress: The progress handler to invoke as the unpacking progresses.
|
|
public func unpack(
|
|
_ image: Image,
|
|
for platform: Platform,
|
|
at path: URL,
|
|
progress: ProgressHandler? = nil
|
|
) async throws -> Mount {
|
|
let cleanedPath = try prepareUnpackPath(path: path)
|
|
let manifest = try await image.manifest(for: platform)
|
|
let filesystem = try EXT4.Formatter(
|
|
FilePath(
|
|
cleanedPath
|
|
),
|
|
minDiskSize: blockSizeInBytes
|
|
)
|
|
defer { try? filesystem.close() }
|
|
|
|
// Resolve layer paths upfront. When progress reporting is enabled and a layer
|
|
// uses zstd, decompress once so both the size-scanning pass and the unpack
|
|
// pass share the same decompressed file.
|
|
var resolvedLayers: [(file: URL, filter: ContainerizationArchive.Filter)] = []
|
|
var decompressedFiles: [URL] = []
|
|
for layer in manifest.layers {
|
|
try Task.checkCancellation()
|
|
let content = try await image.getContent(digest: layer.digest)
|
|
let compression = try compressionFilter(for: layer.mediaType)
|
|
if progress != nil && compression == .zstd {
|
|
let decompressed = try ArchiveReader.decompressZstd(content.path)
|
|
decompressedFiles.append(decompressed)
|
|
resolvedLayers.append((file: decompressed, filter: .none))
|
|
} else {
|
|
resolvedLayers.append((file: content.path, filter: compression))
|
|
}
|
|
}
|
|
defer {
|
|
for file in decompressedFiles {
|
|
ArchiveReader.cleanUpDecompressedZstd(file)
|
|
}
|
|
}
|
|
|
|
if let progress {
|
|
var totalSize: Int64 = 0
|
|
var totalItems: Int = 0
|
|
for layer in resolvedLayers {
|
|
try Task.checkCancellation()
|
|
let totals = try EXT4.Formatter.scanArchiveHeaders(
|
|
format: .paxRestricted, filter: layer.filter, file: layer.file)
|
|
totalSize += totals.size
|
|
totalItems += totals.items
|
|
}
|
|
var totalEvents: [ProgressEvent] = []
|
|
if totalSize > 0 {
|
|
totalEvents.append(.addTotalSize(totalSize))
|
|
}
|
|
if totalItems > 0 {
|
|
totalEvents.append(.addTotalItems(totalItems))
|
|
}
|
|
if !totalEvents.isEmpty {
|
|
await progress(totalEvents)
|
|
}
|
|
}
|
|
|
|
for resolved in resolvedLayers {
|
|
try Task.checkCancellation()
|
|
let reader = try ArchiveReader(
|
|
format: .paxRestricted,
|
|
filter: resolved.filter,
|
|
file: resolved.file
|
|
)
|
|
try await filesystem.unpack(reader: reader, progress: progress)
|
|
}
|
|
|
|
return .block(
|
|
format: "ext4",
|
|
source: cleanedPath,
|
|
destination: "/",
|
|
options: []
|
|
)
|
|
}
|
|
|
|
private func prepareUnpackPath(path: URL) throws -> String {
|
|
let blockPath = path.absolutePath()
|
|
guard !FileManager.default.fileExists(atPath: blockPath) else {
|
|
throw ContainerizationError(.exists, message: "block device already exists at \(blockPath)")
|
|
}
|
|
return blockPath
|
|
}
|
|
|
|
private func compressionFilter(for mediaType: String) throws -> ContainerizationArchive.Filter {
|
|
switch mediaType {
|
|
case MediaTypes.imageLayer, MediaTypes.dockerImageLayer:
|
|
return .none
|
|
case MediaTypes.imageLayerGzip, MediaTypes.dockerImageLayerGzip:
|
|
return .gzip
|
|
case MediaTypes.imageLayerZstd, MediaTypes.dockerImageLayerZstd:
|
|
return .zstd
|
|
default:
|
|
throw ContainerizationError(.unsupported, message: "media type \(mediaType) not supported.")
|
|
}
|
|
}
|
|
}
|