chore: import upstream snapshot with attribution
Build containerization / Verify commit signatures (push) Has been skipped
Build containerization / containerization (push) Successful in 0s
Linux build / Linux compile check (push) Has been cancelled
Linux build / Determine Swift version (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:25:30 +08:00
commit 680845cb1c
445 changed files with 103779 additions and 0 deletions
@@ -0,0 +1,4 @@
# ``ContainerizationEXT4``
`ContainerizationEXT4` provides functionality to read the superblock of an existing ext4 block device and format a new block device with
the ext4 file system.
@@ -0,0 +1,97 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
extension EXT4.InodeFlag {
public static func | (lhs: Self, rhs: Self) -> Self {
Self(rawValue: lhs.rawValue | rhs.rawValue)
}
public static func | (lhs: Self, rhs: Self) -> UInt32 {
lhs.rawValue | rhs.rawValue
}
public static func | (lhs: Self, rhs: UInt32) -> UInt32 {
lhs.rawValue | rhs
}
}
extension EXT4.CompatFeature {
public static func | (lhs: Self, rhs: Self) -> Self {
EXT4.CompatFeature(rawValue: lhs.rawValue | rhs.rawValue)
}
public static func | (lhs: Self, rhs: Self) -> UInt32 {
lhs.rawValue | rhs.rawValue
}
}
extension EXT4.IncompatFeature {
public static func | (lhs: Self, rhs: Self) -> Self {
EXT4.IncompatFeature(rawValue: lhs.rawValue | rhs.rawValue)
}
public static func | (lhs: Self, rhs: Self) -> UInt32 {
lhs.rawValue | rhs.rawValue
}
}
extension EXT4.RoCompatFeature {
public static func | (lhs: Self, rhs: Self) -> Self {
EXT4.RoCompatFeature(rawValue: lhs.rawValue | rhs.rawValue)
}
public static func | (lhs: Self, rhs: Self) -> UInt32 {
lhs.rawValue | rhs.rawValue
}
}
extension EXT4.FileModeFlag {
public static func | (lhs: Self, rhs: Self) -> Self {
Self(rawValue: lhs.rawValue | rhs.rawValue)
}
public static func | (lhs: Self, rhs: Self) -> UInt16 {
lhs.rawValue | rhs.rawValue
}
}
extension EXT4.XAttrEntry {
init(using bytes: [UInt8]) throws {
guard bytes.count == 16 else {
throw EXT4.Error.invalidXattrEntry
}
nameLength = bytes[0]
nameIndex = bytes[1]
let rawValue = Array(bytes[2...3])
valueOffset = rawValue.withUnsafeBytes { $0.loadLittleEndian(as: UInt16.self) }
let rawValueInum = Array(bytes[4...7])
valueInum = rawValueInum.withUnsafeBytes { $0.loadLittleEndian(as: UInt32.self) }
let rawSize = Array(bytes[8...11])
valueSize = rawSize.withUnsafeBytes { $0.loadLittleEndian(as: UInt32.self) }
let rawHash = Array(bytes[12...])
hash = rawHash.withUnsafeBytes { $0.loadLittleEndian(as: UInt32.self) }
}
}
extension EXT4 {
static func tupleToArray<T>(_ tuple: T) -> [UInt8] {
let reflection = Mirror(reflecting: tuple)
return reflection.children.compactMap { $0.value as? UInt8 }
}
}
@@ -0,0 +1,101 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
import SystemPackage
extension EXT4 {
class FileTree {
class FileTreeNode {
let inode: InodeNumber
let name: String
var children: [Ptr<FileTreeNode>] = []
var blocks: (start: UInt32, end: UInt32)?
var additionalBlocks: [(start: UInt32, end: UInt32)]?
var link: InodeNumber?
private weak var parent: Ptr<FileTreeNode>?
init(
inode: InodeNumber,
name: String,
parent: Ptr<FileTreeNode>?,
children: [Ptr<FileTreeNode>] = [],
blocks: (start: UInt32, end: UInt32)? = nil,
additionalBlocks: [(start: UInt32, end: UInt32)]? = nil,
link: InodeNumber? = nil
) {
self.inode = inode
self.name = name
self.children = children
self.blocks = blocks
self.additionalBlocks = additionalBlocks
self.link = link
self.parent = parent
}
deinit {
self.children.removeAll()
self.children = []
self.blocks = nil
self.additionalBlocks = nil
self.link = nil
}
var path: FilePath? {
var components: [String] = [self.name]
var _ptr = self.parent
while let ptr = _ptr {
components.append(ptr.pointee.name)
_ptr = ptr.pointee.parent
}
let path = components.reversed().joined(separator: "/")
return FilePath(path).lexicallyNormalized()
}
}
var root: Ptr<FileTreeNode>
init(_ root: InodeNumber, _ name: String) {
self.root = Ptr(FileTreeNode(inode: root, name: name, parent: nil))
}
func lookup(path: FilePath) -> Ptr<FileTreeNode>? {
var components: [String] = path.items
var node = self.root
if components.first == "/" {
components = Array(components.dropFirst())
}
if components.count == 0 {
return node
}
for component in components {
var found = false
for childPtr in node.pointee.children {
let child = childPtr.pointee
if child.name == component {
node = childPtr
found = true
break
}
}
guard found else {
return nil
}
}
return node
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,197 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerizationOS
import Foundation
// JBD2 on-disk format reference:
// https://www.kernel.org/doc/html/latest/filesystems/ext4/journal.html
extension EXT4.Formatter {
/// Entry point called from close() when journaling is enabled.
func initializeJournal(
config: EXT4.JournalConfig,
filesystemUUID: (
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8
)
) throws -> UInt32 {
let journalBlocks = try calculateJournalSize(requestedSize: config.size, usableBlocks: blockCount)
// Align to block boundary before recording start.
if self.pos % self.blockSize != 0 {
try self.seek(block: self.currentBlock + 1)
}
let journalStartBlock = self.currentBlock
try writeJournalSuperblock(journalBlocks: journalBlocks, filesystemUUID: filesystemUUID)
try zeroJournalBlocks(count: journalBlocks - 1)
try setupJournalInode(startBlock: journalStartBlock, blockCount: journalBlocks)
return journalBlocks
}
// MARK: - Private helpers
private func calculateJournalSize(requestedSize: UInt64?, usableBlocks: UInt32) throws -> UInt32 {
if let size = requestedSize {
let blocks = size / UInt64(self.blockSize)
// JBD2_MIN_JOURNAL_BLOCKS: the kernel refuses to mount with fewer.
// blocks == 0 would also cause a UInt32 underflow in the caller.
guard blocks >= EXT4.MinJournalBlocks else {
throw EXT4.Formatter.Error.journalTooSmall(size)
}
// Safe: any journal large enough to overflow UInt32 (>16 TiB at 4 KiB block size)
// would fail at the I/O layer before this conversion is reached.
return UInt32(blocks)
}
// Default sizing: scale with the usable content area, with a floor determined by
// JBD2_MIN_JOURNAL_BLOCKS and a ceiling that follows e2fsprogs convention: 128 MiB for
// filesystems up to 128 GiB, and 1 GiB for larger filesystems. The larger ceiling was
// introduced in e2fsprogs 1.43.2:
// https://e2fsprogs.sourceforge.net/e2fsprogs-release.html#1.43.2
let usableBytes = UInt64(usableBlocks) * UInt64(self.blockSize)
let scaledBytes = usableBytes / 64 // 1/64th of the usable area, matching e2fsprogs defaults
let minBytes: UInt64 = UInt64(EXT4.MinJournalBlocks) * UInt64(self.blockSize)
let maxBytes: UInt64 = usableBytes > 128.gib() ? 1.gib() : 128.mib()
let clampedBytes = min(max(scaledBytes, minBytes), maxBytes)
// Safe: clampedBytes 1 GiB and blockSize 1, so the quotient fits in UInt32.
return UInt32(clampedBytes / UInt64(self.blockSize))
}
private func writeJournalSuperblock(
journalBlocks: UInt32,
filesystemUUID: (
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8
)
) throws {
// Safe: blockSize is UInt32; widening to Int (64-bit on all supported platforms) never truncates.
var buf = [UInt8](repeating: 0, count: Int(self.blockSize))
// JBD2 is a big-endian format regardless of host byte order (§3.6.1).
func writeU32BigEndian(_ value: UInt32, at offset: Int) {
buf[offset] = UInt8((value >> 24) & 0xFF)
buf[offset + 1] = UInt8((value >> 16) & 0xFF)
buf[offset + 2] = UInt8((value >> 8) & 0xFF)
buf[offset + 3] = UInt8(value & 0xFF)
}
// JBD2 block header (§3.6.3): https://www.kernel.org/doc/html/latest/filesystems/ext4/journal.html#block-header
writeU32BigEndian(EXT4.JournalMagic, at: 0x00) // h_magic
writeU32BigEndian(4, at: 0x04) // h_blocktype = superblock v2
writeU32BigEndian(1, at: 0x08) // h_sequence
// JBD2 superblock body (§3.6.4): https://www.kernel.org/doc/html/latest/filesystems/ext4/journal.html#super-block
writeU32BigEndian(self.blockSize, at: 0x0C) // s_blocksize
writeU32BigEndian(journalBlocks, at: 0x10) // s_maxlen
writeU32BigEndian(1, at: 0x14) // s_first (first usable block)
writeU32BigEndian(1, at: 0x18) // s_sequence
// 0x1C s_start: left zero kernel treats zero as "journal empty, begin at s_first"
// 0x20 s_errno: left zero no prior abort error
// 0x24 s_feature_compat: left zero no optional features (e.g. data-block checksums)
// 0x28 s_feature_incompat: left zero non-zero unrecognised flags would cause mount refusal
// 0x2C s_feature_ro_compat: left zero no flags defined by the spec
// s_uuid at 0x30 (16 bytes)
let uuidBytes = [
filesystemUUID.0, filesystemUUID.1, filesystemUUID.2, filesystemUUID.3,
filesystemUUID.4, filesystemUUID.5, filesystemUUID.6, filesystemUUID.7,
filesystemUUID.8, filesystemUUID.9, filesystemUUID.10, filesystemUUID.11,
filesystemUUID.12, filesystemUUID.13, filesystemUUID.14, filesystemUUID.15,
]
buf[0x30..<0x40] = uuidBytes[...]
writeU32BigEndian(1, at: 0x40) // s_nr_users
let maxTrans = min(journalBlocks / 4, 32768)
writeU32BigEndian(maxTrans, at: 0x48) // s_max_transaction
writeU32BigEndian(maxTrans, at: 0x4C) // s_max_trans_data
// s_users[0] at 0x100 (first entry of 768-byte users array)
buf[0x100..<0x110] = uuidBytes[...]
try self.handle.write(contentsOf: buf)
}
private func zeroJournalBlocks(count: UInt32) throws {
guard count > 0 else { return }
let chunkSize = 1.mib()
// Safe: both operands are UInt32, so their product peaks at ~17 TiB, which fits
// in Int64 (the width of Int on all 64-bit Apple platforms).
let totalBytes = Int(count) * Int(self.blockSize)
let zeroBuf = [UInt8](repeating: 0, count: min(Int(chunkSize), totalBytes))
var remaining = totalBytes
while remaining > 0 {
let toWrite = min(zeroBuf.count, remaining)
try self.handle.write(contentsOf: zeroBuf[0..<toWrite])
remaining -= toWrite
}
}
private func setupJournalInode(startBlock: UInt32, blockCount: UInt32) throws {
var journalInode = EXT4.Inode()
journalInode.mode = EXT4.Inode.Mode(.S_IFREG, 0o600)
journalInode.uid = 0
journalInode.gid = 0
let size = UInt64(blockCount) * UInt64(self.blockSize)
journalInode.sizeLow = size.lo
journalInode.sizeHigh = size.hi
let now = Date().fs()
journalInode.atime = now.lo
journalInode.atimeExtra = now.hi
journalInode.ctime = now.lo
journalInode.ctimeExtra = now.hi
journalInode.mtime = now.lo
journalInode.mtimeExtra = now.hi
journalInode.crtime = now.lo
journalInode.crtimeExtra = now.hi
journalInode.linksCount = 1
journalInode.extraIsize = UInt16(EXT4.ExtraIsize)
journalInode.flags = EXT4.InodeFlag.extents.rawValue | EXT4.InodeFlag.hugeFile.rawValue
// Journal is one contiguous allocation numExtents = 1 extent tree fits inline
// in the inode, so writeExtents needs no extra disk I/O for extent index blocks.
// Safe: blockCount is at most UInt32.max and startBlock 0, so the addition could
// theoretically overflow but zeroJournalBlocks would have already failed with an
// I/O error if the journal extended past the end of the filesystem image.
journalInode = try self.writeExtents(journalInode, (startBlock, startBlock + blockCount))
self.inodes[Int(EXT4.JournalInode) - 1].pointee = journalInode
}
func journalInodeBlockBackup() -> (
UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32,
UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32,
UInt32
) {
let ji = self.inodes[Int(EXT4.JournalInode) - 1].pointee
// s_jnl_blocks layout (§4.1.2): first 15 words = i_block[] extent-tree data,
// 16th word (index 15) = i_size_high, 17th word (index 16) = i_size.
var words = [UInt32](repeating: 0, count: 17)
withUnsafeBytes(of: ji.block) { bytes in
for i in 0..<15 {
words[i] = bytes.load(fromByteOffset: i * 4, as: UInt32.self)
}
}
words[15] = ji.sizeHigh // i_size_high (16th element per spec)
words[16] = ji.sizeLow // i_size (17th element per spec)
return (
words[0], words[1], words[2], words[3],
words[4], words[5], words[6], words[7],
words[8], words[9], words[10], words[11],
words[12], words[13], words[14], words[15],
words[16]
)
}
}
@@ -0,0 +1,36 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
extension EXT4 {
class Ptr<T> {
let underlying: UnsafeMutablePointer<T>
var pointee: T {
get { underlying.pointee }
set { underlying.pointee = newValue }
}
init(_ value: T) {
self.underlying = .allocate(capacity: 1)
self.underlying.initialize(to: value)
}
deinit {
underlying.deinitialize(count: 1)
underlying.deallocate()
}
}
}
@@ -0,0 +1,277 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
import SystemPackage
extension EXT4 {
/// The `EXT4Reader` opens a block device, parses the superblock, and loads group descriptors & inodes.
public class EXT4Reader {
public var superBlock: EXT4.SuperBlock {
self._superBlock
}
let handle: FileHandle
let _superBlock: EXT4.SuperBlock
private var groupDescriptors: [UInt32: EXT4.GroupDescriptor] = [:]
private var inodes: [InodeNumber: EXT4.Inode] = [:]
var hardlinks: [FilePath: InodeNumber] = [:]
var tree: EXT4.FileTree = EXT4.FileTree(EXT4.RootInode, ".")
var blockSize: UInt64 { UInt64(_superBlock.blockSize) }
private var groupDescriptorSize: UInt16 {
if _superBlock.featureIncompat & EXT4.IncompatFeature.bit64.rawValue != 0 {
return _superBlock.descSize
}
return UInt16(MemoryLayout<EXT4.GroupDescriptor>.size)
}
public init(blockDevice: FilePath) throws {
guard FileManager.default.fileExists(atPath: blockDevice.description) else {
throw EXT4.Error.notFound(blockDevice.description)
}
guard let fileHandle = FileHandle(forReadingAtPath: blockDevice) else {
throw Error.notFound(blockDevice.description)
}
self.handle = fileHandle
try handle.seek(toOffset: EXT4.SuperBlockOffset)
let superBlockSize = MemoryLayout<EXT4.SuperBlock>.size
guard let data = try? self.handle.read(upToCount: superBlockSize) else {
throw EXT4.Error.couldNotReadSuperBlock(blockDevice.description, EXT4.SuperBlockOffset, superBlockSize)
}
let sb = data.withUnsafeBytes { ptr in
ptr.loadLittleEndian(as: EXT4.SuperBlock.self)
}
guard sb.magic == EXT4.SuperBlockMagic else {
throw EXT4.Error.invalidSuperBlock
}
self._superBlock = sb
var items: [(item: Ptr<EXT4.FileTree.FileTreeNode>, inode: InodeNumber)] = [
(self.tree.root, EXT4.RootInode)
]
while items.count > 0 {
guard let item = items.popLast() else {
break
}
let (itemPtr, inodeNum) = item
let childItems = try self.children(of: inodeNum)
let root = itemPtr.pointee
for (itemName, itemInodeNum) in childItems {
if itemName == "." || itemName == ".." {
continue
}
if self.inodes[itemInodeNum] != nil {
// we have seen this inode before, we will hard link this file to it
guard let parentPath = itemPtr.pointee.path else {
continue
}
let path = parentPath.join(itemName)
self.hardlinks[path] = itemInodeNum
continue
}
let blocks = try self.getExtents(inode: itemInodeNum)
let itemTreeNode = FileTree.FileTreeNode(
inode: itemInodeNum,
name: itemName,
parent: itemPtr,
children: []
)
if let blocks {
if blocks.count > 1 {
itemTreeNode.additionalBlocks = Array(blocks.dropFirst())
}
itemTreeNode.blocks = blocks.first
}
let itemTreeNodePtr = Ptr(itemTreeNode)
root.children.append(itemTreeNodePtr)
itemPtr.pointee = root
let itemInode = try self.getInode(number: itemInodeNum)
if itemInode.mode.isDir() {
items.append((itemTreeNodePtr, itemInodeNum))
}
}
}
}
deinit {
try? self.handle.close()
}
private func readGroupDescriptor(_ number: UInt32) throws -> GroupDescriptor {
let bs = self.blockSize
let offset = bs + UInt64(number) * UInt64(self.groupDescriptorSize)
try self.handle.seek(toOffset: offset)
guard let data = try? self.handle.read(upToCount: MemoryLayout<EXT4.GroupDescriptor>.size) else {
throw EXT4.Error.couldNotReadGroup(number)
}
let gd = data.withUnsafeBytes { ptr in
ptr.loadLittleEndian(as: EXT4.GroupDescriptor.self)
}
return gd
}
private func readInode(_ number: UInt32) throws -> Inode {
let inodeGroupNumber = ((number - 1) / self._superBlock.inodesPerGroup)
let numberInGroup = UInt64((number - 1) % self._superBlock.inodesPerGroup)
let gd = try getGroupDescriptor(inodeGroupNumber)
let inodeTableStart = UInt64(gd.inodeTableLow) * self.blockSize
let inodeOffset: UInt64 = inodeTableStart + numberInGroup * UInt64(_superBlock.inodeSize)
try self.handle.seek(toOffset: inodeOffset)
guard let inodeData = try self.handle.read(upToCount: MemoryLayout<EXT4.Inode>.size) else {
throw EXT4.Error.couldNotReadInode(number)
}
let inode = inodeData.withUnsafeBytes { ptr in
ptr.loadLittleEndian(as: EXT4.Inode.self)
}
return inode
}
private func getDirTree(_ number: InodeNumber) throws -> [(String, InodeNumber)] {
var children: [(String, InodeNumber)] = []
let extents = try getExtents(inode: number) ?? []
for (start, end) in extents {
try self.seek(block: start)
for i in 0..<(end - start) {
guard let dirEntryBlock = try self.handle.read(upToCount: Int(self.blockSize)) else {
throw EXT4.Error.couldNotReadBlock(start + i)
}
let childEntries = try getDirEntries(dirTree: dirEntryBlock)
children.append(contentsOf: childEntries)
}
}
return children.sorted { a, b in
a.0 < b.0
}
}
private func getDirEntries(dirTree: Data) throws -> [(String, InodeNumber)] {
var children: [(String, InodeNumber)] = []
var offset = 0
let entrySize = MemoryLayout<DirectoryEntry>.size
while offset < dirTree.count {
let dirEntry = dirTree.subdata(in: offset..<offset + entrySize).withUnsafeBytes {
$0.loadLittleEndian(as: DirectoryEntry.self)
}
guard dirEntry.recordLength >= entrySize else {
break
}
if dirEntry.inode == 0 {
offset += Int(dirEntry.recordLength)
continue
}
let nameData = dirTree.subdata(in: offset + 8..<offset + 8 + Int(dirEntry.nameLength))
let name = String(data: nameData, encoding: .utf8) ?? ""
children.append((name, dirEntry.inode))
offset += Int(dirEntry.recordLength)
}
return children.sorted { a, b in
a.0 < b.0
}
}
func getExtents(inode: InodeNumber) throws -> [(start: UInt32, end: UInt32)]? {
let inode = try self.getInode(number: inode)
let inodeBlock = Data(tupleToArray(inode.block))
var offset = 0
var extents: [(start: UInt32, end: UInt32)] = []
let extentHeaderSize = MemoryLayout<ExtentHeader>.size
let extentIndexSize = MemoryLayout<ExtentIndex>.size
let extentLeafSize = MemoryLayout<ExtentLeaf>.size
// read extent header
let header = inodeBlock.subdata(in: offset..<offset + extentHeaderSize).withUnsafeBytes {
$0.loadLittleEndian(as: ExtentHeader.self)
}
guard header.magic == EXT4.ExtentHeaderMagic else {
return []
}
offset += extentHeaderSize // Jump to entries
switch header.depth {
case 0:
// When depth is 0 the extent header is followed by extent leaves
for _ in 0..<header.entries {
let leaf = inodeBlock.subdata(in: offset..<offset + extentLeafSize).withUnsafeBytes {
$0.loadLittleEndian(as: ExtentLeaf.self)
}
extents.append((leaf.startLow, leaf.startLow + UInt32(leaf.length)))
offset += extentLeafSize
}
case 1:
// When depth is 1 the extent header is followed by extent indices which point to leaves
for _ in 0..<header.entries {
let indexNode = inodeBlock.subdata(in: offset..<offset + extentIndexSize).withUnsafeBytes {
$0.loadLittleEndian(as: ExtentIndex.self)
}
try self.seek(block: indexNode.leafLow)
guard let block = try self.handle.read(upToCount: Int(self.blockSize)) else {
throw EXT4.Error.couldNotReadBlock(indexNode.leafLow)
}
var blockOffset = 0
let leafHeader = block.subdata(in: blockOffset..<extentHeaderSize).withUnsafeBytes {
$0.loadLittleEndian(as: ExtentHeader.self)
}
guard leafHeader.magic == EXT4.ExtentHeaderMagic else {
throw Error.invalidExtents
}
blockOffset += extentHeaderSize
for _ in 0..<leafHeader.entries {
let leaf = block.subdata(in: blockOffset..<blockOffset + extentLeafSize).withUnsafeBytes {
$0.loadLittleEndian(as: ExtentLeaf.self)
}
extents.append((leaf.startLow, leaf.startLow + UInt32(leaf.length)))
blockOffset += extentLeafSize
}
offset += extentIndexSize
}
default:
throw Error.deepExtentsUnimplemented
}
return extents
}
// MARK: Internal functions
func getInode(number: UInt32) throws -> Inode {
if let inode = self.inodes[number] {
return inode
}
let inode = try readInode(number)
self.inodes[number] = inode
return inode
}
func getGroupDescriptor(_ number: UInt32) throws -> GroupDescriptor {
if let gd = self.groupDescriptors[number] {
return gd
}
let gd = try readGroupDescriptor(number)
self.groupDescriptors[number] = gd
return gd
}
func children(of number: EXT4.InodeNumber) throws -> [(String, InodeNumber)] {
try getDirTree(number)
}
}
}
@@ -0,0 +1,633 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
// swiftlint:disable large_tuple
import Foundation
extension EXT4 {
public struct SuperBlock {
public var inodesCount: UInt32 = 0
public var blocksCountLow: UInt32 = 0
public var reservedBlocksCountLow: UInt32 = 0
public var freeBlocksCountLow: UInt32 = 0
public var freeInodesCount: UInt32 = 0
public var firstDataBlock: UInt32 = 0
public var logBlockSize: UInt32 = 0
public var logClusterSize: UInt32 = 0
public var blockSize: UInt32 { 1024 << logBlockSize }
public var blocksPerGroup: UInt32 = 0
public var clustersPerGroup: UInt32 = 0
public var inodesPerGroup: UInt32 = 0
public var mtime: UInt32 = 0
public var wtime: UInt32 = 0
public var mountCount: UInt16 = 0
public var maxMountCount: UInt16 = 0
public var magic: UInt16 = 0
public var state: UInt16 = 0
public var errors: UInt16 = 0
public var minorRevisionLevel: UInt16 = 0
public var lastCheck: UInt32 = 0
public var checkInterval: UInt32 = 0
public var creatorOS: UInt32 = 0
public var revisionLevel: UInt32 = 0
public var defaultReservedUid: UInt16 = 0
public var defaultReservedGid: UInt16 = 0
public var firstInode: UInt32 = 0
public var inodeSize: UInt16 = 0
public var blockGroupNr: UInt16 = 0
public var featureCompat: UInt32 = 0
public var featureIncompat: UInt32 = 0
public var featureRoCompat: UInt32 = 0
public var uuid:
(
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8
) = (
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0
)
public var volumeName:
(
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8
) = (
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0
)
public var lastMounted:
(
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8
) = (
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0
)
public var algorithmUsageBitmap: UInt32 = 0
public var preallocBlocks: UInt8 = 0
public var preallocDirBlocks: UInt8 = 0
public var reservedGdtBlocks: UInt16 = 0
public var journalUUID:
(
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8
) = (
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0
)
public var journalInum: UInt32 = 0
public var journalDev: UInt32 = 0
public var lastOrphan: UInt32 = 0
public var hashSeed: (UInt32, UInt32, UInt32, UInt32) = (0, 0, 0, 0)
public var defHashVersion: UInt8 = 0
public var journalBackupType: UInt8 = 0
public var descSize: UInt16 = UInt16(MemoryLayout<GroupDescriptor>.size)
public var defaultMountOpts: UInt32 = 0
public var firstMetaBg: UInt32 = 0
public var mkfsTime: UInt32 = 0
public var journalBlocks:
(
UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32,
UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32,
UInt32
) = (
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0
)
public var blocksCountHigh: UInt32 = 0
public var reservedBlocksCountHigh: UInt32 = 0
public var freeBlocksCountHigh: UInt32 = 0
public var minExtraIsize: UInt16 = 0
public var wantExtraIsize: UInt16 = 0
public var flags: UInt32 = 0
public var raidStride: UInt16 = 0
public var mmpInterval: UInt16 = 0
public var mmpBlock: UInt64 = 0
public var raidStripeWidth: UInt32 = 0
public var logGroupsPerFlex: UInt8 = 0
public var checksumType: UInt8 = 0
public var reservedPad: UInt16 = 0
public var kbytesWritten: UInt64 = 0
public var snapshotInum: UInt32 = 0
public var snapshotID: UInt32 = 0
public var snapshotRBlocksCount: UInt64 = 0
public var snapshotList: UInt32 = 0
public var errorCount: UInt32 = 0
public var firstErrorTime: UInt32 = 0
public var firstErrorInode: UInt32 = 0
public var firstErrorBlock: UInt64 = 0
public var firstErrorFunc:
(
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8
) = (
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0
)
public var firstErrorLine: UInt32 = 0
public var lastErrorTime: UInt32 = 0
public var lastErrorInode: UInt32 = 0
public var lastErrorLine: UInt32 = 0
public var lastErrorBlock: UInt64 = 0
public var lastErrorFunc:
(
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8
) = (
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0
)
public var mountOpts:
(
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8
) = (
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0
)
public var userQuotaInum: UInt32 = 0
public var groupQuotaInum: UInt32 = 0
public var overheadBlocks: UInt32 = 0
public var backupBgs: (UInt32, UInt32) = (0, 0)
public var encryptAlgos: (UInt8, UInt8, UInt8, UInt8) = (0, 0, 0, 0)
public var encryptPwSalt:
(
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8
) = (
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0
)
public var lpfInode: UInt32 = 0
public var projectQuotaInum: UInt32 = 0
public var checksumSeed: UInt32 = 0
public var wtimeHigh: UInt8 = 0
public var mtimeHigh: UInt8 = 0
public var mkfsTimeHigh: UInt8 = 0
public var lastcheckHigh: UInt8 = 0
public var firstErrorTimeHigh: UInt8 = 0
public var lastErrorTimeHigh: UInt8 = 0
public var pad: (UInt8, UInt8) = (0, 0)
public var reserved:
(
UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32,
UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32,
UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32,
UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32,
UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32,
UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32,
UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32,
UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32,
UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32,
UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32,
UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32,
UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32
) = (
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0
)
public var checksum: UInt32 = 0
}
static let JournalMagic: UInt32 = 0xC03B_3998
static let JournalInode: InodeNumber = 8
static let MinJournalBlocks: UInt32 = 1024 // JBD2_MIN_JOURNAL_BLOCKS
struct DefaultMountOpts {
static let journalData: UInt32 = 0x0020 // data=journal
static let journalOrdered: UInt32 = 0x0040 // data=ordered
static let journalWriteback: UInt32 = 0x0060 // data=writeback
}
struct CompatFeature {
let rawValue: UInt32
static let dirPrealloc = CompatFeature(rawValue: 0x1)
static let imagicInodes = CompatFeature(rawValue: 0x2)
static let hasJournal = CompatFeature(rawValue: 0x4)
static let extAttr = CompatFeature(rawValue: 0x8)
static let resizeInode = CompatFeature(rawValue: 0x10)
static let dirIndex = CompatFeature(rawValue: 0x20)
static let lazyBg = CompatFeature(rawValue: 0x40)
static let excludeInode = CompatFeature(rawValue: 0x80)
static let excludeBitmap = CompatFeature(rawValue: 0x100)
static let sparseSuper2 = CompatFeature(rawValue: 0x200)
}
struct IncompatFeature {
let rawValue: UInt32
static let compression = IncompatFeature(rawValue: 0x1)
static let filetype = IncompatFeature(rawValue: 0x2)
static let recover = IncompatFeature(rawValue: 0x4)
static let journalDev = IncompatFeature(rawValue: 0x8)
static let metaBg = IncompatFeature(rawValue: 0x10)
static let extents = IncompatFeature(rawValue: 0x40)
static let bit64 = IncompatFeature(rawValue: 0x80)
static let mmp = IncompatFeature(rawValue: 0x100)
static let flexBg = IncompatFeature(rawValue: 0x200)
static let eaInode = IncompatFeature(rawValue: 0x400)
static let dirdata = IncompatFeature(rawValue: 0x1000)
static let csumSeed = IncompatFeature(rawValue: 0x2000)
static let largedir = IncompatFeature(rawValue: 0x4000)
static let inlineData = IncompatFeature(rawValue: 0x8000)
static let encrypt = IncompatFeature(rawValue: 0x10000)
}
struct RoCompatFeature {
let rawValue: UInt32
static let sparseSuper = RoCompatFeature(rawValue: 0x1)
static let largeFile = RoCompatFeature(rawValue: 0x2)
static let btreeDir = RoCompatFeature(rawValue: 0x4)
static let hugeFile = RoCompatFeature(rawValue: 0x8)
static let gdtCsum = RoCompatFeature(rawValue: 0x10)
static let dirNlink = RoCompatFeature(rawValue: 0x20)
static let extraIsize = RoCompatFeature(rawValue: 0x40)
static let hasSnapshot = RoCompatFeature(rawValue: 0x80)
static let quota = RoCompatFeature(rawValue: 0x100)
static let bigalloc = RoCompatFeature(rawValue: 0x200)
static let metadataCsum = RoCompatFeature(rawValue: 0x400)
static let replica = RoCompatFeature(rawValue: 0x800)
static let readonly = RoCompatFeature(rawValue: 0x1000)
static let project = RoCompatFeature(rawValue: 0x2000)
}
struct BlockGroupFlag {
let rawValue: UInt16
static let inodeUninit = BlockGroupFlag(rawValue: 0x1)
static let blockUninit = BlockGroupFlag(rawValue: 0x2)
static let inodeZeroed = BlockGroupFlag(rawValue: 0x4)
}
struct GroupDescriptor {
let blockBitmapLow: UInt32
let inodeBitmapLow: UInt32
let inodeTableLow: UInt32
let freeBlocksCountLow: UInt16
let freeInodesCountLow: UInt16
let usedDirsCountLow: UInt16
let flags: UInt16
let excludeBitmapLow: UInt32
let blockBitmapCsumLow: UInt16
let inodeBitmapCsumLow: UInt16
let itableUnusedLow: UInt16
let checksum: UInt16
}
struct GroupDescriptor64 {
let groupDescriptor: GroupDescriptor
let blockBitmapHigh: UInt32
let inodeBitmapHigh: UInt32
let inodeTableHigh: UInt32
let freeBlocksCountHigh: UInt16
let freeInodesCountHigh: UInt16
let usedDirsCountHigh: UInt16
let itableUnusedHigh: UInt16
let excludeBitmapHigh: UInt32
let blockBitmapCsumHigh: UInt16
let inodeBitmapCsumHigh: UInt16
let reserved: UInt32
}
public struct FileModeFlag: Sendable {
let rawValue: UInt16
public static let S_IXOTH = FileModeFlag(rawValue: 0x1)
public static let S_IWOTH = FileModeFlag(rawValue: 0x2)
public static let S_IROTH = FileModeFlag(rawValue: 0x4)
public static let S_IXGRP = FileModeFlag(rawValue: 0x8)
public static let S_IWGRP = FileModeFlag(rawValue: 0x10)
public static let S_IRGRP = FileModeFlag(rawValue: 0x20)
public static let S_IXUSR = FileModeFlag(rawValue: 0x40)
public static let S_IWUSR = FileModeFlag(rawValue: 0x80)
public static let S_IRUSR = FileModeFlag(rawValue: 0x100)
public static let S_ISVTX = FileModeFlag(rawValue: 0x200)
public static let S_ISGID = FileModeFlag(rawValue: 0x400)
public static let S_ISUID = FileModeFlag(rawValue: 0x800)
public static let S_IFIFO = FileModeFlag(rawValue: 0x1000)
public static let S_IFCHR = FileModeFlag(rawValue: 0x2000)
public static let S_IFDIR = FileModeFlag(rawValue: 0x4000)
public static let S_IFBLK = FileModeFlag(rawValue: 0x6000)
public static let S_IFREG = FileModeFlag(rawValue: 0x8000)
public static let S_IFLNK = FileModeFlag(rawValue: 0xA000)
public static let S_IFSOCK = FileModeFlag(rawValue: 0xC000)
public static let TypeMask = FileModeFlag(rawValue: 0xF000)
}
public typealias InodeNumber = UInt32
public struct Inode {
public var mode: UInt16 = 0
public var uid: UInt16 = 0
public var sizeLow: UInt32 = 0
public var atime: UInt32 = 0
public var ctime: UInt32 = 0
public var mtime: UInt32 = 0
public var dtime: UInt32 = 0
public var gid: UInt16 = 0
public var linksCount: UInt16 = 0
public var blocksLow: UInt32 = 0
public var flags: UInt32 = 0
public var version: UInt32 = 0
public var block:
(
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8
) = (
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0
)
public var generation: UInt32 = 0
public var xattrBlockLow: UInt32 = 0
public var sizeHigh: UInt32 = 0
public var obsoleteFragmentAddr: UInt32 = 0
public var blocksHigh: UInt16 = 0
public var xattrBlockHigh: UInt16 = 0
public var uidHigh: UInt16 = 0
public var gidHigh: UInt16 = 0
public var checksumLow: UInt16 = 0
public var reserved: UInt16 = 0
public var extraIsize: UInt16 = 0
public var checksumHigh: UInt16 = 0
public var ctimeExtra: UInt32 = 0
public var mtimeExtra: UInt32 = 0
public var atimeExtra: UInt32 = 0
public var crtime: UInt32 = 0
public var crtimeExtra: UInt32 = 0
public var versionHigh: UInt32 = 0
public var projid: UInt32 = 0 // Size until this point is 160 bytes
public var inlineXattrs:
( // 96 bytes for extended attributes
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8
) = (
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0
)
public static func Mode(_ mode: FileModeFlag, _ perm: UInt16) -> UInt16 {
mode.rawValue | perm
}
}
struct InodeFlag {
let rawValue: UInt32
static let secRm = InodeFlag(rawValue: 0x1)
static let unRm = InodeFlag(rawValue: 0x2)
static let compressed = InodeFlag(rawValue: 0x4)
static let sync = InodeFlag(rawValue: 0x8)
static let immutable = InodeFlag(rawValue: 0x10)
static let append = InodeFlag(rawValue: 0x20)
static let noDump = InodeFlag(rawValue: 0x40)
static let noAtime = InodeFlag(rawValue: 0x80)
static let dirtyCompressed = InodeFlag(rawValue: 0x100)
static let compressedClusters = InodeFlag(rawValue: 0x200)
static let noCompress = InodeFlag(rawValue: 0x400)
static let encrypted = InodeFlag(rawValue: 0x800)
static let hashedIndex = InodeFlag(rawValue: 0x1000)
static let magic = InodeFlag(rawValue: 0x2000)
static let journalData = InodeFlag(rawValue: 0x4000)
static let noTail = InodeFlag(rawValue: 0x8000)
static let dirSync = InodeFlag(rawValue: 0x10000)
static let topDir = InodeFlag(rawValue: 0x20000)
static let hugeFile = InodeFlag(rawValue: 0x40000)
static let extents = InodeFlag(rawValue: 0x80000)
static let eaInode = InodeFlag(rawValue: 0x200000)
static let eofBlocks = InodeFlag(rawValue: 0x400000)
static let snapfile = InodeFlag(rawValue: 0x0100_0000)
static let snapfileDeleted = InodeFlag(rawValue: 0x0400_0000)
static let snapfileShrunk = InodeFlag(rawValue: 0x0800_0000)
static let inlineData = InodeFlag(rawValue: 0x1000_0000)
static let projectIDInherit = InodeFlag(rawValue: 0x2000_0000)
static let reserved = InodeFlag(rawValue: 0x8000_0000)
}
struct ExtentHeader {
let magic: UInt16
let entries: UInt16
let max: UInt16
let depth: UInt16
let generation: UInt32
}
struct ExtentIndex {
let block: UInt32
let leafLow: UInt32
let leafHigh: UInt16
let unused: UInt16
}
struct ExtentLeaf {
let block: UInt32
let length: UInt16
let startHigh: UInt16
let startLow: UInt32
}
struct ExtentTail {
let checksum: UInt32
}
struct ExtentIndexNode {
var header: ExtentHeader
var indices: [ExtentIndex]
}
struct ExtentLeafNode {
var header: ExtentHeader
var leaves: [ExtentLeaf]
}
struct DirectoryEntry {
let inode: InodeNumber
let recordLength: UInt16
let nameLength: UInt8
let fileType: UInt8
// let name: [UInt8]
}
enum FileType: UInt8 {
case unknown = 0x0
case regular = 0x1
case directory = 0x2
case character = 0x3
case block = 0x4
case fifo = 0x5
case socket = 0x6
case symbolicLink = 0x7
}
struct DirectoryEntryTail {
let reservedZero1: UInt32
let recordLength: UInt16
let reservedZero2: UInt8
let fileType: UInt8
let checksum: UInt32
}
struct DirectoryTreeRoot {
let dot: DirectoryEntry
let dotName: [UInt8]
let dotDot: DirectoryEntry
let dotDotName: [UInt8]
let reservedZero: UInt32
let hashVersion: UInt8
let infoLength: UInt8
let indirectLevels: UInt8
let unusedFlags: UInt8
let limit: UInt16
let count: UInt16
let block: UInt32
// let entries: [DirectoryTreeEntry]
}
struct DirectoryTreeNode {
let fakeInode: UInt32
let fakeRecordLength: UInt16
let nameLength: UInt8
let fileType: UInt8
let limit: UInt16
let count: UInt16
let block: UInt32
// let entries: [DirectoryTreeEntry]
}
struct DirectoryTreeEntry {
let hash: UInt32
let block: UInt32
}
struct DirectoryTreeTail {
let reserved: UInt32
let checksum: UInt32
}
struct XAttrEntry {
let nameLength: UInt8
let nameIndex: UInt8
let valueOffset: UInt16
let valueInum: UInt32
let valueSize: UInt32
let hash: UInt32
}
struct XAttrHeader {
let magic: UInt32
let referenceCount: UInt32
let blocks: UInt32
let hash: UInt32
let checksum: UInt32
let reserved: [UInt32]
}
}
extension EXT4.Inode {
public static func Root() -> EXT4.Inode {
var inode = Self() // inode
inode.mode = Self.Mode(.S_IFDIR, 0o755)
inode.linksCount = 2
inode.uid = 0
inode.gid = 0
// time
let now = Date().fs()
let now_lo: UInt32 = now.lo
let now_hi: UInt32 = now.hi
inode.atime = now_lo
inode.atimeExtra = now_hi
inode.ctime = now_lo
inode.ctimeExtra = now_hi
inode.mtime = now_lo
inode.mtimeExtra = now_hi
inode.crtime = now_lo
inode.crtimeExtra = now_hi
inode.flags = EXT4.InodeFlag.hugeFile.rawValue
inode.extraIsize = UInt16(EXT4.ExtraIsize)
return inode
}
}
@@ -0,0 +1,317 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
/*
* Note: Both the entries and values for the attributes need to occupy a size that is a multiple of 4,
* meaning, in cases where the attribute name or value is not a multiple of 4, it is padded with 0
* until it reaches that size.
*/
extension EXT4 {
public struct ExtendedAttribute {
public static let prefixMap: [Int: String] = [
1: "user.",
2: "system.posix_acl_access",
3: "system.posix_acl_default",
4: "trusted.",
6: "security.",
7: "system.",
8: "system.richacl",
]
let name: String
let index: UInt8
let value: [UInt8]
var sizeValue: UInt32 {
UInt32((value.count + 3) & ~3)
}
var sizeEntry: UInt32 {
UInt32((name.count + 3) & ~3 + 16) // 16 bytes are needed to store other metadata for the xattr entry
}
var size: UInt32 {
sizeEntry + sizeValue
}
var fullName: String {
Self.decompressName(id: Int(index), suffix: name)
}
var hash: UInt32 {
var hash: UInt32 = 0
for char in name {
hash = (hash << 5) ^ (hash >> 27) ^ UInt32(char.asciiValue!)
}
var i = 0
while i + 3 < value.count {
let s = value[i..<i + 4]
let v = s.withUnsafeBytes { $0.loadLittleEndian(as: UInt32.self) }
hash = (hash << 16) ^ (hash >> 16) ^ v
i += 4
}
if value.count % 4 != 0 {
let last = value.count & ~3
var buff: [UInt8] = [0, 0, 0, 0]
for (i, byte) in value[last...].enumerated() {
buff[i] = byte
}
let v = buff.withUnsafeBytes { $0.loadLittleEndian(as: UInt32.self) }
hash = (hash << 16) ^ (hash >> 16) ^ v
}
return hash
}
init(name: String, value: [UInt8]) {
let compressed = Self.compressName(name)
self.name = compressed.str
self.index = compressed.id
self.value = value
}
init(idx: UInt8, compressedName name: String, value: [UInt8]) {
self.name = name
self.index = idx
self.value = value
}
// MARK: Class methods
public static func compressName(_ name: String) -> (id: UInt8, str: String) {
for (id, prefix) in prefixMap.sorted(by: { $1.1.count < $0.1.count }) where name.hasPrefix(prefix) {
return (UInt8(id), String(name.dropFirst(prefix.count)))
}
return (0, name)
}
public static func decompressName(id: Int, suffix: String) -> String {
guard let prefix = prefixMap[id] else {
return suffix
}
return "\(prefix)\(suffix)"
}
}
public struct FileXattrsState {
private let inodeCapacity: UInt32
private let blockCapacity: UInt32
private let inode: UInt32 // the inode number for which we are tracking these xattrs
var inlineAttributes: [ExtendedAttribute] = []
var blockAttributes: [ExtendedAttribute] = []
private var usedSizeInline: UInt32 = 0
private var usedSizeBlock: UInt32 = 0
private var inodeFreeBytes: UInt32 {
self.inodeCapacity - EXT4.XattrInodeHeaderSize - usedSizeInline - 4 // need to have 4 null bytes b/w xattr entries and values
}
private var blockFreeBytes: UInt32 {
self.blockCapacity - EXT4.XattrBlockHeaderSize - usedSizeBlock - 4
}
init(inode: UInt32, inodeXattrCapacity: UInt32, blockCapacity: UInt32) {
self.inode = inode
self.inodeCapacity = inodeXattrCapacity
self.blockCapacity = blockCapacity
}
public mutating func add(_ attribute: ExtendedAttribute) throws {
let size = attribute.size
if size <= inodeFreeBytes {
usedSizeInline += size
inlineAttributes.append(attribute)
return
}
if size <= blockFreeBytes {
usedSizeBlock += size
blockAttributes.append(attribute)
return
}
throw Error.insufficientSpace(Int(self.inode))
}
public func writeInlineAttributes(buffer: inout [UInt8]) throws {
var idx = 0
withUnsafeLittleEndianBytes(
of: EXT4.XAttrHeaderMagic,
body: { bytes in
for byte in bytes {
buffer[idx] = byte
idx += 1
}
})
try Self.write(buffer: &buffer, attrs: self.inlineAttributes, start: UInt16(idx), delta: 0, inline: true)
}
public func writeBlockAttributes(buffer: inout [UInt8]) throws {
var idx = 0
for val in [EXT4.XAttrHeaderMagic, 1, 1] {
withUnsafeLittleEndianBytes(
of: UInt32(val),
body: { bytes in
for byte in bytes {
buffer[idx] = byte
idx += 1
}
})
}
while idx != 32 {
buffer[idx] = 0
idx += 1
}
var attributes = self.blockAttributes
attributes.sort(by: {
if $0.index != $1.index {
return $0.index < $1.index
}
if $0.name.count != $1.name.count {
return $0.name.count < $1.name.count
}
return $0.name < $1.name
})
try Self.write(buffer: &buffer, attrs: attributes, start: UInt16(idx), delta: UInt16(idx), inline: false)
}
/// Writes the specified list of extended attribute entries and their values to the provided
/// This method does not fill in any headers (Inode inline / block level) that may be required to parse these attributes
///
/// - Parameters:
/// - buffer: An array of [UInt8] where the data will be written into
/// - attrs: The list of ExtendedAttributes to write
/// - start: the index from where data should be put into the buffer - useful when if you dont want this method to be overwriting existing data
/// - delta: index from where the begin the offset calculations
/// - inline: if the byte buffer being written into is an inline data block for an inode: Determines the hash calculation
private static func write(
buffer: inout [UInt8], attrs: [ExtendedAttribute], start: UInt16, delta: UInt16, inline: Bool
) throws {
var offset: UInt16 = UInt16(buffer.count) + delta - start
var front = Int(start)
var end = buffer.count
for attribute in attrs {
guard end - front >= 4 else {
throw Error.malformedXattrBuffer
}
var out: [UInt8] = []
let v = attribute.sizeValue
offset -= UInt16(v)
out.append(UInt8(attribute.name.count))
out.append(attribute.index)
withUnsafeLittleEndianBytes(
of: UInt16(offset),
body: { bytes in
out.append(contentsOf: bytes)
})
out.append(contentsOf: [0, 0, 0, 0]) // these next four bytes indicate that the attr values are in the same block
withUnsafeLittleEndianBytes(
of: UInt32(attribute.value.count),
body: { bytes in
out.append(contentsOf: bytes)
})
if !inline {
withUnsafeLittleEndianBytes(
of: UInt32(attribute.hash),
body: { bytes in
out.append(contentsOf: bytes)
})
} else {
out.append(contentsOf: [0, 0, 0, 0])
}
guard let name = attribute.name.data(using: .ascii) else {
throw Error.convertAsciiString(attribute.name)
}
out.append(contentsOf: [UInt8](name))
while out.count < Int(attribute.sizeEntry) { // ensure that xattr entry size is a multiple of 4
out.append(0)
}
for (i, byte) in out.enumerated() {
buffer[front + i] = byte
}
front += out.count
end -= Int(attribute.sizeValue)
for (i, byte) in attribute.value.enumerated() {
buffer[end + i] = byte
}
}
}
public static func read(buffer: [UInt8], start: Int, offset: Int) throws -> [ExtendedAttribute] {
var i = start
var attribs: [ExtendedAttribute] = []
// 16 is the size of 1 XAttrEntry
while i + 16 <= buffer.count {
let attributeStart = i
let rawXattrEntry = Array(buffer[i..<i + 16])
let xattrEntry = try EXT4.XAttrEntry(using: rawXattrEntry)
i += 16
var endIndex = i + Int(xattrEntry.nameLength)
guard endIndex <= buffer.count else {
continue
}
let rawName = buffer[i..<endIndex]
guard let name = String(bytes: rawName, encoding: .ascii) else {
throw Error.nonAsciiXattrName
}
let valueStart = Int(xattrEntry.valueOffset) + offset
let valueEnd = Int(xattrEntry.valueOffset) + Int(xattrEntry.valueSize) + offset
guard valueEnd <= buffer.count else {
break
}
let value = [UInt8](buffer[valueStart..<valueEnd])
let xattr = ExtendedAttribute(idx: xattrEntry.nameIndex, compressedName: name, value: value)
attribs.append(xattr)
i = attributeStart + xattr.sizeEntry
// The next 4 bytes being null indicate that there are no more attributes to read
endIndex = i + 3
guard endIndex < buffer.count else {
continue
}
if Array(buffer[i...i + 3]) == [0, 0, 0, 0] {
break
}
}
return attribs
}
public enum Error: CustomStringConvertible, Swift.Error {
case insufficientSpace(_ inode: Int)
case malformedXattrBuffer
case convertAsciiString(_ s: String)
case nonAsciiXattrName
case missingXAttrHeader
public var description: String {
switch self {
case .insufficientSpace(let inode):
return "cannot fit xattr for inode \(inode)"
case .malformedXattrBuffer:
return "malformed extended attribute buffer"
case .convertAsciiString(let s):
return "cannot convert string \(s) to a list of ASCII characters"
case .nonAsciiXattrName:
return "extended attribute name contains non-ASCII bytes"
case .missingXAttrHeader:
return "missing header for extended attribute entry"
}
}
}
}
}
+354
View File
@@ -0,0 +1,354 @@
//===----------------------------------------------------------------------===//
// 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 ContainerizationOS
import Foundation
/**
```
# EXT4 Filesystem Layout
The EXT4 filesystem divides the disk into an upfront metadata section followed by several logical groups known as block groups. The
metadata section looks like this:
+--------------------------+
| Boot Sector (1024) |
+--------------------------+
| Superblock (1024) |
+--------------------------+
| Empty (2048) |
+--------------------------+
| |
| [Block Group Descriptors]|
| |
| - Free/used block bitmap |
| - Free/used inode bitmap |
| - Inode table pointer |
| - Other metadata |
| |
+--------------------------+
## Block Groups
Each block group optionally stores a copy of the superblock and group descriptor table for disaster recovery.
The rest of the block group comprises of data blocks. The size of each block group is dynamically decided
while formatting, based on total amount of space available on the disk.
+--------------------------+
| Block Group 0 |
| +------------------+ |
| | Super Block | |
| +------------------+ |
| | Group Desc. | |
| +------------------+ |
| | Data Blocks | |
| | | |
| +------------------+ |
+--------------------------+
| Block Group 1 |
| +------------------+ |
| | Super Block | |
| +------------------+ |
| | Group Desc. | |
| +------------------+ |
| | Data Blocks | |
| | | |
| +------------------+ |
+--------------------------+
| ... |
+--------------------------+
| Block Group N |
| +------------------+ |
| | Super Block | |
| +------------------+ |
| | Group Desc. | |
| +------------------+ |
| | Data Blocks | |
| | | |
| +------------------+ |
+--------------------------+
The descriptor for each block group contain the following information:
- Block Bitmap
- Inode Bitmap
- Pointer to Inode Table
- other metadata such as used block count, num. dirs etc.
### Block Bitmap
A sequence of bits, where each bit represents a block in the block group.
1: In use block
0: Free block
+---------------+---------------+
| Block |
| Bitmap |
+---------------+---------------+
| 1 0 1 0 1 1 0 0 |
+---------------+---------------+
| | | | | | | |
| | | | | | | |
v v v v v v v v
+---+---+---+---+---+---+---+---+
| B | | B | | B | B | | |
+---+---+---+---+---+---+---+---+
Whenever a file is created, free data blocks are identified by using this table.
When it is deleted, the corresponding data blocks are marked as free.
### Inode Bitmap
A sequence of bits, where each bit represents an inode in the block group. Since
inodes per group is a fixed number, this bitmap is made to be of sufficient length
to accommodate that many inodes
1: In use inode
0: Free inode
+---------------+---------------+
| Inode |
| Bitmap |
+---------------+---------------+
| 1 0 1 0 1 1 0 0 |
+---------------+---------------+
| | | | | | | |
| | | | | | | |
v v v v v v v v
+---+---+---+---+---+---+---+---+
| I | | I | | I | I | | |
+---+---+---+---+---+---+---+---+
## Inode table
Inode table provides a mapping from Inode -> Data blocks. In this implementation, inode size is set to 256 bytes.
Inode table uses extents to efficiently describe the mapping.
+-----------------------+
| Inode Table |
+-----------------------+
| Inode | Metadata |
+-------+---------------+
| 1 | permissions |
| | size |
| | user ID |
| | group ID |
| | timestamps |
| | block |
| | blocks count |
+-------+---------------+
| 2 | ... |
+-------+---------------+
| ... | ... |
+-------+---------------+
The length of `block` field in the inode table is 60 bytes. This field contains an extent tree
that holds information about ranges of blocks used by the file. For smaller files, the entire extent
tree can be stored within this field.
+-----------------------+
| Inode |
+-----------------------+
| Metadata |
+-----------------------+
| Extent Tree |
| +-------------------+ |
| | Extent Leaf Node | |
| +-------------------+ |
| | - Start Block | |
| | - Block Count | |
| | - ... | |
| +-------------------+ |
+-----------------------+
For larger files which span across multiple non-contiguous blocks, extent tree's root points to extent
blocks, which in-turn point to the blocks used by the file
+-----------------------+
| Extent Tree |
| +-------------------+ |
| | Extent Root | |
| +-------------------+ |
| | - Pointers to | |
| | Extent Blocks | |
| +-------------------+ |
+-----------------------+
|
v
+-----------------------+
| Extent Block |
+-----------------------+
| +-------------------+ |
| | Extent Leaf Node | |
| +-------------------+ |
| | - Start Block | |
| | - Block Count | |
| | - ... | |
| +-------------------+ |
| +-------------------+ |
| | Extent Leaf Node | |
| +-------------------+ |
| | - Start Block | |
| | - Block Count | |
| | - ... | |
| +-------------------+ |
+-----------------------+
## Directory entries
The data blocks for directory inodes point to a list of directory entries. Each entry
consists of only a name and inode number. The name and inode number correspond to the
name and inode number of the children of the directory
+-------------------------+
| Directory Entry |
+-------------------------+
| inode | rec_len | name |
+-------------------------+
| 2 | 1 | "." |
+-------------------------+
| Directory Entry |
+-------------------------+
| inode | rec_len | name |
+-------------------------+
| 1 | 2 | ".." |
+-------------------------+
| Directory Entry |
+-------------------------+
| inode | rec_len | name |
+-------------------------+
| 11 | 10 | lost& |
| | | found |
+-------------------------+
More details can be found here https://ext4.wiki.kernel.org/index.php/Ext4_Disk_Layout
```
*/
/// A type for interacting with ext4 file systems.
///
/// The `Ext4` class provides functionality to read the superblock of an existing ext4 block device
/// and format a new block device with the ext4 file system.
///
/// Usage:
/// - To read the superblock of an existing ext4 block device, create an instance of `Ext4` with the
/// path to the block device
/// - To format a new block device with ext4, create an instance of `Ext4.Formatter` with the path to the block
/// device and call the `close()` method.
///
/// Example 1: Read an existing block device
/// ```swift
/// let blockDevice = URL(filePath: "/dev/sdb")
/// // succeeds if a valid ext4 fs is found at path
/// let ext4 = try Ext4(blockDevice: blockDevice)
/// print("Block size: \(ext4.blockSize)")
/// print("Total size: \(ext4.size)")
///
/// // Reading the superblock
/// let superblock = ext4.superblock
/// print("Superblock information:")
/// print("Total blocks: \(superblock.blocksCountLow)")
/// ```
///
/// Example 2: Format a new block device (Refer [`EXT4.Formatter`](x-source-tag://EXT4.Formatter) for more info)
/// ```swift
/// let devicePath = URL(filePath: "/dev/sdc")
/// let formatter = try EXT4.Formatter(devicePath, blockSize: 4096)
/// try formatter.close()
/// ```
public enum EXT4 {
public static let SuperBlockMagic: UInt16 = 0xef53
static let ExtentHeaderMagic: UInt16 = 0xf30a
static let XAttrHeaderMagic: UInt32 = 0xea02_0000
static let DefectiveBlockInode: InodeNumber = 1
static let RootInode: InodeNumber = 2
static let FirstInode: InodeNumber = 11
static let LostAndFoundInode: InodeNumber = 11
static let InodeActualSize: UInt32 = 160 // 160 bytes used by metadata
static let InodeExtraSize: UInt32 = 96 // 96 bytes for inline xattrs
static let InodeSize: UInt32 = UInt32(MemoryLayout<Inode>.size) // 256 bytes. This is the max size of an inode
static let XattrInodeHeaderSize: UInt32 = 4
static let XattrBlockHeaderSize: UInt32 = 32
static let ExtraIsize: UInt16 = UInt16(InodeActualSize) - 128
static let MaxLinks: UInt32 = 65000
static let MaxBlocksPerExtent: UInt32 = 0x8000
static let MaxFileSize: UInt64 = 128.gib()
static let SuperBlockOffset: UInt64 = 1024
public struct JournalConfig: Sendable {
public var size: UInt64?
public var defaultMode: JournalMode?
public enum JournalMode: Sendable {
case writeback
case ordered
case journal
}
public init(size: UInt64? = nil, defaultMode: JournalMode? = nil) {
self.size = size
self.defaultMode = defaultMode
}
public static let `default` = JournalConfig()
}
}
extension EXT4 {
// `EXT4` errors.
public enum Error: Swift.Error, CustomStringConvertible, Sendable, Equatable {
case notFound(_ path: String)
case couldNotReadSuperBlock(_ path: String, _ offset: UInt64, _ size: Int)
case invalidSuperBlock
case deepExtentsUnimplemented
case invalidExtents
case invalidXattrEntry
case couldNotReadBlock(_ block: UInt32)
case invalidPathEncoding(_ path: String)
case couldNotReadInode(_ inode: UInt32)
case couldNotReadGroup(_ group: UInt32)
public var description: String {
switch self {
case .notFound(let path):
return "file at path \(path) not found"
case .couldNotReadSuperBlock(let path, let offset, let size):
return "could not read \(size) bytes of superblock from \(path) at offset \(offset)"
case .invalidSuperBlock:
return "not a valid EXT4 superblock"
case .deepExtentsUnimplemented:
return "deep extents are not supported"
case .invalidExtents:
return "extents invalid or corrupted"
case .invalidXattrEntry:
return "invalid extended attribute entry"
case .couldNotReadBlock(let block):
return "could not read block \(block)"
case .invalidPathEncoding(let path):
return "path encoding for '\(path)' is invalid, must be ascii or utf8"
case .couldNotReadInode(let inode):
return "could not read inode \(inode)"
case .couldNotReadGroup(let group):
return "could not read group descriptor \(group)"
}
}
}
}
@@ -0,0 +1,215 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
import SystemPackage
extension EXT4.EXT4Reader {
public func export(archive: FilePath) throws {
let config = ArchiveWriterConfiguration(
format: .paxRestricted, filter: .none, options: [Options.xattrformat(.schily)])
let writer = try ArchiveWriter(configuration: config)
try writer.open(file: archive.url)
var items = self.tree.root.pointee.children
let hardlinkedInodes = Set(self.hardlinks.values)
var hardlinkTargets: [EXT4.InodeNumber: FilePath] = [:]
while items.count > 0 {
let itemPtr = items.removeFirst()
let item = itemPtr.pointee
let inode = try self.getInode(number: item.inode)
let entry = WriteEntry()
let mode = inode.mode
let size: UInt64 = (UInt64(inode.sizeHigh) << 32) | UInt64(inode.sizeLow)
entry.permissions = mode_t(mode)
guard let path = item.path else {
continue
}
if hardlinkedInodes.contains(item.inode) {
hardlinkTargets[item.inode] = path
}
guard self.hardlinks[path] == nil else {
continue
}
var attributes: [EXT4.ExtendedAttribute] = []
let buffer: [UInt8] = EXT4.tupleToArray(inode.inlineXattrs)
if !buffer.allZeros {
try attributes.append(contentsOf: Self.readInlineExtendedAttributes(from: buffer))
}
if inode.xattrBlockLow != 0 {
let block = inode.xattrBlockLow
try self.seek(block: block)
guard let buffer = try self.handle.read(upToCount: Int(self.blockSize)) else {
throw EXT4.Error.couldNotReadBlock(block)
}
try attributes.append(contentsOf: Self.readBlockExtendedAttributes(from: [UInt8](buffer)))
}
var xattrs: [String: Data] = [:]
for attribute in attributes {
guard attribute.fullName != "system.data" else {
continue
}
xattrs[attribute.fullName] = Data(attribute.value)
}
let pathStr = path.description
entry.path = pathStr
entry.size = Int64(size)
entry.group = gid_t(inode.gidHigh) << 16 | gid_t(inode.gid)
entry.owner = uid_t(inode.uidHigh) << 16 | uid_t(inode.uid)
entry.creationDate = Date(fsTimestamp: UInt64(inode.crtimeExtra) << 32 | UInt64(inode.crtime))
entry.modificationDate = Date(fsTimestamp: UInt64(inode.mtimeExtra) << 32 | UInt64(inode.mtime))
entry.contentAccessDate = Date(fsTimestamp: UInt64(inode.atimeExtra) << 32 | UInt64(inode.atime))
entry.xattrs = xattrs
if mode.isDir() {
entry.fileType = .directory
for child in item.children {
items.append(child)
}
if pathStr == "" {
continue
}
try writer.writeEntry(entry: entry, data: nil)
} else if mode.isReg() {
entry.fileType = .regular
var data = Data()
var remaining: UInt64 = size
if let block = item.blocks {
for dataBlock in block.start..<block.end {
try self.seek(block: dataBlock)
var count: UInt64
if remaining > self.blockSize {
count = self.blockSize
} else {
count = remaining
}
guard let dataBytes = try self.handle.read(upToCount: Int(count)) else {
throw EXT4.Error.couldNotReadBlock(dataBlock)
}
data.append(dataBytes)
remaining -= UInt64(dataBytes.count)
}
}
if let additionalBlocks = item.additionalBlocks {
for block in additionalBlocks {
for dataBlock in block.start..<block.end {
try self.seek(block: dataBlock)
var count: UInt64
if remaining > self.blockSize {
count = self.blockSize
} else {
count = remaining
}
guard let dataBytes = try self.handle.read(upToCount: Int(count)) else {
throw EXT4.Error.couldNotReadBlock(dataBlock)
}
data.append(dataBytes)
remaining -= UInt64(dataBytes.count)
}
}
}
try writer.writeEntry(entry: entry, data: data)
} else if mode.isLink() {
entry.fileType = .symbolicLink
if size < 60 {
let linkBytes = EXT4.tupleToArray(inode.block)
entry.symlinkTarget = String(bytes: linkBytes.prefix(Int(size)), encoding: .utf8) ?? ""
} else {
if let block = item.blocks {
try self.seek(block: block.start)
guard let linkBytes = try self.handle.read(upToCount: Int(size)) else {
throw EXT4.Error.couldNotReadBlock(block.start)
}
entry.symlinkTarget = String(bytes: linkBytes, encoding: .utf8) ?? ""
}
}
try writer.writeEntry(entry: entry, data: nil)
} else { // do not process sockets, fifo, character and block devices
continue
}
}
for (path, number) in self.hardlinks {
guard let targetPath = hardlinkTargets[number] else {
continue
}
let inode = try self.getInode(number: number)
let entry = WriteEntry()
entry.path = path.description
entry.hardlink = targetPath.description
entry.permissions = mode_t(inode.mode)
entry.group = gid_t(inode.gidHigh) << 16 | gid_t(inode.gid)
entry.owner = uid_t(inode.uidHigh) << 16 | uid_t(inode.uid)
entry.creationDate = Date(fsTimestamp: UInt64(inode.crtimeExtra) << 32 | UInt64(inode.crtime))
entry.modificationDate = Date(fsTimestamp: UInt64(inode.mtimeExtra) << 32 | UInt64(inode.mtime))
entry.contentAccessDate = Date(fsTimestamp: UInt64(inode.atimeExtra) << 32 | UInt64(inode.atime))
try writer.writeEntry(entry: entry, data: nil)
}
try writer.finishEncoding()
}
@available(*, deprecated, renamed: "readInlineExtendedAttributes(from:)")
public static func readInlineExtenedAttributes(from buffer: [UInt8]) throws -> [EXT4.ExtendedAttribute] {
try readInlineExtendedAttributes(from: buffer)
}
public static func readInlineExtendedAttributes(from buffer: [UInt8]) throws -> [EXT4.ExtendedAttribute] {
let header = buffer[0..<4].withUnsafeBytes { $0.loadLittleEndian(as: UInt32.self) }
if header != EXT4.XAttrHeaderMagic {
throw EXT4.FileXattrsState.Error.missingXAttrHeader
}
return try EXT4.FileXattrsState.read(buffer: buffer, start: 4, offset: 4)
}
@available(*, deprecated, renamed: "readBlockExtendedAttributes(from:)")
public static func readBlockExtenedAttributes(from buffer: [UInt8]) throws -> [EXT4.ExtendedAttribute] {
try readBlockExtendedAttributes(from: buffer)
}
public static func readBlockExtendedAttributes(from buffer: [UInt8]) throws -> [EXT4.ExtendedAttribute] {
let header = buffer[0..<4].withUnsafeBytes { $0.loadLittleEndian(as: UInt32.self) }
if header != EXT4.XAttrHeaderMagic {
throw EXT4.FileXattrsState.Error.missingXAttrHeader
}
return try EXT4.FileXattrsState.read(buffer: [UInt8](buffer), start: 32, offset: 0)
}
func seek(block: UInt32) throws {
try self.handle.seek(toOffset: UInt64(block) * blockSize)
}
}
extension Date {
init(fsTimestamp: UInt64) {
if fsTimestamp == 0 {
self = Date.distantPast
return
}
// 32 bits - base: seconds since January 1, 1970, signed (negative for pre-1970 dates)
// 2 bits - epoch: overflow counter (0-3), how many times the 32-bit seconds field has wrapped
// 30 bits - nanoseconds (0-999,999,999)
let base = Int32(truncatingIfNeeded: fsTimestamp)
let epoch = Int64(fsTimestamp & 0x3_0000_0000)
let seconds = Int64(base) + epoch
let nanoseconds = Double(fsTimestamp >> 34) / 1_000_000_000
self = Date(timeIntervalSince1970: Double(seconds) + nanoseconds)
}
}
@@ -0,0 +1,495 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
import SystemPackage
extension EXT4 {
public enum PathIOError: Swift.Error, CustomStringConvertible {
case notFound(String)
case notAFile(String)
case isDirectory(String)
case notADirectory(String)
case symlinkLoop(String)
case invalidPath(String)
public var description: String {
switch self {
case .notFound(let p): return "no such file or directory: \(p)"
case .notAFile(let p): return "not a regular file: \(p)"
case .isDirectory(let p): return "is a directory: \(p)"
case .notADirectory(let p): return "not a directory: \(p)"
case .symlinkLoop(let p): return "symlink loop while resolving: \(p)"
case .invalidPath(let p): return "invalid path: \(p)"
}
}
}
}
// MARK: - Public API
extension EXT4.EXT4Reader {
/// Return true if a path exists (file or directory) in this ext4 device.
public func exists(_ path: FilePath, followSymlinks: Bool = true) -> Bool {
(try? resolvePath(path, followSymlinks: followSymlinks).inode) != nil
}
/// Get the total number of blocks in the filesystem
private var totalBlocks: UInt64 {
let lo = UInt64(_superBlock.blocksCountLow)
let hi = UInt64(_superBlock.blocksCountHigh)
return lo | (hi << 32)
}
/// Validate that a physical block address is within device bounds
private func validateBlockAddress(_ block: UInt32) throws {
guard UInt64(block) < totalBlocks else {
throw EXT4.PathIOError.invalidPath("block address \(block) exceeds device bounds (\(totalBlocks) blocks)")
}
}
/// Metadata (inode + inode number) for a path.
public func stat(_ path: FilePath, followSymlinks: Bool = true) throws -> (inodeNumber: EXT4.InodeNumber, inode: EXT4.Inode) {
let resolved = try resolvePath(path, followSymlinks: followSymlinks)
return (resolved.inodeNum, try getInode(number: resolved.inodeNum))
}
/// List a directory's entries (names only). Does not include "." or "..".
public func listDirectory(_ path: FilePath) throws -> [String] {
let (inoNum, ino) = try stat(path)
guard ino.mode.isDir() else {
throw EXT4.PathIOError.notADirectory(path.description)
}
let children = try children(of: inoNum)
return
children
.map { $0.0 }
.filter { $0 != "." && $0 != ".." }
.sorted()
}
/// Read bytes from a regular file at `path` into `buffer`, starting at `offset`.
/// Returns the number of bytes written to `buffer` (may be less than `buffer.count` at EOF).
/// - Note: Semantics mirror `read(2)`: partial reads are possible.
@discardableResult
public func readFile(
at path: FilePath,
into buffer: UnsafeMutableRawBufferPointer,
offset: UInt64 = 0,
followSymlinks: Bool = true
) throws -> Int {
let context = try prepareRead(path: path, offset: offset, followSymlinks: followSymlinks)
if buffer.count == 0 || context.maxReadable == 0 {
return 0
}
let want = min(UInt64(buffer.count), context.maxReadable)
return try performRead(
inodeNumber: context.inodeNumber,
start: context.start,
wantedBytes: want,
into: buffer
)
}
/// Read bytes from a regular file at `path` starting at `offset`.
/// If `count` is nil, reads to EOF. Returns exactly the requested bytes (or less at EOF).
public func readFile(
at path: FilePath,
offset: UInt64 = 0,
count: Int? = nil,
followSymlinks: Bool = true
) throws -> Data {
let context = try prepareRead(path: path, offset: offset, followSymlinks: followSymlinks)
let want = count.map { min(UInt64($0), context.maxReadable) } ?? context.maxReadable
if want == 0 {
return Data()
}
var out = Data(count: Int(want))
let wrote = try out.withUnsafeMutableBytes {
try performRead(
inodeNumber: context.inodeNumber,
start: context.start,
wantedBytes: want,
into: $0
)
}
if wrote < Int(want) {
out.removeSubrange(wrote..<Int(want))
}
return out
}
private func prepareRead(
path: FilePath,
offset: UInt64,
followSymlinks: Bool
) throws -> (inodeNumber: EXT4.InodeNumber, start: UInt64, maxReadable: UInt64) {
let (inoNum, inode) = try stat(path, followSymlinks: followSymlinks)
if inode.mode.isDir() {
throw EXT4.PathIOError.isDirectory(path.description)
}
if !inode.mode.isReg() {
throw EXT4.PathIOError.notAFile(path.description)
}
let fileSize: UInt64 = inodeFileSize(inode)
let start = min(offset, fileSize)
let maxReadable = fileSize - start
return (inodeNumber: inoNum, start: start, maxReadable: maxReadable)
}
private func performRead(
inodeNumber: EXT4.InodeNumber,
start: UInt64,
wantedBytes: UInt64,
into buffer: UnsafeMutableRawBufferPointer
) throws -> Int {
if wantedBytes == 0 {
return 0
}
guard let extents = try self.getExtents(inode: inodeNumber), !extents.isEmpty else {
return 0
}
for (physStartBlk, physEndBlk) in extents {
try validateBlockAddress(physStartBlk)
if physEndBlk > physStartBlk {
try validateBlockAddress(physEndBlk - 1)
}
}
guard let base = buffer.baseAddress else {
return 0
}
let desiredBytes = Int(min(wantedBytes, UInt64(buffer.count)))
if desiredBytes == 0 {
return 0
}
let blockSizeBytes = self.blockSize
let reqStart = start
let reqEnd = start + UInt64(desiredBytes)
var logicalOffset: UInt64 = 0
var bytesWritten = 0
for (physStartBlk, physEndBlk) in extents {
let extentBytes = UInt64(physEndBlk - physStartBlk) * blockSizeBytes
let logicalEnd = logicalOffset + extentBytes
if logicalEnd <= reqStart {
logicalOffset = logicalEnd
continue
}
if logicalOffset >= reqEnd {
break
}
let overlapStart = max(logicalOffset, reqStart)
let overlapEnd = min(logicalEnd, reqEnd)
var remaining = overlapEnd - overlapStart
if remaining == 0 {
logicalOffset = logicalEnd
continue
}
let offsetIntoExtent = overlapStart - logicalOffset
let absoluteByteOffset = (UInt64(physStartBlk) * blockSizeBytes) + offsetIntoExtent
do {
try self.handle.seek(toOffset: absoluteByteOffset)
} catch {
if bytesWritten > 0 {
return bytesWritten
}
throw EXT4.PathIOError.invalidPath("failed to seek to offset \(absoluteByteOffset): \(error)")
}
while remaining > 0 && bytesWritten < desiredBytes {
let chunk = min(desiredBytes - bytesWritten, Int(min(remaining, UInt64(1 << 20))))
let dest = UnsafeMutableRawBufferPointer(
start: base.advanced(by: bytesWritten),
count: chunk
)
do {
guard let data = try self.handle.read(upToCount: chunk) else {
return bytesWritten
}
if data.count == 0 {
return bytesWritten
}
// Copy the data to the destination buffer
data.withUnsafeBytes { sourceBytes in
dest.copyMemory(from: UnsafeRawBufferPointer(sourceBytes))
}
bytesWritten += data.count
remaining -= UInt64(data.count)
if data.count < chunk && remaining > 0 {
return bytesWritten
}
} catch {
if bytesWritten > 0 {
return bytesWritten
}
throw error
}
}
logicalOffset = logicalEnd
if bytesWritten >= desiredBytes {
break
}
}
return bytesWritten
}
// MARK: - Internals inside EXT4Reader
public struct ResolvedPath {
let inodeNum: EXT4.InodeNumber
let inode: EXT4.Inode
}
/// Resolve a path to an inode (optionally following symlinks).
/// Paths may be absolute ("/...") or relative (from "/").
public func resolvePath(_ path: FilePath, followSymlinks: Bool, maxSymlinks: Int = 40) throws -> ResolvedPath {
var components: [String] = normalize(path: path)
var current: EXT4.InodeNumber = EXT4.RootInode
var parentStack: [EXT4.InodeNumber] = [] // Track parent chain for proper ".." handling
var symlinkHops = 0
// Process components one at a time to handle symlinks in the middle of paths
var componentIndex = 0
while componentIndex < components.count {
let name = components[componentIndex]
if name == "." {
componentIndex += 1
continue
}
if name == ".." {
// Handle parent directory traversal
if current == EXT4.RootInode {
// At root, ".." points to itself
componentIndex += 1
continue
}
// Use parent stack if available
if !parentStack.isEmpty {
current = parentStack.removeLast()
} else {
// Fallback: look up ".." entry in filesystem
let entries = try children(of: current)
if let parent = entries.first(where: { $0.0 == ".." })?.1 {
current = parent
}
}
componentIndex += 1
continue
}
// Regular component: verify current is a directory and look up child
let currentInode = try getInode(number: current)
guard currentInode.mode.isDir() else {
throw EXT4.PathIOError.notADirectory(name)
}
let entries = try children(of: current)
guard let child = entries.first(where: { $0.0 == name }) else {
throw EXT4.PathIOError.notFound(name)
}
// Check if child is a symlink
let childInode = try getInode(number: child.1)
if childInode.mode.isLink() && followSymlinks {
// Enforce max symlink depth
symlinkHops += 1
if symlinkHops > maxSymlinks {
throw EXT4.PathIOError.symlinkLoop(FilePath(components.joined(separator: "/")).description)
}
// Read symlink target
let linkBytes = try readFileFromInode(inodeNum: child.1)
guard let linkTarget = String(data: linkBytes, encoding: .utf8), !linkTarget.isEmpty else {
throw EXT4.PathIOError.invalidPath("empty symlink target")
}
// Parse symlink target into components
let targetComponents = normalize(path: FilePath(linkTarget))
// Replace current component with symlink target components and continue
if linkTarget.hasPrefix("/") {
// Absolute symlink: reset to root
current = EXT4.RootInode
parentStack = []
// Replace the symlink component with target components + remaining path
components = targetComponents + Array(components[(componentIndex + 1)...])
componentIndex = 0 // Start from beginning with new path
} else {
// Relative symlink: continue from current directory
// Replace the symlink component with target components + remaining path
components = Array(components[0..<componentIndex]) + targetComponents + Array(components[(componentIndex + 1)...])
// Don't change componentIndex - continue from same position with expanded path
}
} else {
// Not a symlink or not following symlinks - descend into directory
parentStack.append(current)
current = child.1
componentIndex += 1
}
}
// All components processed - return final inode
let finalInode = try getInode(number: current)
return ResolvedPath(inodeNum: current, inode: finalInode)
}
/// Normalize a path into components, handling absolute and relative paths.
private func normalize(path: FilePath) -> [String] {
let s = path.description
let trimmed = s.hasPrefix("/") ? String(s.dropFirst()) : s
if trimmed.isEmpty { return [] }
return trimmed.split(separator: "/").map(String.init)
}
/// Read entire file content of a regular file given an inode (used for symlink targets).
private func readFileFromInode(inodeNum: EXT4.InodeNumber) throws -> Data {
let ino = try getInode(number: inodeNum)
guard ino.mode.isReg() || ino.mode.isLink() else {
return Data()
}
let size = inodeFileSize(ino)
if size == 0 { return Data() }
// Handle fast symlinks (target stored directly in inode block field)
if ino.mode.isLink() && size < 60 {
// Extract target from inode block field
let blockData = withUnsafeBytes(of: ino.block) { Data($0) }
return blockData.prefix(Int(size))
}
return try readFileBytesFromExtents(inodeNum: inodeNum, offset: 0, count: size)
}
/// Low-level read using extents, with explicit offset & length (in bytes).
private func readFileBytesFromExtents(inodeNum: EXT4.InodeNumber, offset: UInt64, count: UInt64) throws -> Data {
guard let extents = try self.getExtents(inode: inodeNum), !extents.isEmpty else {
return Data()
}
// Validate all extent blocks are within device bounds
for (startBlk, endBlk) in extents {
try validateBlockAddress(startBlk)
if endBlk > startBlk {
try validateBlockAddress(endBlk - 1)
}
}
var out = Data(capacity: Int(count))
var logicalOffset: UInt64 = 0
var bytesReadSuccessfully: Int = 0
let reqStart = offset
let reqEnd = offset + count
let bs = self.blockSize
for (startBlk, endBlk) in extents {
let extentBytes = UInt64(endBlk - startBlk) * bs
let logicalEnd = logicalOffset + extentBytes
if logicalEnd <= reqStart {
logicalOffset = logicalEnd
continue
}
if logicalOffset >= reqEnd { break }
let ovlStart = max(logicalOffset, reqStart)
let ovlEnd = min(logicalEnd, reqEnd)
let ovlLen = ovlEnd - ovlStart
if ovlLen == 0 {
logicalOffset = logicalEnd
continue
}
let offsetIntoExtent = ovlStart - logicalOffset
let absByteOffset = UInt64(startBlk) * bs + offsetIntoExtent
do {
try self.handle.seek(toOffset: absByteOffset)
} catch {
if bytesReadSuccessfully > 0 {
// Return partial data that was successfully read
return out
}
throw EXT4.PathIOError.invalidPath("failed to seek to offset \(absByteOffset): \(error)")
}
var left = ovlLen
while left > 0 {
let chunk = Int(min(left, 1 << 20))
do {
guard let data = try self.handle.read(upToCount: chunk) else {
let blk = UInt32(absByteOffset / bs)
throw EXT4.Error.couldNotReadBlock(blk)
}
out.append(data)
bytesReadSuccessfully += data.count
left -= UInt64(data.count)
if data.count < chunk && left > 0 {
// Partial read - return what we have
return out
}
} catch {
if bytesReadSuccessfully > 0 {
// Return partial data on error
return out
}
throw error
}
}
logicalOffset = logicalEnd
if out.count >= Int(count) { break }
}
if out.count > Int(count) { out.removeSubrange(Int(count)..<out.count) }
return out
}
/// Compute 64-bit file size from the inode fields (i_size).
/// ext4 stores low 32 bits in i_size_lo and the high 32 bits in i_size_high when 64-bit sizes are enabled.
private func inodeFileSize(_ inode: EXT4.Inode) -> UInt64 {
// The Containerization EXT4 Inode struct exposes mode and block fields; size fields
// are commonly named sizeLo/sizeHigh in this codebase.
// EXT4 supports 64-bit file sizes - always use both low and high parts.
let lo = UInt64(inode.sizeLow)
let hi = UInt64(inode.sizeHigh)
return lo | (hi << 32)
}
}
@@ -0,0 +1,113 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
import SystemPackage
extension FilePath {
public static let Separator: String = "/"
public var bytes: [UInt8] {
self.withCString { cstr in
var ptr = cstr
var rawBytes: [UInt8] = []
while UInt(bitPattern: ptr) != 0 {
if ptr.pointee == 0x00 { break }
rawBytes.append(UInt8(bitPattern: ptr.pointee))
ptr = ptr.successor()
}
return rawBytes
}
}
public var base: String {
self.lastComponent?.string ?? "/"
}
public var dir: FilePath {
self.removingLastComponent()
}
public var url: URL {
URL(fileURLWithPath: self.string)
}
public var items: [String] {
self.components.map { $0.string }
}
public var isRoot: Bool { // platform agnostic
self.removingRoot().isEmpty
}
public init(_ url: URL) {
self.init(url.path(percentEncoded: false))
}
public func join(_ path: FilePath) -> FilePath {
self.pushing(path)
}
public func join(_ path: String) -> FilePath {
self.join(FilePath(path))
}
public func split() -> (dir: FilePath, base: String) {
(self.dir, self.base)
}
public func clean() -> FilePath {
self.lexicallyNormalized()
}
public static func rel(_ basepath: String, _ targpath: String) -> FilePath {
let base = FilePath(basepath)
let targ = FilePath(targpath)
if base == targ {
return "."
}
let baseComponents = base.items
let targComponents = targ.items
var commonPrefix = 0
while commonPrefix < min(baseComponents.count, targComponents.count)
&& baseComponents[commonPrefix] == targComponents[commonPrefix]
{
commonPrefix += 1
}
let upCount = baseComponents.count - commonPrefix
let relComponents = Array(repeating: "..", count: upCount) + targComponents[commonPrefix...]
return FilePath(relComponents.joined(separator: Self.Separator))
}
}
extension FileHandle {
public convenience init?(forWritingTo path: FilePath) {
self.init(forWritingAtPath: path.description)
}
public convenience init?(forReadingAtPath path: FilePath) {
self.init(forReadingAtPath: path.description)
}
public convenience init?(forReadingFrom path: FilePath) {
self.init(forReadingAtPath: path.description)
}
}
@@ -0,0 +1,67 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
public struct FileTimestamps {
public var access: Date
public var modification: Date
public var creation: Date
public var now: Date
public var accessLo: UInt32 {
access.fs().lo
}
public var accessHi: UInt32 {
access.fs().hi
}
public var modificationLo: UInt32 {
modification.fs().lo
}
public var modificationHi: UInt32 {
modification.fs().hi
}
public var creationLo: UInt32 {
creation.fs().lo
}
public var creationHi: UInt32 {
creation.fs().hi
}
public var nowLo: UInt32 {
now.fs().lo
}
public var nowHi: UInt32 {
now.fs().hi
}
public init(access: Date?, modification: Date?, creation: Date?) {
now = Date()
self.access = access ?? now
self.modification = modification ?? now
self.creation = creation ?? now
}
public init() {
self.init(access: nil, modification: nil, creation: nil)
}
}
@@ -0,0 +1,255 @@
//===----------------------------------------------------------------------===//
// 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 ContainerizationExtras
import ContainerizationOS
import Foundation
import SystemPackage
private typealias Hardlinks = [FilePath: FilePath]
extension EXT4.Formatter {
/// Unpack the provided archive on to the ext4 filesystem.
public func unpack(reader: ArchiveReader, progress: ProgressHandler? = nil) async throws {
try await self.unpackEntries(reader: reader, progress: progress)
}
/// Unpack an archive at the source URL on to the ext4 filesystem.
public func unpack(
source: URL,
format: ContainerizationArchive.Format = .paxRestricted,
compression: ContainerizationArchive.Filter = .gzip,
progress: ProgressHandler? = nil
) async throws {
// For zstd, decompress once and reuse for both passes to avoid double decompression.
let fileToRead: URL
let readerFilter: ContainerizationArchive.Filter
var decompressedFile: URL?
if progress != nil && compression == .zstd {
let decompressed = try ArchiveReader.decompressZstd(source)
fileToRead = decompressed
readerFilter = .none
decompressedFile = decompressed
} else {
fileToRead = source
readerFilter = compression
}
defer {
if let decompressedFile {
ArchiveReader.cleanUpDecompressedZstd(decompressedFile)
}
}
if let progress {
// First pass: scan headers to get totals (fast, metadata only)
let totals = try Self.scanArchiveHeaders(format: format, filter: readerFilter, file: fileToRead)
var totalEvents: [ProgressEvent] = []
if totals.size > 0 {
totalEvents.append(.addTotalSize(totals.size))
}
if totals.items > 0 {
totalEvents.append(.addTotalItems(totals.items))
}
if !totalEvents.isEmpty {
await progress(totalEvents)
}
}
// Unpack pass
let reader = try ArchiveReader(
format: format,
filter: readerFilter,
file: fileToRead
)
try await self.unpackEntries(reader: reader, progress: progress)
}
/// Scan archive headers to count the total number of bytes in regular files
/// and the total number of entries.
public static func scanArchiveHeaders(
format: ContainerizationArchive.Format,
filter: ContainerizationArchive.Filter,
file: URL
) throws -> (size: Int64, items: Int) {
let reader = try ArchiveReader(format: format, filter: filter, file: file)
var totalSize: Int64 = 0
var totalItems: Int = 0
for (entry, _) in reader.makeStreamingIterator() {
try Task.checkCancellation()
guard entry.path != nil else { continue }
totalItems += 1
if entry.fileType == .regular, entry.hardlink == nil, let size = entry.size {
totalSize += Int64(size)
}
}
return (size: totalSize, items: totalItems)
}
/// Core unpack logic. When `progress` is nil the handler calls are skipped.
private func unpackEntries(reader: ArchiveReader, progress: ProgressHandler?) async throws {
var hardlinks: Hardlinks = [:]
// Allocate a single 128KiB reusable buffer for all files to minimize allocations
// and reduce the number of read calls to libarchive.
let bufferSize = 128 * 1024
let reusableBuffer = UnsafeMutableBufferPointer<UInt8>.allocate(capacity: bufferSize)
defer { reusableBuffer.deallocate() }
for (entry, streamReader) in reader.makeStreamingIterator() {
try Task.checkCancellation()
guard var pathEntry = entry.path else {
continue
}
pathEntry = preProcessPath(s: pathEntry)
let path = FilePath(pathEntry)
if path.base.hasPrefix(".wh.") {
if path.base == ".wh..wh..opq" { // whiteout directory
try self.unlink(path: path.dir, directoryWhiteout: true)
if let progress {
await progress([.addItems(1)])
}
continue
}
let startIndex = path.base.index(path.base.startIndex, offsetBy: ".wh.".count)
let filePath = String(path.base[startIndex...])
let dir: FilePath = path.dir
try self.unlink(path: dir.join(filePath))
if let progress {
await progress([.addItems(1)])
}
continue
}
if let hardlink = entry.hardlink {
let hl = preProcessPath(s: hardlink)
hardlinks[path] = FilePath(hl)
if let progress {
await progress([.addItems(1)])
}
continue
}
let ts = FileTimestamps(
access: entry.contentAccessDate, modification: entry.modificationDate, creation: entry.creationDate)
switch entry.fileType {
case .directory:
try self.create(
path: path, mode: EXT4.Inode.Mode(.S_IFDIR, UInt16(entry.permissions)), ts: ts, uid: entry.owner,
gid: entry.group,
xattrs: entry.xattrs)
case .regular:
try self.create(
path: path, mode: EXT4.Inode.Mode(.S_IFREG, UInt16(entry.permissions)), ts: ts, buf: streamReader,
uid: entry.owner,
gid: entry.group, xattrs: entry.xattrs, fileBuffer: reusableBuffer)
if let progress, let size = entry.size {
await progress([.addSize(Int64(size))])
}
case .symbolicLink:
var symlinkTarget: FilePath?
if let target = entry.symlinkTarget {
symlinkTarget = FilePath(target)
}
try self.create(
path: path, link: symlinkTarget, mode: EXT4.Inode.Mode(.S_IFLNK, UInt16(entry.permissions)), ts: ts,
uid: entry.owner,
gid: entry.group, xattrs: entry.xattrs)
default:
if let progress {
await progress([.addItems(1)])
}
continue
}
if let progress {
await progress([.addItems(1)])
}
}
guard hardlinks.acyclic else {
throw UnpackError.circularLinks
}
for (path, _) in hardlinks {
if let resolvedTarget = try hardlinks.resolve(path) {
try self.link(link: path, target: resolvedTarget)
}
}
}
private func preProcessPath(s: String) -> String {
var p = s
if p.hasPrefix("./") {
p = String(p.dropFirst())
}
if !p.hasPrefix("/") {
p = "/" + p
}
return p
}
}
/// Common errors for unpacking an archive onto an ext4 filesystem.
public enum UnpackError: Swift.Error, CustomStringConvertible, Sendable, Equatable {
/// The name is invalid.
case invalidName(_ name: String)
/// A circular link is found.
case circularLinks
/// The description of the error.
public var description: String {
switch self {
case .invalidName(let name):
return "'\(name)' is an invalid name"
case .circularLinks:
return "circular links found"
}
}
}
extension Hardlinks {
fileprivate var acyclic: Bool {
for (_, target) in self {
var visited: Set<FilePath> = [target]
var next = target
while let item = self[next] {
if visited.contains(item) {
return false
}
next = item
visited.insert(next)
}
}
return true
}
fileprivate func resolve(_ key: FilePath) throws -> FilePath? {
let target = self[key]
guard let target else {
return nil
}
var next = target
var visited: Set<FilePath> = [next]
while let item = self[next] {
if visited.contains(item) {
throw UnpackError.circularLinks
}
next = item
visited.insert(next)
}
return next
}
}
@@ -0,0 +1,131 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
extension UInt64 {
public var lo: UInt32 {
UInt32(self & 0xffff_ffff)
}
public var hi: UInt32 {
UInt32(self >> 32)
}
public static func - (lhs: Self, rhs: UInt32) -> UInt64 {
lhs - UInt64(rhs)
}
public static func % (lhs: Self, rhs: UInt32) -> UInt64 {
lhs % UInt64(rhs)
}
public static func / (lhs: Self, rhs: UInt32) -> UInt32 {
UInt32(lhs / UInt64(rhs))
}
public static func * (lhs: Self, rhs: UInt32) -> UInt64 {
lhs * UInt64(rhs)
}
public static func * (lhs: Self, rhs: Int) -> UInt64 {
lhs * UInt64(rhs)
}
}
extension UInt32 {
public var lo: UInt16 {
UInt16(self & 0xffff)
}
public var hi: UInt16 {
UInt16(self >> 16)
}
public static func + (lhs: Self, rhs: Int.IntegerLiteralType) -> UInt32 {
lhs + UInt32(rhs)
}
public static func - (lhs: Self, rhs: Int.IntegerLiteralType) -> UInt32 {
lhs - UInt32(rhs)
}
public static func / (lhs: Self, rhs: Int.IntegerLiteralType) -> UInt32 {
lhs / UInt32(rhs)
}
public static func - (lhs: Self, rhs: UInt16) -> UInt32 {
lhs - UInt32(rhs)
}
public static func * (lhs: Self, rhs: Int.IntegerLiteralType) -> Int {
Int(lhs) * rhs
}
}
extension Int {
public static func + (lhs: Self, rhs: UInt32) -> Int {
lhs + Int(rhs)
}
public static func + (lhs: Self, rhs: UInt32) -> UInt32 {
UInt32(lhs) + rhs
}
}
extension UInt16 {
func isDir() -> Bool {
self & EXT4.FileModeFlag.TypeMask.rawValue == EXT4.FileModeFlag.S_IFDIR.rawValue
}
func isLink() -> Bool {
self & EXT4.FileModeFlag.TypeMask.rawValue == EXT4.FileModeFlag.S_IFLNK.rawValue
}
func isReg() -> Bool {
self & EXT4.FileModeFlag.TypeMask.rawValue == EXT4.FileModeFlag.S_IFREG.rawValue
}
func fileType() -> UInt8 {
typealias FMode = EXT4.FileModeFlag
typealias FileType = EXT4.FileType
switch self & FMode.TypeMask.rawValue {
case FMode.S_IFREG.rawValue:
return FileType.regular.rawValue
case FMode.S_IFDIR.rawValue:
return FileType.directory.rawValue
case FMode.S_IFCHR.rawValue:
return FileType.character.rawValue
case FMode.S_IFBLK.rawValue:
return FileType.block.rawValue
case FMode.S_IFIFO.rawValue:
return FileType.fifo.rawValue
case FMode.S_IFSOCK.rawValue:
return FileType.socket.rawValue
case FMode.S_IFLNK.rawValue:
return FileType.symbolicLink.rawValue
default:
return FileType.unknown.rawValue
}
}
}
extension [UInt8] {
var allZeros: Bool {
for num in self where num != 0 {
return false
}
return true
}
}
+4
View File
@@ -0,0 +1,4 @@
# ``ContainerizationEXT4``
`ContainerizationEXT4` provides functionality to read the superblock of an existing ext4 block device and format a new block device with
the ext4 file system.
@@ -0,0 +1,78 @@
//===----------------------------------------------------------------------===//
// 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 CoreFoundation
// takes a pointer and converts its contents to native endian bytes
public func withUnsafeLittleEndianBytes<T, Result>(of value: T, body: (UnsafeRawBufferPointer) throws -> Result)
rethrows -> Result
{
switch Endian {
case .little:
return try withUnsafeBytes(of: value) { bytes in
try body(bytes)
}
case .big:
return try withUnsafeBytes(of: value) { buffer in
let reversedBuffer = Array(buffer.reversed())
return try reversedBuffer.withUnsafeBytes { buf in
try body(buf)
}
}
}
}
public func withUnsafeLittleEndianBuffer<T>(
of value: UnsafeRawBufferPointer, body: (UnsafeRawBufferPointer) throws -> T
) rethrows -> T {
switch Endian {
case .little:
return try body(value)
case .big:
let reversed = Array(value.reversed())
return try reversed.withUnsafeBytes { buf in
try body(buf)
}
}
}
extension UnsafeRawBufferPointer {
// loads littleEndian raw data, converts it native endian format and calls UnsafeRawBufferPointer.load
public func loadLittleEndian<T>(as type: T.Type) -> T {
switch Endian {
case .little:
return self.load(as: T.self)
case .big:
let buffer = Array(self.reversed())
return buffer.withUnsafeBytes { ptr in
ptr.load(as: T.self)
}
}
}
}
public enum Endianness {
case little
case big
}
// returns current endianness
public var Endian: Endianness {
var value: UInt32 = 0x0102_0304
return withUnsafeBytes(of: &value) { buffer in
buffer.first == 0x04 ? .little : .big
}
}