Skip to content

EIO on files with depth ≥ 2 extent trees: index-node ei_unused dropped, breaking extent-block checksum #7

Description

@michaelschleiss

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

  1. 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).
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions