diff --git a/Sources/ContainerizationEXT4/EXT4+Formatter.swift b/Sources/ContainerizationEXT4/EXT4+Formatter.swift index 5f13386a..2df82106 100644 --- a/Sources/ContainerizationEXT4/EXT4+Formatter.swift +++ b/Sources/ContainerizationEXT4/EXT4+Formatter.swift @@ -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.init(repeating: 0, count: 2048)) } - // MARK: Private Methods and Properties - private var handle: FileHandle - private var inodes: [Ptr] + // 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] + 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)" } } } diff --git a/Sources/ContainerizationEXT4/EXT4+Journal.swift b/Sources/ContainerizationEXT4/EXT4+Journal.swift new file mode 100644 index 00000000..5e3511f6 --- /dev/null +++ b/Sources/ContainerizationEXT4/EXT4+Journal.swift @@ -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.. ( + 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] + ) + } +} diff --git a/Sources/ContainerizationEXT4/EXT4+Types.swift b/Sources/ContainerizationEXT4/EXT4+Types.swift index be4da74a..12eb7b68 100644 --- a/Sources/ContainerizationEXT4/EXT4+Types.swift +++ b/Sources/ContainerizationEXT4/EXT4+Types.swift @@ -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 diff --git a/Sources/ContainerizationEXT4/EXT4.swift b/Sources/ContainerizationEXT4/EXT4.swift index 9e7b3d61..d32bb8f1 100644 --- a/Sources/ContainerizationEXT4/EXT4.swift +++ b/Sources/ContainerizationEXT4/EXT4.swift @@ -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 { diff --git a/Sources/Integration/ContainerTests.swift b/Sources/Integration/ContainerTests.swift index 84b86356..3093d485 100644 --- a/Sources/Integration/ContainerTests.swift +++ b/Sources/Integration/ContainerTests.swift @@ -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" 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), diff --git a/Tests/ContainerizationEXT4Tests/TestEXT4Journal.swift b/Tests/ContainerizationEXT4Tests/TestEXT4Journal.swift new file mode 100644 index 00000000..83703246 --- /dev/null +++ b/Tests/ContainerizationEXT4Tests/TestEXT4Journal.swift @@ -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() + } + } +}