diff --git a/Sources/ContainerizationEXT4/EXT4+Formatter.swift b/Sources/ContainerizationEXT4/EXT4+Formatter.swift index 708b9e1b0..4f461441e 100644 --- a/Sources/ContainerizationEXT4/EXT4+Formatter.swift +++ b/Sources/ContainerizationEXT4/EXT4+Formatter.swift @@ -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.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.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 } diff --git a/Sources/ContainerizationEXT4/EXT4+Journal.swift b/Sources/ContainerizationEXT4/EXT4+Journal.swift index 5e3511f6a..aa235972a 100644 --- a/Sources/ContainerizationEXT4/EXT4+Journal.swift +++ b/Sources/ContainerizationEXT4/EXT4+Journal.swift @@ -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.. 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" + ) + } +}