Adds journal modes to EXT4.Formatter. (#689)

- Closes #671.
- Adds optional journal parameter to `EXT4.Formatter.init()`, with nil
default specifying the current no-journal filesystem configuration.
Otherwise the parameter value contains the journal size and mode.
- `minDiskSize` parameter for formatter init specifies the minimum
usable capacity of the resulting filesystem. The resulting disk image
grows past this value to accommodate the filesystem on-disk structures,
including the journal if specified.
This commit is contained in:
J Logan authored and GitHub committed 2026-04-21 12:25:09 -07:00
1 parent 325d33a881
commit f1ee6f8b73
7 files changed
+679 -23

No files matched your search

+112 -23
View File
@@ -42,7 +42,8 @@ extension EXT4 {
blockSize / groupDescriptorSize
}
private var blockCount: UInt32 {
// internally accessed by journal setup
var blockCount: UInt32 {
((size - 1) / blockSize) + 1
}
@@ -64,7 +65,9 @@ extension EXT4 {
/// - devicePath: The path to the block device where the ext4 filesystem will be created.
/// - blockSize: The filesystem block size in bytes. Must be a power of two in the set
/// {1024, 2048, 4096}. Defaults to 4096.
/// - minDiskSize: The minimum disk size required for the formatted filesystem.
/// - minDiskSize: The minimum usable capacity for the filesystem. When a journal is
/// configured, the actual image size will be larger than this value by the journal size.
/// - journal: The JBD2 journal size and mode, or nil for an unjournalled filesystem.
///
/// - Note: This ext4 formatter is designed for creating block devices out of container images and does not support all the
/// features and options available in the full ext4 filesystem implementation. It focuses
@@ -72,7 +75,7 @@ extension EXT4 {
///
/// - Important: Ensure that the destination block device is accessible and has sufficient permissions
/// for formatting. The formatting process will erase all existing data on the device.
public init(_ devicePath: FilePath, blockSize: UInt32 = 4096, minDiskSize: UInt64 = 256.kib()) throws {
public init(_ devicePath: FilePath, blockSize: UInt32 = 4096, minDiskSize: UInt64 = 256.kib(), journal: JournalConfig? = nil) throws {
/// The constructor performs the following steps:
///
/// 1. Creates the first 10 inodes:
@@ -93,6 +96,9 @@ extension EXT4 {
guard blockSize >= 1024 && blockSize <= 4096 && blockSize.nonzeroBitCount == 1 else {
throw Error.invalidBlockSize(blockSize)
}
guard minDiskSize / UInt64(blockSize) <= UInt64(UInt32.max) else {
throw Error.cannotResizeFS(minDiskSize)
}
self.logBlockSize = UInt32(blockSize.trailingZeroBitCount) - 10
if !FileManager.default.fileExists(atPath: devicePath.description) {
_ = FileManager.default.createFile(atPath: devicePath.description, contents: nil)
@@ -123,6 +129,7 @@ extension EXT4 {
}
// step #2
self.tree = FileTree(EXT4.RootInode, "/")
self.journalConfig = journal
// skip past the superblock and block descriptor table
try self.seek(block: self.groupDescriptorBlocks + 1)
// lost+found directory is required for e2fsck to pass
@@ -582,6 +589,9 @@ extension EXT4 {
//
// This function is responsible for finalizing the formatting process of an ext4 filesystem
// after the following structures have been written:
// - JBD2 journal (inode 8): Written first when a journal config is provided. Includes the
// JBD2 superblock and zeroed journal data blocks. self.size is expanded by the journal
// size so that minDiskSize represents usable capacity rather than total image size.
// - Inode table: Contains information about each file and directory in the filesystem.
// - Block bitmap: Tracks the allocation status of each block in the filesystem.
// - Inode bitmap: Tracks the allocation status of each inode in the filesystem.
@@ -603,6 +613,26 @@ extension EXT4 {
}
breadthWiseChildTree.append(contentsOf: child.pointee.children.map { (child, $0) })
}
// Generate UUID once; shared by filesystem superblock and JBD2 superblock.
let filesystemUUID = UUID().uuid
// Journal init MUST precede optimizeBlockGroupLayout() and commitInodeTable().
// Reason 1: optimizeBlockGroupLayout reads self.currentBlock — journal blocks
// must already be written to be counted in the layout calculation.
// Reason 2: commitInodeTable writes inode 8 to disk — setupJournalInode must
// have updated self.inodes[7] in memory first.
var journalByteCount: UInt64 = 0
if let config = journalConfig {
// initializeJournal returns the number of blocks written for the journal.
// journalByteCount is fed into the newSize floor below so that minDiskSize
// represents usable capacity: the journal is additive overhead on top of
// the content area. self.size is left unmodified here so the existing
// lseek + write file-extension path handles the physical resize.
let journalBlocks = try initializeJournal(config: config, filesystemUUID: filesystemUUID)
journalByteCount = UInt64(journalBlocks) * self.blockSize
}
let blockGroupSize = optimizeBlockGroupLayout(blocks: self.currentBlock, inodes: UInt32(self.inodes.count))
let inodeTableOffset = try self.commitInodeTable(
blockGroups: blockGroupSize.blockGroups,
@@ -617,17 +647,18 @@ extension EXT4 {
let bitmapBlocks: UInt32 = blockGroupSize.blockGroups * 2 // each group has two bitmaps - for inodes, and for blocks
let dataBlocks: UInt32 = bitmapOffset + bitmapBlocks // last data block
var diskBlocks = dataBlocks
var minimumDiskBlocks = (blockGroupSize.blockGroups - 1) * self.blocksPerGroup + 1
var contentRequiredBlocks = (blockGroupSize.blockGroups - 1) * self.blocksPerGroup + 1
if blockGroupSize.blockGroups == 1 {
minimumDiskBlocks = self.blocksPerGroup // at least 1 block group
contentRequiredBlocks = self.blocksPerGroup // at least 1 block group
}
if diskBlocks < minimumDiskBlocks { // for data + metadata
diskBlocks = minimumDiskBlocks
if diskBlocks < contentRequiredBlocks { // for data + metadata
diskBlocks = contentRequiredBlocks
}
let minimumDiskSize = UInt64(minimumDiskBlocks) * self.blockSize
var newSize = self.size
if newSize < minimumDiskSize {
newSize = minimumDiskSize
let contentRequiredSize = UInt64(contentRequiredBlocks) * self.blockSize
// minDiskSize is usable capacity; the journal is additive on top.
var newSize = self.size + journalByteCount
if newSize < contentRequiredSize {
newSize = contentRequiredSize
}
// number of blocks needed for group descriptors
let groupDescriptorBlockCount: UInt32 = (blockGroupSize.blockGroups - 1) / self.groupsPerDescriptorBlock + 1
@@ -658,7 +689,15 @@ extension EXT4 {
if newSize < totalGroups * blocksPerGroup * blockSize {
newSize = UInt64(totalGroups * blocksPerGroup * blockSize)
}
// Snapshot groupDescriptorBlocks before self.size potentially changes: the bitmap
// loop uses this to identify which GDT slots were physically reserved at init time,
// so it can mark any unused slots as free without accidentally freeing content blocks
// written starting at reservedDescriptorBlocks + 1.
let reservedDescriptorBlocks = self.groupDescriptorBlocks
if self.size < newSize {
guard newSize / UInt64(self.blockSize) <= UInt64(UInt32.max) else {
throw Error.cannotResizeFS(newSize)
}
self.size = newSize
let pos = self.pos
guard lseek(self.handle.fileDescriptor, off_t(self.size - 1), 0) == self.size - 1 else {
@@ -694,8 +733,8 @@ extension EXT4 {
for i in 0...usedGroupDescriptorBlocks {
bitmap[Int(i / 8)] |= 1 << (i % 8)
}
if usedGroupDescriptorBlocks + 1 <= self.groupDescriptorBlocks {
for i in usedGroupDescriptorBlocks + 1...self.groupDescriptorBlocks {
if usedGroupDescriptorBlocks + 1 <= reservedDescriptorBlocks {
for i in usedGroupDescriptorBlocks + 1...reservedDescriptorBlocks {
bitmap[Int(i / 8)] &= ~(1 << (i % 8))
blocks -= 1
}
@@ -860,7 +899,6 @@ extension EXT4 {
superblock.firstInode = EXT4.FirstInode
superblock.lpfInode = EXT4.LostAndFoundInode
superblock.inodeSize = UInt16(EXT4.InodeSize)
superblock.featureCompat = CompatFeature.sparseSuper2 | CompatFeature.extAttr
superblock.featureIncompat =
IncompatFeature.filetype | IncompatFeature.extents | IncompatFeature.flexBg
superblock.featureRoCompat =
@@ -868,31 +906,78 @@ extension EXT4 {
superblock.minExtraIsize = EXT4.ExtraIsize
superblock.wantExtraIsize = EXT4.ExtraIsize
superblock.logGroupsPerFlex = 31
superblock.uuid = UUID().uuid
superblock.uuid = filesystemUUID
var compatFeatures: UInt32 = CompatFeature.sparseSuper2 | CompatFeature.extAttr
if let config = journalConfig {
compatFeatures |= CompatFeature.hasJournal.rawValue
superblock.journalInum = EXT4.JournalInode
superblock.journalUUID = filesystemUUID
superblock.journalBlocks = journalInodeBlockBackup()
superblock.journalBackupType = 1 // s_jnl_backup_type: 1 = s_jnl_blocks[] holds a valid inode backup
if let mode = config.defaultMode {
switch mode {
case .writeback: superblock.defaultMountOpts = DefaultMountOpts.journalWriteback
case .ordered: superblock.defaultMountOpts = DefaultMountOpts.journalOrdered
case .journal: superblock.defaultMountOpts = DefaultMountOpts.journalData
}
}
}
superblock.featureCompat = compatFeatures
// Fields intentionally left at zero:
// s_r_blocks_count_lo: no blocks reserved for root
// s_mtime / s_wtime: never mounted/written; kernel updates on first access
// s_mnt_count / s_max_mnt_count: no forced-fsck-after-N-mounts policy
// s_lastcheck / s_checkinterval: no time-based fsck scheduling
// s_def_resuid / s_def_resgid: reserved blocks owned by uid/gid 0 (root)
// s_block_group_nr: this superblock resides in group 0
// s_volume_name: no volume label
// s_last_mounted: no recorded prior mount path
// s_algorithm_usage_bitmap: obsolete compression field, not used
// s_prealloc_blocks / s_prealloc_dir_blocks: block preallocation not enabled
// s_reserved_gdt_blocks: online resize not supported
// s_journal_dev: journal is internal (inode 8), not on an external device
// s_last_orphan: fresh filesystem, no pending orphan cleanup
// s_hash_seed / s_def_hash_version: kernel initialises htree hash seed at first mount
// s_first_meta_bg: meta block group feature not enabled
// s_mkfs_time: creation timestamp not recorded
// s_raid_stride / s_mmp_interval / s_mmp_block / s_raid_stripe_width: no RAID or MMP
// s_checksum_type / s_checksum_seed: metadata checksums not enabled (no csum feature bit)
// s_snapshot_*: snapshot feature not enabled
// s_error_count / s_first_error_* / s_last_error_*: fresh filesystem, no recorded errors
// s_usr_quota_inum / s_grp_quota_inum / s_prj_quota_inum: quotas not enabled
// s_overhead_clusters: kernel computes dynamically; zero is always safe
// s_backup_bgs: sparse_super2 active but no secondary backup groups requested
// s_encrypt_algos / s_encrypt_pw_salt: encryption not enabled
// s_checksum: superblock checksum not enabled (no metadata_csum feature bit)
try withUnsafeLittleEndianBytes(of: superblock) { bytes in
try self.handle.write(contentsOf: bytes)
}
try self.handle.write(contentsOf: Array<UInt8>.init(repeating: 0, count: 2048))
}
// MARK: Private Methods and Properties
private var handle: FileHandle
private var inodes: [Ptr<Inode>]
// MARK: Private and internal methods and properties
private var tree: FileTree
private var deletedBlocks: [(start: UInt32, end: UInt32)] = []
private var pos: UInt64 {
// internally accessed by journal setup
var handle: FileHandle
var inodes: [Ptr<Inode>]
let journalConfig: JournalConfig?
var pos: UInt64 {
guard let offset = try? self.handle.offset() else {
return 0
}
return offset
}
private var currentBlock: UInt32 {
var currentBlock: UInt32 {
self.pos / self.blockSize
}
private func seek(block: UInt32) throws {
func seek(block: UInt32) throws {
try self.handle.seek(toOffset: UInt64(block) * blockSize)
}
@@ -1021,7 +1106,7 @@ extension EXT4 {
}
}
private func writeExtents(_ inode: Inode, _ blocks: (start: UInt32, end: UInt32)) throws -> Inode {
func writeExtents(_ inode: Inode, _ blocks: (start: UInt32, end: UInt32)) throws -> Inode {
var inode = inode
// rest of code assumes that extents MUST go into a new block
if self.pos % self.blockSize != 0 {
@@ -1149,6 +1234,7 @@ extension EXT4 {
inode.flags = InodeFlag.extents | inode.flags
return inode
}
// writes a single directory entry
private func writeDirEntry(name: String, inode: InodeNumber, left: inout Int, link: InodeNumber? = nil) throws {
guard self.inodes[Int(inode) - 1].pointee.linksCount > 0 else {
@@ -1226,6 +1312,7 @@ extension EXT4 {
case cannotCreateSparseFile(_ path: FilePath)
case cannotResizeFS(_ size: UInt64)
case invalidBlockSize(_ size: UInt32)
case journalTooSmall(_ size: UInt64)
public var description: String {
switch self {
case .notDirectory(let path):
@@ -1257,9 +1344,11 @@ extension EXT4 {
case .cannotCreateSparseFile(let path):
return "cannot create sparse file at \(path)"
case .cannotResizeFS(let size):
return "cannot resize fs to \(size) bytes"
return "cannot set filesystem size to \(size) bytes"
case .invalidBlockSize(let size):
return "invalid block size \(size): must be 1024, 2048, or 4096"
case .journalTooSmall(let size):
return "requested journal size \(size) bytes is too small; minimum is \(EXT4.MinJournalBlocks) blocks (JBD2_MIN_JOURNAL_BLOCKS)"
}
}
}
@@ -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]
)
}
}
@@ -245,6 +245,16 @@ extension EXT4 {
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
+18
View File
@@ -293,6 +293,24 @@ public enum EXT4 {
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 {
+173
View File
@@ -3490,6 +3490,179 @@ extension IntegrationSuite {
}
}
// Validates the on-disk structure of a journaled EXT4 image on the host before
// attempting a container mount. This catches geometry mismatches (superblock block
// count vs. physical file size) and missing journal metadata without requiring
// e2fsck to be present in the container image.
//
// Uses raw values for internal constants because integration tests cannot use
// @testable import: CompatFeature.hasJournal = 0x4, EXT4.JournalInode = 8.
private func verifyJournalFilesystem(
at path: URL,
minDiskSize: UInt64,
expectedMountOpts: UInt32
) throws {
let attrs = try FileManager.default.attributesOfItem(atPath: path.absolutePath())
guard let fileSize = attrs[.size] as? UInt64 else {
throw IntegrationError.assert(msg: "could not read file size for \(path.lastPathComponent)")
}
let reader = try EXT4.EXT4Reader(blockDevice: FilePath(path.absolutePath()))
let sb = reader.superBlock
let blocksCount = UInt64(sb.blocksCountLow) | (UInt64(sb.blocksCountHigh) << 32)
let blockSize = UInt64(sb.blockSize)
guard fileSize == blocksCount * blockSize else {
throw IntegrationError.assert(
msg: "geometry mismatch: fileSize=\(fileSize), blocksCount=\(blocksCount), blockSize=\(blockSize)")
}
guard fileSize > minDiskSize else {
throw IntegrationError.assert(
msg: "journal did not grow the image: fileSize=\(fileSize), minDiskSize=\(minDiskSize)")
}
guard sb.featureCompat & 0x4 != 0 else {
throw IntegrationError.assert(
msg: "COMPAT_HAS_JOURNAL not set in featureCompat (0x\(String(sb.featureCompat, radix: 16)))")
}
guard sb.journalInum == 8 else {
throw IntegrationError.assert(msg: "journalInum=\(sb.journalInum), expected 8")
}
guard sb.defaultMountOpts == expectedMountOpts else {
throw IntegrationError.assert(
msg: "defaultMountOpts=0x\(String(sb.defaultMountOpts, radix: 16)), expected 0x\(String(expectedMountOpts, radix: 16)))")
}
}
func testWritableLayerJournalWriteback() async throws {
let id = "test-writable-layer-journal-writeback"
let bs = try await bootstrap(id)
let writableLayerPath = Self.testDir.appending(component: "\(id)-writable.ext4")
try? FileManager.default.removeItem(at: writableLayerPath)
let filesystem = try EXT4.Formatter(
FilePath(writableLayerPath.absolutePath()),
minDiskSize: 512.mib(),
journal: .init(defaultMode: .writeback)
)
try filesystem.close()
// 0x0060 = data=writeback | barrier
try verifyJournalFilesystem(at: writableLayerPath, minDiskSize: 512.mib(), expectedMountOpts: 0x0060)
let writableLayer = Mount.block(
format: "ext4",
source: writableLayerPath.absolutePath(),
destination: "/",
options: []
)
let buffer = BufferWriter()
let container = try LinuxContainer(id, rootfs: bs.rootfs, writableLayer: writableLayer, vmm: bs.vmm) { config in
config.process.arguments = ["/bin/sh", "-c", "echo 'journal writeback' > /tmp/testfile && cat /tmp/testfile"]
config.process.stdout = buffer
config.bootLog = bs.bootLog
}
try await container.create()
try await container.start()
let status = try await container.wait()
try await container.stop()
guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "process failed with status \(status)")
}
guard let output = String(data: buffer.data, encoding: .utf8) else {
throw IntegrationError.assert(msg: "failed to convert stdout to UTF8")
}
guard output.trimmingCharacters(in: .whitespacesAndNewlines) == "journal writeback" else {
throw IntegrationError.assert(msg: "unexpected output: \(output)")
}
}
func testWritableLayerJournalOrdered() async throws {
let id = "test-writable-layer-journal-ordered"
let bs = try await bootstrap(id)
let writableLayerPath = Self.testDir.appending(component: "\(id)-writable.ext4")
try? FileManager.default.removeItem(at: writableLayerPath)
let filesystem = try EXT4.Formatter(
FilePath(writableLayerPath.absolutePath()),
minDiskSize: 512.mib(),
journal: .init(defaultMode: .ordered)
)
try filesystem.close()
// 0x0040 = data=ordered | barrier
try verifyJournalFilesystem(at: writableLayerPath, minDiskSize: 512.mib(), expectedMountOpts: 0x0040)
let writableLayer = Mount.block(
format: "ext4",
source: writableLayerPath.absolutePath(),
destination: "/",
options: []
)
let buffer = BufferWriter()
let container = try LinuxContainer(id, rootfs: bs.rootfs, writableLayer: writableLayer, vmm: bs.vmm) { config in
config.process.arguments = ["/bin/sh", "-c", "echo 'journal ordered' > /tmp/testfile && cat /tmp/testfile"]
config.process.stdout = buffer
config.bootLog = bs.bootLog
}
try await container.create()
try await container.start()
let status = try await container.wait()
try await container.stop()
guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "process failed with status \(status)")
}
guard let output = String(data: buffer.data, encoding: .utf8) else {
throw IntegrationError.assert(msg: "failed to convert stdout to UTF8")
}
guard output.trimmingCharacters(in: .whitespacesAndNewlines) == "journal ordered" else {
throw IntegrationError.assert(msg: "unexpected output: \(output)")
}
}
func testWritableLayerJournalData() async throws {
let id = "test-writable-layer-journal-data"
let bs = try await bootstrap(id)
let writableLayerPath = Self.testDir.appending(component: "\(id)-writable.ext4")
try? FileManager.default.removeItem(at: writableLayerPath)
let filesystem = try EXT4.Formatter(
FilePath(writableLayerPath.absolutePath()),
minDiskSize: 512.mib(),
journal: .init(defaultMode: .journal)
)
try filesystem.close()
// 0x0020 = data=journal | barrier
try verifyJournalFilesystem(at: writableLayerPath, minDiskSize: 512.mib(), expectedMountOpts: 0x0020)
let writableLayer = Mount.block(
format: "ext4",
source: writableLayerPath.absolutePath(),
destination: "/",
options: []
)
let buffer = BufferWriter()
let container = try LinuxContainer(id, rootfs: bs.rootfs, writableLayer: writableLayer, vmm: bs.vmm) { config in
config.process.arguments = ["/bin/sh", "-c", "echo 'journal data' > /tmp/testfile && cat /tmp/testfile"]
config.process.stdout = buffer
config.bootLog = bs.bootLog
}
try await container.create()
try await container.start()
let status = try await container.wait()
try await container.stop()
guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "process failed with status \(status)")
}
guard let output = String(data: buffer.data, encoding: .utf8) else {
throw IntegrationError.assert(msg: "failed to convert stdout to UTF8")
}
guard output.trimmingCharacters(in: .whitespacesAndNewlines) == "journal data" else {
throw IntegrationError.assert(msg: "unexpected output: \(output)")
}
}
func testWritableLayerPreservesLowerLayer() async throws {
let id = "test-writable-layer-preserves-lower"
+3
View File
@@ -341,6 +341,9 @@ struct IntegrationSuite: AsyncParsableCommand {
Test("container read-only rootfs hosts file", testReadOnlyRootfsHostsFileWritten),
Test("container read-only rootfs DNS", testReadOnlyRootfsDNSConfigured),
Test("container writable layer", testWritableLayer),
Test("container writable layer journal writeback", testWritableLayerJournalWriteback),
Test("container writable layer journal ordered", testWritableLayerJournalOrdered),
Test("container writable layer journal data", testWritableLayerJournalData),
Test("container writable layer preserves lower", testWritableLayerPreservesLowerLayer),
Test("container writable layer reads from lower", testWritableLayerReadsFromLower),
Test("container writable layer with ro lower", testWritableLayerWithReadOnlyLower),
@@ -0,0 +1,166 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import Foundation
import SystemPackage
import Testing
@testable import ContainerizationEXT4
/// Structural validation tests for journaled EXT4 filesystems.
///
/// Each test creates a minimal image with a specific journal mode, then reads the
/// superblock back via `EXT4.EXT4Reader` and asserts the invariants that the Linux
/// kernel checks before mounting:
///
/// - **Geometry**: `fileSize == blocksCount * blockSize`
/// (the exact invariant behind "bad geometry: block count N exceeds size of device")
/// - **Additive sizing**: `fileSize > minDiskSize`
/// (the journal grows the image rather than carving into the usable capacity)
/// - **Feature flag**: `featureCompat` has `hasJournal` set
/// - **Journal inode**: `journalInum == EXT4.JournalInode` (inode 8)
/// - **Mount options**: `defaultMountOpts` matches the requested mode
@Suite(.serialized)
struct JournalFormatTests {
let minDiskSize: UInt64 = 128.mib()
// MARK: - Per-mode tests
@Test func writeback() throws {
let path = tmpPath("ext4-journal-writeback")
defer { cleanup(path) }
let formatter = try EXT4.Formatter(path, minDiskSize: minDiskSize, journal: .init(defaultMode: .writeback))
try writeHello(formatter, content: "writeback")
try formatter.close()
// Writeback mode: only journal metadata is committed to disk before the data.
// defaultMountOpts encodes this as 0x0060 (data=writeback | barrier).
try verifyStructure(at: path, expectedMountOpts: EXT4.DefaultMountOpts.journalWriteback)
}
@Test func ordered() throws {
let path = tmpPath("ext4-journal-ordered")
defer { cleanup(path) }
let formatter = try EXT4.Formatter(path, minDiskSize: minDiskSize, journal: .init(defaultMode: .ordered))
try writeHello(formatter, content: "ordered")
try formatter.close()
// Ordered mode (the Linux default): data blocks are written before the journal commit.
// defaultMountOpts encodes this as 0x0040 (data=ordered | barrier).
try verifyStructure(at: path, expectedMountOpts: EXT4.DefaultMountOpts.journalOrdered)
}
@Test func journalData() throws {
let path = tmpPath("ext4-journal-data")
defer { cleanup(path) }
let formatter = try EXT4.Formatter(path, minDiskSize: minDiskSize, journal: .init(defaultMode: .journal))
try writeHello(formatter, content: "journal")
try formatter.close()
// Data journaling mode: data and metadata are both journaled.
// defaultMountOpts encodes this as 0x0020 (data=journal | barrier).
try verifyStructure(at: path, expectedMountOpts: EXT4.DefaultMountOpts.journalData)
}
// MARK: - Shared structural helper
private func verifyStructure(at path: FilePath, expectedMountOpts: UInt32) throws {
let fileSize = try fileByteCount(at: path)
let reader = try EXT4.EXT4Reader(blockDevice: path)
let sb = reader.superBlock
let blocksCount = UInt64(sb.blocksCountLow) | (UInt64(sb.blocksCountHigh) << 32)
let blockSize = UInt64(sb.blockSize)
// The Linux kernel rejects mounts where the superblock's block count implies
// a larger device than the actual file backing the filesystem.
#expect(fileSize == blocksCount * blockSize, "geometry mismatch: fileSize=\(fileSize), blocksCount=\(blocksCount), blockSize=\(blockSize)")
// The journal is additive overhead; the image must be larger than minDiskSize.
#expect(fileSize > minDiskSize, "journal did not grow the image: fileSize=\(fileSize), minDiskSize=\(minDiskSize)")
// The COMPAT_HAS_JOURNAL flag must be set for the kernel to look for inode 8.
#expect(sb.featureCompat & EXT4.CompatFeature.hasJournal.rawValue != 0, "COMPAT_HAS_JOURNAL not set in featureCompat (0x\(String(sb.featureCompat, radix: 16)))")
// The journal inode number recorded in the superblock must point at inode 8.
#expect(sb.journalInum == EXT4.JournalInode, "journalInum=\(sb.journalInum), expected \(EXT4.JournalInode)")
// The default mount options must reflect the requested journaling mode.
#expect(sb.defaultMountOpts == expectedMountOpts, "defaultMountOpts=0x\(String(sb.defaultMountOpts, radix: 16)), expected 0x\(String(expectedMountOpts, radix: 16))")
}
// MARK: - Filesystem population helpers
private func writeHello(_ formatter: EXT4.Formatter, content: String) throws {
try formatter.create(path: FilePath("/data"), mode: EXT4.Inode.Mode(.S_IFDIR, 0o755))
let stream = InputStream(data: Data(content.utf8))
stream.open()
defer { stream.close() }
try formatter.create(
path: FilePath("/data/hello.txt"),
mode: EXT4.Inode.Mode(.S_IFREG, 0o644),
buf: stream)
}
// MARK: - File helpers
private func tmpPath(_ name: String) -> FilePath {
FilePath(
FileManager.default.temporaryDirectory
.appendingPathComponent("\(name)-\(UUID().uuidString).img", isDirectory: false)
.path
)
}
private func cleanup(_ path: FilePath) {
try? FileManager.default.removeItem(atPath: path.string)
}
private func fileByteCount(at path: FilePath) throws -> UInt64 {
let attrs = try FileManager.default.attributesOfItem(atPath: path.string)
guard let size = attrs[.size] as? UInt64 else {
throw CocoaError(.fileReadUnknown)
}
return size
}
}
/// Tests that the UInt32 block-count overflow guards fire at the right points.
struct JournalOverflowTests {
// (UInt32.max + 1) blocks at the minimum block size triggers the guard in init()
// before any file is created, so no cleanup is needed.
@Test func initRejectsBlockCountOverflowingUInt32() {
let path = FilePath(
FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: false).path)
#expect(throws: EXT4.Formatter.Error.self) {
try EXT4.Formatter(path, blockSize: 1024, minDiskSize: (UInt64(UInt32.max) + 1) * 1024)
}
}
// minDiskSize is exactly at the UInt32 block count limit; adding any journal pushes
// newSize / blockSize above UInt32.max. The guard in close() fires before the bitmap
// loop, so this test completes quickly despite the large sparse file.
@Test func closeRejectsJournalExpansionOverflowingUInt32() throws {
let path = FilePath(
FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: false).path)
defer { try? FileManager.default.removeItem(atPath: path.string) }
let formatter = try EXT4.Formatter(
path, blockSize: 1024, minDiskSize: UInt64(UInt32.max) * 1024,
journal: .init(defaultMode: .writeback))
#expect(throws: EXT4.Formatter.Error.self) {
try formatter.close()
}
}
}