mirror of
https://github.com/apple/container.git
synced 2026-10-15 02:29:21 +00:00
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:
1 parent
325d33a881
commit
f1ee6f8b73
7 files changed
+679
-23
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user