Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions Sources/ContainerizationEXT4/EXT4+Formatter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1023,11 +1023,14 @@ extension EXT4 {
}
let tableSize: UInt64 = UInt64(EXT4.InodeSize) * blockGroups * inodesPerGroup
let rest = tableSize - UInt64(self.inodes.count) * EXT4.InodeSize
let zeroBlock = Array<UInt8>.init(repeating: 0, count: Int(self.blockSize))
for _ in 0..<(rest / self.blockSize) {
try self.handle.write(contentsOf: zeroBlock)
}
try self.handle.write(contentsOf: Array<UInt8>.init(repeating: 0, count: Int(rest % self.blockSize)))
// The inodes past the ones written are free, and the table reads as
// zero for the whole span they cover. A hole reads as zero as well,
// so the span is skipped rather than written: the bytes a reader
// sees are the same, and the blocks a filesystem holding the image
// gives up to them are not. The bitmaps of the groups past the ones
// holding content are placed by the same seek, and what follows here
// writes at a higher offset, so the file still reaches its length.
try self.handle.seek(toOffset: self.pos + rest)
return inodeTableOffset
}

Expand Down
23 changes: 14 additions & 9 deletions Sources/ContainerizationEXT4/EXT4+Journal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,17 +126,22 @@ 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
while remaining > 0 {
let toWrite = min(zeroBuf.count, remaining)
try self.handle.write(contentsOf: zeroBuf[0..<toWrite])
remaining -= toWrite
}
let totalBytes = UInt64(count) * UInt64(self.blockSize)
// A journal block holding no transaction reads as zero, and so does a
// hole, so the span is sought over rather than written. An image
// formatted at the capacity a container's filesystem is given takes a
// gigabyte of journal, and writing it spent a gigabyte of the host's
// disk on every image unpacked before it held anything.
//
// The last block is written rather than sought over, so a journal that
// does not fit in the image still fails here at the I/O layer, which is
// what writing the whole span gave and what the journal inode's extent
// is written on the strength of.
let end = self.pos + totalBytes
try self.handle.seek(toOffset: end - UInt64(self.blockSize))
try self.handle.write(contentsOf: [UInt8](repeating: 0, count: Int(self.blockSize)))
}

private func setupJournalInode(startBlock: UInt32, blockCount: UInt32) throws {
Expand Down
85 changes: 85 additions & 0 deletions Tests/ContainerizationEXT4Tests/TestEXT4Sparse.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
//===----------------------------------------------------------------------===//
// 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

/// A formatted image holds its structures where a reader expects them and
/// nothing anywhere else. What an empty filesystem costs the host it is stored
/// on is the sum of the structures actually written, not of the capacity the
/// filesystem was given: the journal and the free half of the inode table read
/// as zero over spans measured in gigabytes at container capacities, and a
/// filesystem holding those as holes reads the same as one holding them as
/// written zeros.
struct Ext4SparseTests {
/// The blocks a file occupies, which is what the filesystem holding it
/// gives up, as opposed to the length the file reports.
private func allocatedBytes(of path: FilePath) throws -> UInt64 {
var st = stat()
guard stat(path.string, &st) == 0 else {
throw EXT4.Formatter.Error.notFound(path)
}
return UInt64(st.st_blocks) * 512
}

private func format(capacity: UInt64, journal: EXT4.JournalConfig?) throws -> (path: FilePath, allocated: UInt64, length: UInt64) {
let path = FilePath(
FileManager.default.uniqueTemporaryDirectory()
.appendingPathComponent("ext4.img.delme.sparse", isDirectory: false))
let formatter = try EXT4.Formatter(path, minDiskSize: capacity, journal: journal)
try formatter.create(path: FilePath("/test"), mode: EXT4.Inode.Mode(.S_IFDIR, 0o755))
try formatter.close()
let handle = try FileHandle(forReadingFrom: path.url)
let length = try handle.seekToEnd()
try handle.close()
return (path, try allocatedBytes(of: path), length)
}

/// An image formatted at the capacity a container's filesystem is given
/// reports that capacity and occupies a small fraction of it. The bound is
/// far above what the structures of an empty filesystem come to and far
/// below the gigabyte a written-out journal alone would add, so it holds
/// whatever the layout does and fails if a span goes back to being written.
@Test func emptyImageAtContainerCapacityOccupiesLittle() throws {
let capacity: UInt64 = 512.gib()
let result = try format(capacity: capacity, journal: .init(defaultMode: .ordered))
defer { try? FileManager.default.removeItem(at: result.path.url) }

#expect(result.length >= capacity)
#expect(
result.allocated < 256.mib(),
"an empty image of \(capacity) bytes occupies \(result.allocated) bytes"
)
}

/// The journal is the largest of those spans, so an image given one and an
/// image given none occupy nearly the same.
@Test func theJournalCostsLittleUntilItHoldsSomething() throws {
let capacity: UInt64 = 512.gib()
let journaled = try format(capacity: capacity, journal: .init(defaultMode: .ordered))
defer { try? FileManager.default.removeItem(at: journaled.path.url) }
let plain = try format(capacity: capacity, journal: nil)
defer { try? FileManager.default.removeItem(at: plain.path.url) }

#expect(
journaled.allocated < plain.allocated + 64.mib(),
"journaled image occupies \(journaled.allocated) against \(plain.allocated) without one"
)
}
}