167 lines
7.7 KiB
Swift
167 lines
7.7 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
// 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()
|
|
}
|
|
}
|
|
}
|