XFS Forensic Reader — Research-First Report (xfs-core + xfs-forensic)¶
Read-only Research-First deliverable (spec, prior art, oracle, phased build) — the mandatory pre-implementation research per the fleet Research-First discipline. Confidence is marked; items not fetched verbatim are flagged as gaps.
1. Authoritative spec¶
Primary — the SGI/kernel "XFS Algorithms & Data Structures" document.
- Title: XFS Algorithms & Data Structures (formerly XFS Filesystem Structure,
3rd ed., SGI / Dave Chinner / xfsprogs team), now maintained in-tree as reST and
rendered at https://www.kernel.org/doc/html/latest/filesystems/xfs/index.html
(chapters: Common types, AG headers, Journaling, Internal Inodes, Data extents,
Directories, …). The older standalone xfs_filesystem_structure.pdf under
mirrors.edge.kernel.org/pub/linux/utils/fs/xfs/docs/ is FlateDecode-compressed —
use the HTML kernel docs, not the PDF.
- Gap: the Princeton mirror of the standalone algorithms HTML 404'd; the
kernel.org Documentation/filesystems/xfs/ tree is the live authoritative source.
- v5 companion: https://www.kernel.org/doc/html/latest/filesystems/xfs/xfs-self-describing-metadata.html
(the v5 xfs_ondisk_hdr verbatim).
- Secondary canonical inspector reference doubling as the oracle:
xfs_db(8) (https://www.mankier.com/8/xfs_db) — documents every on-disk type
field-by-field.
Core structures a reader must parse:
- Superblock — at offset 0, one per AG (AG 0 primary; secondaries are backups).
sb_magicnum= 0x58465342 = ASCII"XFSB"at byte 0.sb_blocksizebytes 4–7 (default 4096 on v5);sb_inodesizebytes 104–105 (default 512 on v5).sb_rootinobytes 56–63 (normally 64);sb_logstartbytes 48–55;sb_agblocksbytes 84–87 (blocks/AG);sb_agcountbytes 88–91;sb_logblocksbytes 96–99.sb_versionnum— low nibble = version (4 vs 5); +sb_features2/sb_features_incompatfor v5. v5 adds CRCs, UUID-stamped metadata,owner/blkno/lsnin headers, bigtime, ftype-in-dirent, inobtcount, reflink, rmapbt.-
Also need the log2 shift fields
sb_inopblog,sb_agblklog,sb_inodelog,sb_blocklog— required for the inode-number decode. -
Allocation Groups + AG headers.
sb_agcountAGs ofsb_agblocksblocks each; sector-0 of each AG holds: - AGF (magic
XAGF0x58414746) — free-space B+trees (bnobt/cntbt roots, freelist, longest free extent); v5 adds rmapbt/refcountbt roots. - AGI (magic
XAGI0x58414749) — inode allocation:agi_root(inobt root),agi_count/agi_freecount, theagi_unlinked[64]hash bucket array (orphaned-but-open inodes — forensically valuable); v5 adds finobt root. -
AGFL (magic
XAFLon v5) — the AG free-list block ring. -
Inode format. Magic
0x494e="IN". Inodes live in chunks of 64. Core is v2 (256-byte inode, v4) or v3 (v5, larger core with CRC/di_crtime/di_ino/di_uuid). Key fields:di_mode,di_version(1/2/3),di_format(data-fork format selector),di_aformat,di_size,di_nblocks,di_nextents, timestamps. Data starts at inode offset 176 on v3.di_format: 0 = DEV, 1 = LOCAL (inline: short-form dir or symlink), 2 = EXTENTS (inline extent array), 3 = BTREE (bmap B+tree root inline). -
Inode-number → (AG, block, offset) decode (the crux; confirm vs
xfs_db convert): agno = ino >> (sb_agblklog + sb_inopblog);agino = ino & ((1 << (sb_agblklog + sb_inopblog)) - 1).agblock = agino >> sb_inopblog;offset = agino & ((1 << sb_inopblog) - 1).fsblock = (agno * sb_agblocks) + agblock; byte =fsblock * sb_blocksize + offset * sb_inodesize.-
Live example (
sb_inopblog=3→ 8 inodes/block):ino 67761631 → agno 0x2, agino 0x9f5df, agblock 0x13ebb, offset 0x7. The decoder must reproducexfs_db convertexactly (an independent structural check; self-minted image => Tier-2). -
The 5 directory formats (by size /
di_format): - Short-form (local,
di_format=1) — entries packed inline; parent inode + per-entry (namelen, offset, name, inumber, v5 ftype). Residue: deleting shifts entries down, leaving the tail entry in inode slack. - Block directory — single block: data entries front, bestfree/free-space array in header, hash/leaf array + tail at end. Deletion sets dirent inumber to 0xFFFF, zeroes the hash offset, updates free array — but the original 32-bit inode number stays visible in the freed entry (recovery signal).
- Leaf directory — data blocks + a leaf block (name-hash → data-block-offset).
- Node directory — a dabtree (da_btree) of leaf blocks.
-
B+tree directory — largest; dir data uses a bmap B+tree to map logical dir-blocks → fsblocks. Non-shortform dir blocks carry a
blkinfo/da_blkinfoheader (forw/back/magic v4; v5 adds crc/owner/blkno/lsn + magicsXDD3/XDB3/XDLF/…). -
Extent lists vs bmap B+tree.
di_format=2(EXTENTS): inline array of 16-byte packedxfs_bmbt_recrecords — packing across two BE u64s:[flag:1 (unwritten)] [startoff:54] [startblock:52] [blockcount:21].startblockis an absolute fsblock (decode via the same agno/agblock split).di_format=3(BTREE): bmbt root header + keys/ptrs; leaves hold the same 16-byte records. Realtime/rmap variants (rtrmapbt magicMAPR0x4d415052) are out of MVP scope. -
v4 vs v5: v5 = self-describing metadata (
magic/crc(crc32c)/uuid/owner/blkno/lsnper metadata block); v5 inodes are v3 core (hasdi_crtime,di_ino,di_uuid,di_flags2incl. bigtime — 64-bit timestamp vs v4's(secs:i32,nsec:i32)); v5 dirents carry a trailing ftype byte (v4 do not — off-by-one risk). CRC32c = Castagnoli/iSCSI polynomial.
2. Existing implementations (build-vs-reuse)¶
Rust:
| Name | URL | Maturity | R/W | License | Role |
|---|---|---|---|---|---|
xfs-fuse/xfuse (Khaled Emara) |
github.com/KhaledEmaraDev/xfuse, crates.io xfs-fuse |
Most mature Rust XFS reader; ~14k dl, active 2026. Deps bincode-next/crc/fuser/uuid. |
Read-only | verify LICENSE before close reading | Best reference + cross-check; a FUSE binary, not a library core — study, don't depend. |
lamxfs (Lamco) |
github.com/lamco-admin/lamxfs, crates.io | v0.1.0, no_std, read-only, clean-room; crc 3.2 CRC_32_ISCSI for v5. |
Read-only | MIT OR Apache-2.0 | Best reuse/reference candidate — permissive, clean-room, dependency-light; likely narrow (boot-path). |
mkfs-xfs (justincormack) |
github.com/justincormack/mkfs-xfs | v0.1.0, dependency-free, writer | Write | (check) | Inverse reference — an encoder shows exact byte layouts; good for synthetic fixtures. |
xfs (2016) |
crates.io | Abandoned; parses XFS perf data, not the FS | — | — | Irrelevant (name collision only). |
Non-Rust references / oracles:
- The Sleuth Kit — XFS support (TSK 4.7.0, 2019). Read-only; fsstat/fls/
istat/icat. Our strongest independent forensic oracle (from-scratch C, not
libxfs → genuine cross-impl validation). Caveat: rough on v5/newer feature bits —
reconcile divergences.
- xfs_db(8) — canonical structural oracle (convert = inode-number ground truth).
- xfs_info/xfs_spaceman, libxfs (reference C, tiebreaker).
- Deleted-recovery refs for -forensic: xfsr, xfs_irecover, the righteousit.com
XFS series (parts 1–4+).
Recommendation: build our own xfs-core (fleet policy; no crate is a
forensic-grade library core). Reuse the crc crate (CRC_32_ISCSI) for v5 + fleet
blazehash-core for content hashing. Learn packing from xfuse (most complete) and
lamxfs (cleanest, permissive); cross-check every structure vs xfs_db + TSK.
Verify xfuse's exact license before close reading.
3. Real sample data + oracle (Tier-2 self-mint backstop; Tier-1 real corpus pending)¶
Mint controlled real images on the Parallels Ubuntu VM (xfsprogs = mkfs.xfs,
xfs_db, xfs_info; sleuthkit for the second oracle):
sudo apt-get install -y xfsprogs sleuthkit coreutils
cd /tmp && rm -rf xfs-oracle && mkdir xfs-oracle && cd xfs-oracle
# v5 (default: CRC + bigtime + ftype)
truncate -s 512M v5.img
mkfs.xfs -f v5.img
xfs_info v5.img > v5.xfs_info.txt
# v4 (legacy, no CRC)
truncate -s 512M v4.img
mkfs.xfs -f -m crc=0 v4.img
# populate the 3 key dir shapes + a multi-extent file + a deleted-file case
mkdir mnt && sudo mount -o loop v5.img mnt
sudo mkdir mnt/sf && for i in 1 2 3; do echo "content-$i" | sudo tee mnt/sf/file$i.txt >/dev/null; done
sudo mkdir mnt/block && for i in $(seq -w 1 40); do echo x | sudo tee mnt/block/e$i >/dev/null; done
sudo mkdir mnt/leaf && for i in $(seq -w 1 2000); do : | sudo tee mnt/leaf/f$i >/dev/null; done
sudo dd if=/dev/urandom of=mnt/big.bin bs=1M count=16
sudo sha256sum mnt/sf/file1.txt mnt/big.bin > /tmp/xfs-oracle/content.sha256
echo "delete-me" | sudo tee mnt/sf/DELETED_secret.txt >/dev/null
sync; sudo rm mnt/sf/DELETED_secret.txt; sync
sudo umount mnt
Oracles:
# A: xfs_db — structural ground truth (independent oracle; self-minted image => Tier-2)
xfs_db -r v5.img -c 'sb 0' -c 'print'
xfs_db -r v5.img -c 'agi 0' -c 'print' # AGI incl. unlinked[]
xfs_db -r v5.img -c 'agf 0' -c 'print'
xfs_db -r v5.img -c 'inode 64' -c 'print'
xfs_db -r v5.img -c 'convert inode <N> agno' -c 'convert inode <N> agino' \
-c 'convert inode <N> agblock' -c 'convert inode <N> offset'
xfs_db -r v5.img -c 'inode <big_ino>' -c 'bmap'
# B: The Sleuth Kit — independent second reader (cross-impl; still Tier-2 on a self-minted image)
fsstat v5.img ; fls -r v5.img ; istat v5.img 64 ; icat v5.img <big_ino> | sha256sum
# C: mount-ro + sha256 — content check (Tier-2: self-minted content)
sudo mount -o ro,loop v5.img mnt; sha256sum mnt/sf/file1.txt mnt/big.bin; sudo umount mnt
Corpora: no well-known public XFS forensic image surfaced in the first pass
(CFReDS/Digital Corpora skew ext/NTFS/FAT/HFS). A self-minted mkfs.xfs image is
REAL-self = Tier-2 even with three independent oracles (xfs_db + TSK + mount) —
independence of the oracle does not lift a self-authored artifact to Tier-1.
Genuine Tier-1 needs a third-party artifact: a RHEL/Rocky/AlmaLinux/CentOS 7+
disk image (the RHEL family defaults to XFS), a DFIR-challenge image, or libyal
libfsxfs test data — sourcing this is an open task (in progress).
Document every minted image's commands in tests/data/README.md + the fleet
docs/corpus-catalog.md.
4. Scope/difficulty + phased build order¶
MVP reader: superblock → AG headers → inode-by-number (v2+v3, shift decode) → the 5 dir formats → extent-list + bmbt file read → v3 timestamps incl. bigtime → v4/v5 branch.
Hard (ranked):
1. bmap B+tree (di_format=3) — multi-level; same 16-byte record packing at
leaves. The 54/52/21-bit split is the single highest-risk number — the classic
"inverted bit-split ships green" trap; pull it verbatim from the spec's
Data-extents chapter, validate every extent vs xfs_db bmap.
2. The 5 directory formats (esp. node dabtree + btree dirs; v4/v5 header-shift +
trailing ftype = off-by-one sources).
3. v4/v5 divergence woven through everything — branch cleanly at the top.
4. Sparse inodes (v5 SPINODES) — inode chunks partially allocated; deferrable past MVP.
5. CRC32c verification (v5) — easy with crc (CRC_32_ISCSI); owner/blkno
self-consistency is the forensically useful part.
Difficulty vs ext4: comparable, a notch harder (~1.3–1.5×): AG model + shift-based inode-number encoding + five dir formats + self-describing v5 metadata.
xfs-core phases:
- P0 Superblock + geometry (derive all shifts; magic XFSB). Oracle: xfs_db sb 0, xfs_info, fsstat.
- P1 Inode-number decode + AG headers (AGF/AGI/AGFL incl. agi_unlinked[64]). Oracle: xfs_db convert (exact-match), agi/agf print.
- P2 Inode core (v2 + v3; bigtime). Oracle: xfs_db inode N print, istat.
- P3 Extent-list files (di_format=2) — 16-byte bmbt unpack → file read. Oracle: xfs_db bmap, icat|sha256, mount content.
- P4 Directories — short-form + block DONE (read_dir/read_by_path,
oracle: xfs_db dir dumps + mount-ro ls -i name→inode + capstone content
sha256 through path navigation; TSK has no XFS on this host so fls is not the
oracle). The ftype byte tracks the fs FEATURE bit (Superblock::has_ftype),
not the v4/v5 version — modern mkfs enables ftype on v4. leaf → node → btree
handled in P5.
- P5 DONE — two parts, both Tier-2 validated (independent oracle, self-minted image):
- Part 1: bmap B+tree file read (di_format=3, read_btree_extents). The
inline xfs_bmdr_block root (fork: bb_level/bb_numrecs then keys[dmaxrecs]
+ ptrs[dmaxrecs] at the 4 + dmaxrecs*8 offset) → on-disk bmbt blocks (BMA3
v5, 72-byte CRC long-form header / BMAP v4, 24-byte header) → 16-byte
xfs_bmbt_rec leaves, walked in startoff order and fed to the shared
extent→file assemble_extents. Bounded (MAX_BMBT_LEVELS/MAX_BMBT_PTRS caps,
every block access bounds-checked). Layouts verbatim from xfs_format.h,
ptr-offset + header-size verified against xfs_db on a real 700-extent btree
file. Oracle: v5frag.img inode 131 — read_file sha256 ==
mount-ro b8fa13c1…; read_btree_extents == all 700 extents of xfs_db bmap.
- Part 2: leaf/node/btree directories. read_dir extended: multi-block
Extents dirs (size > blocksize) and Btree dirs walk their DATA extents
(logical offset below XFS_DIR2_LEAF_OFFSET = 1<<35), reading each XDD3
(v5) / XD2D (v4) multi-block data block (no block tail — entries fill the
block) via the shared entry walker. leaf/hash + freeindex index blocks are
skipped. Oracle: read_dir(leaf/, inode 655488) == mount-ro ls -i
(2000 {f0001..f2000 → inode}); read_by_path("/leaf/f0001") resolves.
- Sparse inodes remain deferred (past MVP).
- P6 — DONE. v5 CRC32c self-describing-metadata validation. On v5 (and ONLY
v5 — v4 has no CRCs) every metadata block carries a CRC32c
(Castagnoli/CRC_32_ISCSI) over the whole on-disk object with the 4-byte CRC
field treated as zero, stored little-endian. crc::verify_crc(buffer,
crc_offset) reproduces the kernel xfs_verify_cksum byte-exactly (scratch
copy, CRC field zeroed in place); crc::crc_status(is_v5, buffer, off) is the
v4→None / v5→Some seam. CRC verification is non-fatal — a bad CRC never
fails a parse; it surfaces as crc_valid: Option<bool> for the -forensic
layer (F3) to turn into a Finding. CRC offsets (VERBATIM from xfs_format.h
/ xfs_da_format.h, each offsetof(...)): SB sb_crc 224 · AGF agf_crc
216 · AGI agi_crc 312 (after unlinked[64]+uuid — NOT the naive early
offset) · AGFL agfl_crc 32 · inode v3 di_crc 100 · dir data/single-block
xfs_dir3_blk_hdr.crc 4 · dir leaf/node xfs_da3_blkinfo.crc 12 · bmbt
long-form bb_u.l.bb_crc 64. Coverage length = the object's whole buffer
(sector / inodesize / blocksize), matching the kernel BBTOB(bp->b_length).
Surfaced on Superblock/Inode (computed in parse — they self-describe
version), Agf/Agi (parse_verified(data, is_v5); plain parse leaves
None), Agfl (parse_v5 verifies, parse_v4 → None), and standalone
verify_dir_block_crc / verify_bmbt_block_crc (detect v5 vs v4 by magic).
Oracle (Tier-2; xfsprogs is the independent CRC author, but the image is self-minted): every unmodified
metadata block from v5.img / v5frag.img verifies Some(true) — SB, AG-0
AGF/AGI/AGFL, root inode 128, an XDD3 dir data block, a da3 leaf/node block,
a BMA3 bmbt block; a single flipped byte flips to Some(false); the v4
images report None. This completes xfs-core (P0–P6) — the reader parses
all file formats (inline/extents/btree) + all dir formats and validates v5
self-describing metadata.
xfs-forensic analyzer status — F1 + F3 DONE; F2 + F4 follow-on.
xfs-forensic (analyzer, over raw Read+Seek/bytes per the reader/analyzer-split
principle — the auditor must see slack the reader normalizes):
- F1 — DONE. Deleted-inode recovery. On delete XFS zeroes only mode/nlink/size/
nblocks/nextents; the extent records at inode offset 176 (v3) can survive, ctime
becomes deletion time, generation increments. recover_deleted sweeps the inode
space for freed (di_mode == 0) v3 inodes, decodes the residual xfs_bmbt_rec
records directly from the fork (di_nextents is zeroed, so it cannot be counted),
and carves the content via the extent reader → XFS-DELETED-INODE-CARVED.
Validated against xfs_db ground truth on the committed 512-byte freed-inode fixture
([startoff=0, startblock=32, blockcount=8], ctime, size).
Kernel-dependent residue caveat (Doer-Checker, verified 2026-07-15): residual
extent survival is not deterministic. Re-minting the delete case on the current
Ubuntu kernel zeroes the inode data fork on inactivation (measured: freed-with-fork
count 0, even pre-unmount), so no extents survive a clean delete there. The committed
fixture comes from the original image where they did survive; the env-gated
full-image carve-hash gate (XFS_DEL_ORACLE) therefore requires a residue-bearing
image and is not reproducible by re-mint on this kernel. Recovery is correct — it
carves residue when present and finds nothing when the fork is zeroed.
- F2 Directory-slack residue — short-form tail entries in inode slack; block-dir
freed dirent keeps its 32-bit inode number (inumber→0xFFFF but original often
readable) → recover deleted filenames + links. (follow-on)
- F3 — DONE. Structural integrity. audit_image parses the primary superblock
over its sector (correct CRC coverage), walks every AG that fits the image, and emits
XFS-CRC-MISMATCH (superblock / AGF / AGI / inode — the inode sweep gated on the
di_ino self-reference so a stray IN in file data cannot mis-flag),
XFS-SB-MIRROR-DIVERGENCE (secondary-SB geometry vs AG-0), XFS-ORPHANED-INODE
(AGI unlinked[64]), and XFS-IMPOSSIBLE-GEOMETRY (allocation-bomb guards). Clean
v5 image emits nothing; crafted corruption detected for each code. Directory-block
and bmbt-block CRC checking is deferred to F2.
- F4 Timestamp anomalies (bigtime-vs-classic mismatch, crtime>mtime) — Info leads
(mirror the timestomp-is-Info fleet stance). (follow-on)
Oracle tiering (corrected — self-minted ≠ Tier-1): our v5.img/v4.img are
REAL-self = Tier-2 — real mkfs.xfs output confirmed by independent oracles
(xfs_db + TSK + mount-ro/sha256), but we chose the scenario, so they can miss
real-world quirks. A non-ours oracle does not make a self-minted artifact
Tier-1; Tier-1 requires a third-party-authored or real-world image. So both
structure and content are validated at Tier-2 today, and deleted-file recovery
is also Tier-2 (self-minted delete cases). Open task: add a genuine Tier-1
XFS corpus (a real RHEL/Rocky/AlmaLinux/CentOS default-XFS image or a DFIR-challenge
image, or libyal libfsxfs third-party test data) and validate against it with
xfs_db/TSK as the independent oracle on their bytes.
Gaps to close before coding: (1) confirm xfuse's exact LICENSE; (2) pull the
verbatim bmbt bit-field widths from the kernel Data-extents chapter (not memory —
that bit split is the highest-risk number in the reader); (3) verify TSK's exact XFS
v5 feature coverage on the minted image before trusting it as tiebreaker on
bigtime/sparse-inode/reflink cases.