From 39acf634b806fb67beac182e3349579bb9fb2040 Mon Sep 17 00:00:00 2001 From: John Logan Date: Wed, 8 Apr 2026 14:41:01 -0700 Subject: [PATCH 01/11] Initial WIP for ext4 journal mode. --- .../ContainerizationEXT4/EXT4+Formatter.swift | 50 +++++-- .../ContainerizationEXT4/EXT4+Journal.swift | 141 ++++++++++++++++++ Sources/ContainerizationEXT4/EXT4+Types.swift | 9 ++ Sources/ContainerizationEXT4/EXT4.swift | 18 +++ 4 files changed, 209 insertions(+), 9 deletions(-) create mode 100644 Sources/ContainerizationEXT4/EXT4+Journal.swift diff --git a/Sources/ContainerizationEXT4/EXT4+Formatter.swift b/Sources/ContainerizationEXT4/EXT4+Formatter.swift index 49112ae8..1a0f0a28 100644 --- a/Sources/ContainerizationEXT4/EXT4+Formatter.swift +++ b/Sources/ContainerizationEXT4/EXT4+Formatter.swift @@ -42,7 +42,7 @@ extension EXT4 { blockSize / groupDescriptorSize } - private var blockCount: UInt32 { + var blockCount: UInt32 { ((size - 1) / blockSize) + 1 } @@ -72,7 +72,11 @@ 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. +<<<<<<< HEAD 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 { +>>>>>>> c38156b (Initial WIP for ext4 journal mode.) /// The constructor performs the following steps: /// /// 1. Creates the first 10 inodes: @@ -128,6 +132,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 @@ -610,6 +615,19 @@ 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. + if let config = journalConfig { + try initializeJournal(config: config, filesystemUUID: filesystemUUID) + } + let blockGroupSize = optimizeBlockGroupLayout(blocks: self.currentBlock, inodes: UInt32(self.inodes.count)) let inodeTableOffset = try self.commitInodeTable( blockGroups: blockGroupSize.blockGroups, @@ -867,7 +885,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 = @@ -875,7 +892,21 @@ 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 + 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 try withUnsafeLittleEndianBytes(of: superblock) { bytes in try self.handle.write(contentsOf: bytes) } @@ -883,23 +914,24 @@ extension EXT4 { } // MARK: Private Methods and Properties - private var handle: FileHandle - private var inodes: [Ptr] + var handle: FileHandle + var inodes: [Ptr] private var tree: FileTree private var deletedBlocks: [(start: UInt32, end: UInt32)] = [] + let journalConfig: JournalConfig? - private var pos: UInt64 { + 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) } @@ -1028,7 +1060,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 { diff --git a/Sources/ContainerizationEXT4/EXT4+Journal.swift b/Sources/ContainerizationEXT4/EXT4+Journal.swift new file mode 100644 index 00000000..453359ce --- /dev/null +++ b/Sources/ContainerizationEXT4/EXT4+Journal.swift @@ -0,0 +1,141 @@ +//===----------------------------------------------------------------------===// +// 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 + +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 { + let journalBlocks = calculateJournalSize(requestedSize: config.size, totalBlocks: 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) + setupJournalInode(startBlock: journalStartBlock, blockCount: journalBlocks) + } + + // MARK: - Private helpers + + private func calculateJournalSize(requestedSize: UInt64?, totalBlocks: UInt32) -> UInt32 { + if let size = requestedSize { + return UInt32(size / UInt64(self.blockSize)) + } + let fsBytes = UInt64(totalBlocks) * UInt64(self.blockSize) + let rawBytes = fsBytes / 256 + let minBytes: UInt64 = 4.mib() + let maxBytes: UInt64 = 128.mib() + let clampedBytes = min(max(rawBytes, minBytes), maxBytes) + 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 { + var buf = [UInt8](repeating: 0, count: Int(self.blockSize)) + + func writeU32(_ 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 + writeU32(EXT4.JournalMagic, at: 0x00) // h_magic + writeU32(4, at: 0x04) // h_blocktype = superblock v2 + writeU32(1, at: 0x08) // h_sequence + + // JBD2 superblock body + writeU32(self.blockSize, at: 0x0C) // s_blocksize + writeU32(journalBlocks, at: 0x10) // s_maxlen + writeU32(1, at: 0x14) // s_first (first usable block) + writeU32(1, at: 0x18) // s_sequence + + // 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[...] + + writeU32(1, at: 0x40) // s_nr_users + + let maxTrans = min(journalBlocks / 4, 32768) + writeU32(maxTrans, at: 0x48) // s_max_transaction + writeU32(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() + 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.. Date: Wed, 8 Apr 2026 15:09:31 -0700 Subject: [PATCH 02/11] Adds integration tests. --- Sources/Integration/ContainerTests.swift | 126 +++++++++++++++++++++++ Sources/Integration/Suite.swift | 3 + 2 files changed, 129 insertions(+) diff --git a/Sources/Integration/ContainerTests.swift b/Sources/Integration/ContainerTests.swift index 84b86356..748d6816 100644 --- a/Sources/Integration/ContainerTests.swift +++ b/Sources/Integration/ContainerTests.swift @@ -3490,6 +3490,132 @@ extension IntegrationSuite { } } + 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() + 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() + 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() + 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" diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index 85341144..4352d2aa 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -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), From e8a6450c88c207f0b900fc1b1e44540179855877 Mon Sep 17 00:00:00 2001 From: John Logan Date: Wed, 8 Apr 2026 15:42:34 -0700 Subject: [PATCH 03/11] Adds Linux kernel documentation links where relevant. --- Sources/ContainerizationEXT4/EXT4+Journal.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Sources/ContainerizationEXT4/EXT4+Journal.swift b/Sources/ContainerizationEXT4/EXT4+Journal.swift index 453359ce..55d37bad 100644 --- a/Sources/ContainerizationEXT4/EXT4+Journal.swift +++ b/Sources/ContainerizationEXT4/EXT4+Journal.swift @@ -17,6 +17,9 @@ 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( @@ -67,12 +70,12 @@ extension EXT4.Formatter { buf[offset + 3] = UInt8(value & 0xFF) } - // JBD2 block header + // JBD2 block header (§3.6.3): https://www.kernel.org/doc/html/latest/filesystems/ext4/journal.html#block-header writeU32(EXT4.JournalMagic, at: 0x00) // h_magic writeU32(4, at: 0x04) // h_blocktype = superblock v2 writeU32(1, at: 0x08) // h_sequence - // JBD2 superblock body + // JBD2 superblock body (§3.6.4): https://www.kernel.org/doc/html/latest/filesystems/ext4/journal.html#super-block writeU32(self.blockSize, at: 0x0C) // s_blocksize writeU32(journalBlocks, at: 0x10) // s_maxlen writeU32(1, at: 0x14) // s_first (first usable block) From b9e84f4cc7541e2f018371909b2d4f3c24993fe6 Mon Sep 17 00:00:00 2001 From: John Logan Date: Wed, 8 Apr 2026 16:07:58 -0700 Subject: [PATCH 04/11] Point out internal access funcs/properties. --- Sources/ContainerizationEXT4/EXT4+Formatter.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Sources/ContainerizationEXT4/EXT4+Formatter.swift b/Sources/ContainerizationEXT4/EXT4+Formatter.swift index 1a0f0a28..ad7db3f0 100644 --- a/Sources/ContainerizationEXT4/EXT4+Formatter.swift +++ b/Sources/ContainerizationEXT4/EXT4+Formatter.swift @@ -42,6 +42,7 @@ extension EXT4 { blockSize / groupDescriptorSize } + // internally accessed by journal setup var blockCount: UInt32 { ((size - 1) / blockSize) + 1 } @@ -913,11 +914,13 @@ extension EXT4 { try self.handle.write(contentsOf: Array.init(repeating: 0, count: 2048)) } - // MARK: Private Methods and Properties - var handle: FileHandle - var inodes: [Ptr] + // MARK: Private and internal methods and properties private var tree: FileTree private var deletedBlocks: [(start: UInt32, end: UInt32)] = [] + + // internally accessed by journal setup + var handle: FileHandle + var inodes: [Ptr] let journalConfig: JournalConfig? var pos: UInt64 { From 8914514bc07cd16f53e9b577b02784f144ce7a84 Mon Sep 17 00:00:00 2001 From: John Logan Date: Wed, 8 Apr 2026 16:22:24 -0700 Subject: [PATCH 05/11] Add safety comments for mixed-size math ops in journal setup. --- Sources/ContainerizationEXT4/EXT4+Journal.swift | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Sources/ContainerizationEXT4/EXT4+Journal.swift b/Sources/ContainerizationEXT4/EXT4+Journal.swift index 55d37bad..1c786b7e 100644 --- a/Sources/ContainerizationEXT4/EXT4+Journal.swift +++ b/Sources/ContainerizationEXT4/EXT4+Journal.swift @@ -44,13 +44,18 @@ extension EXT4.Formatter { private func calculateJournalSize(requestedSize: UInt64?, totalBlocks: UInt32) -> UInt32 { if let size = requestedSize { - return UInt32(size / UInt64(self.blockSize)) + // Clamp to UInt32.max: the kernel caps internal journals at 2^32 blocks + // (per §3.6.4 s_maxlen), and a caller-supplied size large enough to exceed + // that would otherwise trap on the narrowing conversion. + let blocks = size / UInt64(self.blockSize) + return UInt32(min(blocks, UInt64(UInt32.max))) } let fsBytes = UInt64(totalBlocks) * UInt64(self.blockSize) let rawBytes = fsBytes / 256 let minBytes: UInt64 = 4.mib() let maxBytes: UInt64 = 128.mib() let clampedBytes = min(max(rawBytes, minBytes), maxBytes) + // Safe: clampedBytes ≤ 128 MiB, so the quotient is at most 131,072 — well within UInt32. return UInt32(clampedBytes / UInt64(self.blockSize)) } @@ -61,6 +66,7 @@ extension EXT4.Formatter { 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)) func writeU32(_ value: UInt32, at offset: Int) { @@ -105,6 +111,8 @@ extension EXT4.Formatter { 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 @@ -136,7 +144,10 @@ extension EXT4.Formatter { journalInode.extraIsize = UInt16(EXT4.ExtraIsize) journalInode.flags = EXT4.InodeFlag.extents.rawValue - // Journal is one contiguous allocation → numExtents = 1 → inline extents (no disk I/O). + // 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: the journal is placed inside the filesystem, whose total block count is + // also a UInt32, so startBlock + blockCount cannot exceed UInt32.max. journalInode = (try? self.writeExtents(journalInode, (startBlock, startBlock + blockCount))) ?? journalInode self.inodes[Int(EXT4.JournalInode) - 1].initialize(to: journalInode) From edfc374e8cbf193730fe1f342c8e9d8c8bbd492a Mon Sep 17 00:00:00 2001 From: John Logan Date: Wed, 8 Apr 2026 16:42:05 -0700 Subject: [PATCH 06/11] Write the backup copy of the journal to the superblock. --- .../ContainerizationEXT4/EXT4+Formatter.swift | 1 + .../ContainerizationEXT4/EXT4+Journal.swift | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/Sources/ContainerizationEXT4/EXT4+Formatter.swift b/Sources/ContainerizationEXT4/EXT4+Formatter.swift index ad7db3f0..b6ae8ab0 100644 --- a/Sources/ContainerizationEXT4/EXT4+Formatter.swift +++ b/Sources/ContainerizationEXT4/EXT4+Formatter.swift @@ -899,6 +899,7 @@ extension EXT4 { compatFeatures |= CompatFeature.hasJournal.rawValue superblock.journalInum = EXT4.JournalInode superblock.journalUUID = filesystemUUID + superblock.journalBlocks = journalInodeBlockBackup() if let mode = config.defaultMode { switch mode { case .writeback: superblock.defaultMountOpts = DefaultMountOpts.journalWriteback diff --git a/Sources/ContainerizationEXT4/EXT4+Journal.swift b/Sources/ContainerizationEXT4/EXT4+Journal.swift index 1c786b7e..b923484f 100644 --- a/Sources/ContainerizationEXT4/EXT4+Journal.swift +++ b/Sources/ContainerizationEXT4/EXT4+Journal.swift @@ -86,6 +86,11 @@ extension EXT4.Formatter { writeU32(journalBlocks, at: 0x10) // s_maxlen writeU32(1, at: 0x14) // s_first (first usable block) writeU32(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 = [ @@ -152,4 +157,29 @@ extension EXT4.Formatter { self.inodes[Int(EXT4.JournalInode) - 1].initialize(to: 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 + // Extract the 15 UInt32 words from the inode's 60-byte extent-tree field, + // then append sizeLow and sizeHigh as words 15 and 16. + 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.sizeLow + words[16] = ji.sizeHigh + 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] + ) + } } From 58a1b8efdcfde12afc8c91187ce35b4ae16f65b6 Mon Sep 17 00:00:00 2001 From: John Logan Date: Wed, 8 Apr 2026 18:19:19 -0700 Subject: [PATCH 07/11] PR feedback: mode values were borked, throw on journal setup error. --- Sources/ContainerizationEXT4/EXT4+Journal.swift | 6 +++--- Sources/ContainerizationEXT4/EXT4+Types.swift | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/ContainerizationEXT4/EXT4+Journal.swift b/Sources/ContainerizationEXT4/EXT4+Journal.swift index b923484f..01811a86 100644 --- a/Sources/ContainerizationEXT4/EXT4+Journal.swift +++ b/Sources/ContainerizationEXT4/EXT4+Journal.swift @@ -37,7 +37,7 @@ extension EXT4.Formatter { let journalStartBlock = self.currentBlock try writeJournalSuperblock(journalBlocks: journalBlocks, filesystemUUID: filesystemUUID) try zeroJournalBlocks(count: journalBlocks - 1) - setupJournalInode(startBlock: journalStartBlock, blockCount: journalBlocks) + try setupJournalInode(startBlock: journalStartBlock, blockCount: journalBlocks) } // MARK: - Private helpers @@ -128,7 +128,7 @@ extension EXT4.Formatter { } } - private func setupJournalInode(startBlock: UInt32, blockCount: UInt32) { + private func setupJournalInode(startBlock: UInt32, blockCount: UInt32) throws { var journalInode = EXT4.Inode() journalInode.mode = EXT4.Inode.Mode(.S_IFREG, 0o600) journalInode.uid = 0 @@ -153,7 +153,7 @@ extension EXT4.Formatter { // in the inode, so writeExtents needs no extra disk I/O for extent index blocks. // Safe: the journal is placed inside the filesystem, whose total block count is // also a UInt32, so startBlock + blockCount cannot exceed UInt32.max. - journalInode = (try? self.writeExtents(journalInode, (startBlock, startBlock + blockCount))) ?? journalInode + journalInode = try self.writeExtents(journalInode, (startBlock, startBlock + blockCount)) self.inodes[Int(EXT4.JournalInode) - 1].initialize(to: journalInode) } diff --git a/Sources/ContainerizationEXT4/EXT4+Types.swift b/Sources/ContainerizationEXT4/EXT4+Types.swift index 1f661ee7..da80cba8 100644 --- a/Sources/ContainerizationEXT4/EXT4+Types.swift +++ b/Sources/ContainerizationEXT4/EXT4+Types.swift @@ -249,9 +249,9 @@ extension EXT4 { static let JournalInode: InodeNumber = 8 struct DefaultMountOpts { - static let journalData: UInt32 = 0x0004 // data=journal - static let journalOrdered: UInt32 = 0x0008 // data=ordered - static let journalWriteback: UInt32 = 0x000C // data=writeback + static let journalData: UInt32 = 0x0020 // data=journal + static let journalOrdered: UInt32 = 0x0040 // data=ordered + static let journalWriteback: UInt32 = 0x0060 // data=writeback } struct CompatFeature { From 01a705d4e125a65b6919e758494b4da8dfceb271 Mon Sep 17 00:00:00 2001 From: John Logan Date: Wed, 8 Apr 2026 20:24:59 -0700 Subject: [PATCH 08/11] PR feedback plus other fixes. - Set journal backup type. - Trailing comment after superblock setup listing fields we implictly left zero. - Enforce minimum journal size of 1024 blocks (`EXT4_MIN_JOURNAL_BLOCKS`). - Fix byte ordering of `i_size` and `i_size_hi` in the s_jnl_blocks. --- .../ContainerizationEXT4/EXT4+Formatter.swift | 36 ++++++++++++++++--- .../ContainerizationEXT4/EXT4+Journal.swift | 24 ++++++++----- 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/Sources/ContainerizationEXT4/EXT4+Formatter.swift b/Sources/ContainerizationEXT4/EXT4+Formatter.swift index b6ae8ab0..ebd83d5f 100644 --- a/Sources/ContainerizationEXT4/EXT4+Formatter.swift +++ b/Sources/ContainerizationEXT4/EXT4+Formatter.swift @@ -73,11 +73,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. -<<<<<<< HEAD - 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 { ->>>>>>> c38156b (Initial WIP for ext4 journal mode.) /// The constructor performs the following steps: /// /// 1. Creates the first 10 inodes: @@ -900,6 +896,7 @@ extension EXT4 { 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 @@ -909,6 +906,34 @@ extension EXT4 { } } 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) } @@ -1269,6 +1294,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): @@ -1303,6 +1329,8 @@ extension EXT4 { return "cannot resize fs 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 1024 blocks (EXT4_MIN_JOURNAL_BLOCKS)" } } } diff --git a/Sources/ContainerizationEXT4/EXT4+Journal.swift b/Sources/ContainerizationEXT4/EXT4+Journal.swift index 01811a86..330ed313 100644 --- a/Sources/ContainerizationEXT4/EXT4+Journal.swift +++ b/Sources/ContainerizationEXT4/EXT4+Journal.swift @@ -29,7 +29,7 @@ extension EXT4.Formatter { UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8 ) ) throws { - let journalBlocks = calculateJournalSize(requestedSize: config.size, totalBlocks: blockCount) + let journalBlocks = try calculateJournalSize(requestedSize: config.size, totalBlocks: blockCount) // Align to block boundary before recording start. if self.pos % self.blockSize != 0 { try self.seek(block: self.currentBlock + 1) @@ -42,12 +42,17 @@ extension EXT4.Formatter { // MARK: - Private helpers - private func calculateJournalSize(requestedSize: UInt64?, totalBlocks: UInt32) -> UInt32 { + private func calculateJournalSize(requestedSize: UInt64?, totalBlocks: UInt32) throws -> UInt32 { if let size = requestedSize { // Clamp to UInt32.max: the kernel caps internal journals at 2^32 blocks // (per §3.6.4 s_maxlen), and a caller-supplied size large enough to exceed // that would otherwise trap on the narrowing conversion. let blocks = size / UInt64(self.blockSize) + // EXT4_MIN_JOURNAL_BLOCKS = 1024: the kernel refuses to mount with fewer. + // blocks == 0 would also cause a UInt32 underflow in the caller. + guard blocks >= 1024 else { + throw EXT4.Formatter.Error.journalTooSmall(size) + } return UInt32(min(blocks, UInt64(UInt32.max))) } let fsBytes = UInt64(totalBlocks) * UInt64(self.blockSize) @@ -147,12 +152,13 @@ extension EXT4.Formatter { journalInode.crtimeExtra = now.hi journalInode.linksCount = 1 journalInode.extraIsize = UInt16(EXT4.ExtraIsize) - journalInode.flags = EXT4.InodeFlag.extents.rawValue + 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: the journal is placed inside the filesystem, whose total block count is - // also a UInt32, so startBlock + blockCount cannot exceed UInt32.max. + // 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].initialize(to: journalInode) @@ -164,16 +170,16 @@ extension EXT4.Formatter { UInt32 ) { let ji = self.inodes[Int(EXT4.JournalInode) - 1].pointee - // Extract the 15 UInt32 words from the inode's 60-byte extent-tree field, - // then append sizeLow and sizeHigh as words 15 and 16. + // 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.sizeLow - words[16] = ji.sizeHigh + 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], From 696d33faaa09abf2719bd49b71b02eb82d4f4f95 Mon Sep 17 00:00:00 2001 From: John Logan Date: Fri, 10 Apr 2026 10:34:07 -0700 Subject: [PATCH 09/11] PR feedback - journal size clamping fixes. --- .../ContainerizationEXT4/EXT4+Formatter.swift | 10 ++++++- .../ContainerizationEXT4/EXT4+Journal.swift | 27 +++++++++++-------- Sources/ContainerizationEXT4/EXT4+Types.swift | 1 + 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/Sources/ContainerizationEXT4/EXT4+Formatter.swift b/Sources/ContainerizationEXT4/EXT4+Formatter.swift index ebd83d5f..10d86bc5 100644 --- a/Sources/ContainerizationEXT4/EXT4+Formatter.swift +++ b/Sources/ContainerizationEXT4/EXT4+Formatter.swift @@ -63,9 +63,14 @@ extension EXT4 { /// /// - Parameters: /// - devicePath: The path to the block device where the ext4 filesystem will be created. +<<<<<<< HEAD /// - blockSize: The filesystem block size in bytes. Must be a power of two in the set /// {1024, 2048, 4096}. Defaults to 4096. +======= + /// - blockSize: The filesystem block size. +>>>>>>> 4639b7a (PR feedback - journal size clamping fixes.) /// - minDiskSize: The minimum disk size required for the formatted filesystem. + /// - 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 @@ -1295,6 +1300,7 @@ extension EXT4 { case cannotResizeFS(_ size: UInt64) case invalidBlockSize(_ size: UInt32) case journalTooSmall(_ size: UInt64) + case journalTooLarge(_ size: UInt64) public var description: String { switch self { case .notDirectory(let path): @@ -1330,7 +1336,9 @@ extension EXT4 { 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 1024 blocks (EXT4_MIN_JOURNAL_BLOCKS)" + return "requested journal size \(size) bytes is too small; minimum is \(EXT4.MinJournalBlocks) blocks (JBD2_MIN_JOURNAL_BLOCKS)" + case .journalTooLarge(let size): + return "requested journal size \(size) bytes exceeds half the filesystem size" } } } diff --git a/Sources/ContainerizationEXT4/EXT4+Journal.swift b/Sources/ContainerizationEXT4/EXT4+Journal.swift index 330ed313..8efac0c9 100644 --- a/Sources/ContainerizationEXT4/EXT4+Journal.swift +++ b/Sources/ContainerizationEXT4/EXT4+Journal.swift @@ -44,23 +44,28 @@ extension EXT4.Formatter { private func calculateJournalSize(requestedSize: UInt64?, totalBlocks: UInt32) throws -> UInt32 { if let size = requestedSize { - // Clamp to UInt32.max: the kernel caps internal journals at 2^32 blocks - // (per §3.6.4 s_maxlen), and a caller-supplied size large enough to exceed - // that would otherwise trap on the narrowing conversion. let blocks = size / UInt64(self.blockSize) - // EXT4_MIN_JOURNAL_BLOCKS = 1024: the kernel refuses to mount with fewer. + // JBD2_MIN_JOURNAL_BLOCKS: the kernel refuses to mount with fewer. // blocks == 0 would also cause a UInt32 underflow in the caller. - guard blocks >= 1024 else { + guard blocks >= EXT4.MinJournalBlocks else { throw EXT4.Formatter.Error.journalTooSmall(size) } - return UInt32(min(blocks, UInt64(UInt32.max))) + guard blocks <= UInt64(totalBlocks) / 2 else { + throw EXT4.Formatter.Error.journalTooLarge(size) + } + // Safe: blocks ≤ totalBlocks / 2 ≤ UInt32.max / 2, so narrowing cannot trap. + return UInt32(blocks) } + // Default sizing: scale with the filesystem, 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 fsBytes = UInt64(totalBlocks) * UInt64(self.blockSize) - let rawBytes = fsBytes / 256 - let minBytes: UInt64 = 4.mib() - let maxBytes: UInt64 = 128.mib() - let clampedBytes = min(max(rawBytes, minBytes), maxBytes) - // Safe: clampedBytes ≤ 128 MiB, so the quotient is at most 131,072 — well within UInt32. + let scaledBytes = fsBytes / 64 // 1/64th of the filesystem, matching e2fsprogs defaults + let minBytes: UInt64 = UInt64(EXT4.MinJournalBlocks) * UInt64(self.blockSize) + let maxBytes: UInt64 = fsBytes > 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)) } diff --git a/Sources/ContainerizationEXT4/EXT4+Types.swift b/Sources/ContainerizationEXT4/EXT4+Types.swift index da80cba8..12eb7b68 100644 --- a/Sources/ContainerizationEXT4/EXT4+Types.swift +++ b/Sources/ContainerizationEXT4/EXT4+Types.swift @@ -247,6 +247,7 @@ extension EXT4 { 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 From ed5af12541c02af6eeb77bd633b3997ebe53a985 Mon Sep 17 00:00:00 2001 From: John Logan Date: Fri, 10 Apr 2026 16:16:30 -0700 Subject: [PATCH 10/11] Throw if journal too large for a pathologically small filesystem. --- .../ContainerizationEXT4/EXT4+Formatter.swift | 5 ++++- .../ContainerizationEXT4/EXT4+Journal.swift | 21 +++++++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/Sources/ContainerizationEXT4/EXT4+Formatter.swift b/Sources/ContainerizationEXT4/EXT4+Formatter.swift index 10d86bc5..6e54563a 100644 --- a/Sources/ContainerizationEXT4/EXT4+Formatter.swift +++ b/Sources/ContainerizationEXT4/EXT4+Formatter.swift @@ -1301,6 +1301,7 @@ extension EXT4 { case invalidBlockSize(_ size: UInt32) case journalTooSmall(_ size: UInt64) case journalTooLarge(_ size: UInt64) + case filesystemTooSmallForJournal public var description: String { switch self { case .notDirectory(let path): @@ -1338,7 +1339,9 @@ extension EXT4 { case .journalTooSmall(let size): return "requested journal size \(size) bytes is too small; minimum is \(EXT4.MinJournalBlocks) blocks (JBD2_MIN_JOURNAL_BLOCKS)" case .journalTooLarge(let size): - return "requested journal size \(size) bytes exceeds half the filesystem size" + return "requested journal size \(size) bytes exceeds half the filesystem size; a journal this large is unlikely to be useful" + case .filesystemTooSmallForJournal: + return "filesystem is too small to accommodate a minimum-sized journal; increase minDiskSize to at least \(2 * EXT4.MinJournalBlocks) blocks" } } } diff --git a/Sources/ContainerizationEXT4/EXT4+Journal.swift b/Sources/ContainerizationEXT4/EXT4+Journal.swift index 8efac0c9..5394d27d 100644 --- a/Sources/ContainerizationEXT4/EXT4+Journal.swift +++ b/Sources/ContainerizationEXT4/EXT4+Journal.swift @@ -50,6 +50,15 @@ extension EXT4.Formatter { guard blocks >= EXT4.MinJournalBlocks else { throw EXT4.Formatter.Error.journalTooSmall(size) } + // A journal larger than half the filesystem is unlikely to be useful. In + // writeback or ordered mode only metadata is journaled, so even the 1 GiB + // default ceiling is generous. Only data=journal mode journals data blocks + // too, and even then sizing beyond half the filesystem would be wasteful. + // This is a policy limit, not a kernel hard limit; exceeding it would not + // cause a mount failure. + // Note: totalBlocks derives from minDiskSize; close() may expand the filesystem + // slightly for block group alignment. The check is conservative — the final + // filesystem can only be larger, so this guard never permits an oversized journal. guard blocks <= UInt64(totalBlocks) / 2 else { throw EXT4.Formatter.Error.journalTooLarge(size) } @@ -61,9 +70,17 @@ extension EXT4.Formatter { // 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 fsBytes = UInt64(totalBlocks) * UInt64(self.blockSize) - let scaledBytes = fsBytes / 64 // 1/64th of the filesystem, matching e2fsprogs defaults + let halfFsBytes = fsBytes / 2 let minBytes: UInt64 = UInt64(EXT4.MinJournalBlocks) * UInt64(self.blockSize) - let maxBytes: UInt64 = fsBytes > 128.gib() ? 1.gib() : 128.mib() + // Note: totalBlocks derives from minDiskSize; close() may expand the filesystem + // substantially if minDiskSize is small relative to content. This check is + // conservative — the final filesystem can only be larger, so false positives + // (rejecting a journal that would have fit) are possible but false negatives are not. + guard minBytes <= halfFsBytes else { + throw EXT4.Formatter.Error.filesystemTooSmallForJournal + } + let scaledBytes = fsBytes / 64 // 1/64th of the filesystem, matching e2fsprogs defaults + let maxBytes: UInt64 = min(fsBytes > 128.gib() ? 1.gib() : 128.mib(), halfFsBytes) 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)) From d8296193a079b46ce1d3f045bd9e2f1e3a5c5851 Mon Sep 17 00:00:00 2001 From: John Logan Date: Fri, 10 Apr 2026 16:25:08 -0700 Subject: [PATCH 11/11] Fix leftover rebase conflict marker. --- Sources/ContainerizationEXT4/EXT4+Formatter.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Sources/ContainerizationEXT4/EXT4+Formatter.swift b/Sources/ContainerizationEXT4/EXT4+Formatter.swift index 6e54563a..85c0e267 100644 --- a/Sources/ContainerizationEXT4/EXT4+Formatter.swift +++ b/Sources/ContainerizationEXT4/EXT4+Formatter.swift @@ -63,12 +63,9 @@ extension EXT4 { /// /// - Parameters: /// - devicePath: The path to the block device where the ext4 filesystem will be created. -<<<<<<< HEAD /// - blockSize: The filesystem block size in bytes. Must be a power of two in the set /// {1024, 2048, 4096}. Defaults to 4096. -======= /// - blockSize: The filesystem block size. ->>>>>>> 4639b7a (PR feedback - journal size clamping fixes.) /// - minDiskSize: The minimum disk size required for the formatted filesystem. /// - journal: The JBD2 journal size and mode, or nil for an unjournalled filesystem. ///