Summary
Reading a large file returns EIO at offset 0, while smaller files on the same
volume read correctly. The boundary is the extent-tree depth: files whose
extent tree is depth-1 read fine; files large/fragmented enough to push the tree
to depth-2 fail completely. The data is fully allocated on disk — it is
unreachable, not missing.
Root cause: index (internal) extent nodes drop their ei_unused field, so
extent-block checksum validation (which reconstructs the block via toData())
fails whenever an intermediate index block's ei_unused bytes are non-zero.
Environment
- ExtendFS 1.2.0 (from the GitHub release)
- macOS 26.6.2 (25G83), Apple Silicon
- Volume: 4 TB ext4, block size 4096, created by a recent Ubuntu
mke2fs
(features include extents, 64bit, metadata_csum); ~2 TB used.
- Affected files: several large data files (hundreds of GB each). Files ≤ ~120 GiB
read perfectly; files ≥ ~185 GiB return EIO. The clean boundary is the depth-1 →
depth-2 transition of the extent tree.
Evidence (from the actual volume)
debugfs on the raw device shows a failing file has a depth-2 tree, and the
intermediate index block has non-zero ei_unused in its live entries:
$ sudo debugfs -R "ex /path/to/largefile.bin" /dev/rdiskN
Level Entries Logical Physical Length Flags
0/ 2 1/ 1 0 - 48648033 309887192 48648034
1/ 2 1/ 5 0 - 10481663 309887193 10481664 <- intermediate index block
2/ 2 1/339 ... <- leaf extents
$ sudo dd if=/dev/rdiskN bs=4096 skip=309887192 count=1 | (inspect the index entries)
index block 309887192: magic=0xf30a entries=5 eh_max=340 depth=1
slots with NON-ZERO ei_unused: 4 of 340
slot 0: ei_unused=0x03f7
slot 1: ei_unused=0x03f6
slot 2: ei_unused=0x03f6
slot 3: ei_unused=0x03f5
debugfs dump reads the file's data cleanly (correct extent implementation), so the
data is intact and this is purely a driver-side traversal/validation defect.
Root cause
ExtendFS Extension (ext4)/DiskStructures/FileExtentNode.swift
init?(from:isLeaf:), the internal-node (else) branch reads only 10 of the
12 bytes — ei_block (4), ei_leaf_lo (4), ei_leaf_hi (2) — and never reads
ei_unused (bytes 10–11).
toData(), the internal-node branch writes those two bytes back as
data.appendLittleEndian(UInt16(0)).
Extent-block checksums are validated in
FileExtentTreeLevel.init(from:inodeChecksumSeed:) by reconstructing the block via
toData() and CRC32C-ing the first 12 + eh_max*12 bytes. ext4 folds all
eh_max slots into that checksum — including stale slots left behind by past tree
splits, whose ei_unused bytes are frequently non-zero. Because the internal-node
round-trip zeroes bytes 10–11, the reconstruction differs from disk, the CRC fails,
FileExtentTreeLevel.init returns nil, and findExtentsCovering throws
POSIXError(.EIO).
Why only depth ≥ 2
An internal level is checksum-reconstructed only when it is a fetched, non-inode
block. In a depth-1 tree the only internal level is the inode root, which is never
checksummed (the maxNumberOfEntries > 4 guard), and its children are leaf blocks —
leaf nodes have no ei_unused field, so they round-trip losslessly. A depth-2 tree
is the first (and only) time an intermediate index block (eh_max 340, all index
nodes) is checksum-validated, which is exactly where the lossy round-trip bites.
Suggested fix (minimal)
Preserve ei_unused on parse and re-emit it in toData():
} else { // internal (index) node — init?(from:isLeaf:)
guard let lowerStartBlock: UInt32 = try? data.readLittleEndian(at: &offset) else { return nil }
guard let upperStartBlock: UInt16 = try? data.readLittleEndian(at: &offset) else { return nil }
self.physicalBlock = off_t(UInt64.combine(upper: upperStartBlock, lower: lowerStartBlock))
+ guard let unused: UInt16 = try? data.readLittleEndian(at: &offset) else { return nil }
+ self.indexNodeUnused = unused
}
} else { // toData(), internal node
data.appendLittleEndian(physicalBlockLow)
data.appendLittleEndian(physicalBlockHigh)
- data.appendLittleEndian(UInt16(0))
+ data.appendLittleEndian(indexNodeUnused)
}
var type: FSExtentType?
+ /// Raw `ei_unused` bytes (offset 10–11) of an index node, preserved so extent-block
+ /// checksum reconstruction via `toData()` is byte-exact. Zero for leaf/created nodes.
+ var indexNodeUnused: UInt16 = 0
Leaf nodes and the convenience initializer are unaffected (the new field defaults to
0; leaf toData() never reads it).
Suggested fix (more robust, optional)
The deeper fragility is that checksum validation depends on toData() being a
byte-exact inverse of parsing for every field, including semantically-unused ones.
Checksumming the originally-fetched Data in FileExtentTreeLevel (rather than a
re-serialized copy) would immunize validation against this whole class of bug. The
minimal fix above is sufficient for this specific defect; this is a follow-up worth
considering.
Verification
I verified the minimal fix with a standalone round-trip harness: a synthetic depth-1
index block with a single non-zero ei_unused in one slot — the original code
reconstructs bytes that differ from disk and fails CRC (→ EIO); the patched code
reconstructs byte-exact and the CRC validates. Byte-equality implies
checksum-equality under any polynomial.
Note on testing
I have not been able to test a rebuilt driver end-to-end on my own machine: it is
a managed (MDM) Mac, and loading a self-built FSKit module requires the
com.apple.developer.fskit.fsmodule entitlement, which isn't available to a free
personal team, while the ad-hoc/SIP-relaxed route is blocked by the device's
policy. So the analysis and the round-trip proof are as far as I can take it locally —
the fix should be validated in a signed build. Happy to provide the full debugfs
dumps or the test harness if useful.
Thanks for ExtendFS — the FSKit approach is exactly the right direction.
Summary
Reading a large file returns
EIOat offset 0, while smaller files on the samevolume read correctly. The boundary is the extent-tree depth: files whose
extent tree is depth-1 read fine; files large/fragmented enough to push the tree
to depth-2 fail completely. The data is fully allocated on disk — it is
unreachable, not missing.
Root cause: index (internal) extent nodes drop their
ei_unusedfield, soextent-block checksum validation (which reconstructs the block via
toData())fails whenever an intermediate index block's
ei_unusedbytes are non-zero.Environment
mke2fs(features include
extents,64bit,metadata_csum); ~2 TB used.read perfectly; files ≥ ~185 GiB return EIO. The clean boundary is the depth-1 →
depth-2 transition of the extent tree.
Evidence (from the actual volume)
debugfson the raw device shows a failing file has a depth-2 tree, and theintermediate index block has non-zero
ei_unusedin its live entries:debugfs dumpreads the file's data cleanly (correct extent implementation), so thedata is intact and this is purely a driver-side traversal/validation defect.
Root cause
ExtendFS Extension (ext4)/DiskStructures/FileExtentNode.swiftinit?(from:isLeaf:), the internal-node (else) branch reads only 10 of the12 bytes —
ei_block(4),ei_leaf_lo(4),ei_leaf_hi(2) — and never readsei_unused(bytes 10–11).toData(), the internal-node branch writes those two bytes back asdata.appendLittleEndian(UInt16(0)).Extent-block checksums are validated in
FileExtentTreeLevel.init(from:inodeChecksumSeed:)by reconstructing the block viatoData()and CRC32C-ing the first12 + eh_max*12bytes. ext4 folds alleh_maxslots into that checksum — including stale slots left behind by past treesplits, whose
ei_unusedbytes are frequently non-zero. Because the internal-noderound-trip zeroes bytes 10–11, the reconstruction differs from disk, the CRC fails,
FileExtentTreeLevel.initreturnsnil, andfindExtentsCoveringthrowsPOSIXError(.EIO).Why only depth ≥ 2
An internal level is checksum-reconstructed only when it is a fetched, non-inode
block. In a depth-1 tree the only internal level is the inode root, which is never
checksummed (the
maxNumberOfEntries > 4guard), and its children are leaf blocks —leaf nodes have no
ei_unusedfield, so they round-trip losslessly. A depth-2 treeis the first (and only) time an intermediate index block (
eh_max340, all indexnodes) is checksum-validated, which is exactly where the lossy round-trip bites.
Suggested fix (minimal)
Preserve
ei_unusedon parse and re-emit it intoData():} else { // internal (index) node — init?(from:isLeaf:) guard let lowerStartBlock: UInt32 = try? data.readLittleEndian(at: &offset) else { return nil } guard let upperStartBlock: UInt16 = try? data.readLittleEndian(at: &offset) else { return nil } self.physicalBlock = off_t(UInt64.combine(upper: upperStartBlock, lower: lowerStartBlock)) + guard let unused: UInt16 = try? data.readLittleEndian(at: &offset) else { return nil } + self.indexNodeUnused = unused }} else { // toData(), internal node data.appendLittleEndian(physicalBlockLow) data.appendLittleEndian(physicalBlockHigh) - data.appendLittleEndian(UInt16(0)) + data.appendLittleEndian(indexNodeUnused) }Leaf nodes and the convenience initializer are unaffected (the new field defaults to
0; leaftoData()never reads it).Suggested fix (more robust, optional)
The deeper fragility is that checksum validation depends on
toData()being abyte-exact inverse of parsing for every field, including semantically-unused ones.
Checksumming the originally-fetched
DatainFileExtentTreeLevel(rather than are-serialized copy) would immunize validation against this whole class of bug. The
minimal fix above is sufficient for this specific defect; this is a follow-up worth
considering.
Verification
I verified the minimal fix with a standalone round-trip harness: a synthetic depth-1
index block with a single non-zero
ei_unusedin one slot — the original codereconstructs bytes that differ from disk and fails CRC (→ EIO); the patched code
reconstructs byte-exact and the CRC validates. Byte-equality implies
checksum-equality under any polynomial.
Note on testing
I have not been able to test a rebuilt driver end-to-end on my own machine: it is
a managed (MDM) Mac, and loading a self-built FSKit module requires the
com.apple.developer.fskit.fsmoduleentitlement, which isn't available to a freepersonal team, while the ad-hoc/SIP-relaxed route is blocked by the device's
policy. So the analysis and the round-trip proof are as far as I can take it locally —
the fix should be validated in a signed build. Happy to provide the full
debugfsdumps or the test harness if useful.
Thanks for ExtendFS — the FSKit approach is exactly the right direction.