Nemo’s ext4 parser

This page covers internal/filesystem/ext4: detection, superblock and inode parsing, extent-tree traversal, extended attributes, and timestomp.

At a glance

Detection

Sniff in ext4.go looks for the ext4 superblock magic 0xEF53 at byte 1024 + 0x38. That is the fixed superblock location on every ext2, ext3, and ext4 filesystem.

Mounting

New(img) reads the 1024-byte superblock at offset 1024 and validates it before anything else touches the image.

With 64bit, block and inode-table numbers and the group descriptor size can carry a high 32-bit half from an extended region of the descriptor. The parser reads that half only when the feature bit is set.

Inodes (inode.go)

readRawInode finds an inode’s raw bytes in three steps.

  1. Compute the group and in-group index: (inodeNumber - 1) / inodesPerGroup and % inodesPerGroup.
  2. Read the group’s descriptor (at gdtOffset + group * descSize) to get the inode table’s starting block.
  3. Calculate the inode’s offset from there.

readInode then decodes only the fields the parser needs:

Extents (inode.go)

extentBlocks walks an inode’s extent tree. It refuses three cases up front:

walkExtentNode recurses through internal nodes down to the leaves. Along the way it checks:

Each 12-byte leaf extent gives a logical block, a length, and a physical block. A length above 0x8000 marks an unwritten extent: preallocated, but it reads as zeros. The parser treats that as unsupported instead of guessing at its content.

It tracks visited internal-node block numbers, so a cyclic or duplicated child pointer cannot loop the walk. The resulting blocks are sorted by logical number and checked for overlap.

Directories

Children in ext4.go reads linear directories and extent-backed HTree directories.

Indexed traversal follows validated root and index pointers. It checks count and limit fields, block bounds, hash ordering, and unique references. Only leaf blocks are parsed as directory records. Holes, unreferenced blocks, and duplicate names are rejected.

Hash versions 0 to 5 and up to two index levels are supported. The mount allowlist still applies to incompatible feature flags.

Extended attributes (xattr.go)

Reading

readXattrRegions reads the up to two places where ext4 stores an inode’s xattrs.

The external block’s header is validated before its entries are trusted: the magic, a non-zero refcount, and a block count of exactly 1.

parseXattrs walks the entry-list format both regions share.

Writing

writeXattr tries four placements in order.

  1. Update the attribute in place if the name already exists in either region.
  2. Otherwise pack it into the in-inode region next to what is already there, if it fits.
  3. Otherwise take over the in-inode region on its own, if that region is unused.
  4. Otherwise use the external block, if one exists.

An occupied in-inode region is never replaced with only the new attribute when the combined set does not fit. Existing attributes stay in their original region. The new attribute goes into an allocated external block, or the write fails.

Capacity and shared-block checks finish before any image write. A rejected operation leaves the image unchanged.

Three things are refused:

With metadata_csum enabled, both an inode-body write (updateInodeChecksum) and an external-block write recompute and store the right checksum. So the change does not fail fsck on that account.

Timestomp (timestomp.go)

Entry implements filesystem.TimestompCapable: Timestamp (a reader), SupportsTimestamp, and SetTimestamp.

SetTimestamp works in five steps.

  1. Read the whole raw inode.
  2. Compute the field’s byte layout.
  3. Write the new value into that copy.
  4. Refresh the inode checksum when metadata_csum is enabled.
  5. Write the complete inode back in one WriteAt.

A custody decorator on the image therefore sees one change covering the whole inode, not a partial write.

Field layout

timestampLayout maps the fields to offsets:

The extra area is the part past the 128-byte “good old” inode, sized by i_extra_isize. inodeExtraEnd works out how far it extends and validates i_extra_isize on the way.

Asking for created on an inode too small to hold i_crtime gives a clear, named error. It never writes the wrong field.

Encoding

encodeExt4Timestamp and decodeExt4Timestamp handle the split encoding.

What it does not do

SupportsTimestamp checks whether the inode contains the requested field. The technique layer does not compare timestamps against a historical baseline, and it does not save original timestamps. See Timestomping for the resulting detection and restoration limits.

Limitations

Nemo refuses all of these:

Slack and Linux live mode

Image slack

Image slack uses the unused tail of the final allocated block of a regular file.

Nemo refuses these layouts with filesystem.ErrUnsupported, which lets automatic detection continue:

Corrupt extent metadata stays fatal.

Hide, detect, and clear use the shared CRC-checked nemo frame. A backup manifest restores the overwritten residual bytes. Frames from the earlier unmerged ext4 branch used a different format. The shared frame reader does not detect them, so recreate them on disposable fixtures.

Native operations

Linux live mode uses descriptor-backed xattr and timestamp syscalls. Native operations need ordinary file permissions.

Live slack detection and extraction

Explicit live slack detection and extraction open the backing block device read-only.

Raw reads need device read permission (often elevated), a visible device node, and kernel STATX_MNT_ID support.

Unflushed changes or concurrent activity can make the mounted view stale. These reads are for discovery, not a consistent snapshot. For repeatable forensic results, use a stable offline image. Mounted raw writes stay refused.

Image custody records hold write hashes. Live operations record logical operation metadata, not raw image write events.

Testing

ext4_test.go and timestomp_test.go build synthetic superblocks, group descriptors, inodes, and extent trees byte by byte. The tests do not depend on an image from a real Linux install. They pin down the exact on-disk layout assumptions:

xattr_capacity_test.go covers:

It checks existing values after external fallback and after deletion. It also compares the whole image before and after rejected additions and replacements, to confirm no unrelated bytes change.

indexed_test.go generates disposable 1 KiB and 4 KiB ext4 images with mke2fs, debugfs, and e2fsck -D. It lists 700 indexed files with metadata checksums on. Synthetic cases cover index levels and malformed metadata. The e2fsprogs cases skip when those tools are missing.

python3 scripts/linux-acceptance.py --work-parent <ext4-directory> --require-live adds native xattr, timestamp, extraction, and skipped-child checks to the image round trips. The Linux CI job uses this mode, so missing native coverage fails instead of silently skipping.

Further reading