Skip to content

ZFS Forensic Reader — Research-First Report (zfs-core + zfs-forensic)

Read-only Research-First deliverable.

BLUF: ZFS is the hardest mainstream filesystem to parse read-only, by a wide margin. A minimum-viable pure-Rust reader that lists a directory and reads one file by path must traverse ~9 chained object layers (label → nvlist → uberblock → MOS → object directory → DSL → dataset objset → ZPL → dnode → blkptr → DVA → data), each with its own encoding, checksum verification, and compression. There is no production-grade Rust on-disk ZFS parser to reuse or use as an oracle — the one crate (rzfs_lib) is a yanked 0.0.0 placeholder, and TSK does not support ZFS at all. Multi-month build, realistically 3–5× an ext4 reader for the single-vdev MVP, 5–8× with RAIDZ/encryption. Recommendation: pure-Rust clean-room (fits forbid(unsafe), single static binary, no C toolchain/CDDL), scoped brutally, zdb as the oracle — do NOT try to match zfs import semantics in v1.

1. Authoritative spec

Primary (foundational, dated): "ZFS On-Disk Specification — Draft," Sun Microsystems, 2006 (v1). The canonical structural skeleton every ZFS reader is built from; predates lz4/zstd, encryption, large dnodes, the SA layer, feature flags, RAIDZ3, device removal. - Canonical mirror (maintained by Matt Ahrens, ZFS co-creator): https://github.com/ahrens/zfsondisk ("...the OpenZFS on-disk format is an extension of this"). Carries docs/zfs_internals.md. - PDF copy: https://www.giis.co.in/Zfs_ondiskformat.pdf (gives DVA offset math + uberblock-selection rule verbatim; Flate-compressed — extract locally before citing page numbers).

Living reference (authoritative post-2006) — OpenZFS headers are the de-facto spec: - include/sys/spa.hthe blkptr_t bit-by-bit layout (128 bytes; 3 DVAs; the compressed word BDX|lvl|type|etype|E|comp|PSIZE|LSIZE; embedded-blkptr variant; UBERBLOCK constants). This header is the blkptr spec. - include/sys/uberblock_impl.hUBERBLOCK_MAGIC 0x00bab10c, UBERBLOCK_SHIFT 10 (1 KiB slots), ub_txg, ub_rootbp, ub_checkpoint_txg, MMP (0xa11cea11). - include/sys/dnode.hdnode_phys_t (512-byte base; dn_blkptr[]/dn_bonus[]/ dn_spill; variable-length large dnodes in multiples of 512). - include/sys/dmu.h — DMU object types, objset_t, meta-dnode (object 0), bonus. - include/sys/vdev_impl.hVDEV_LABELS 4, VDEV_LABEL_START_SIZE, VDEV_LABEL_END_SIZE. - include/sys/zap_impl.h — micro-ZAP vs fat-ZAP (zap_phys_t, pointer table, zap_leaf_t). - include/sys/zfs_znode.h — znode / SA-based (z_is_sa) layout. - Also needed: sys/dsl_dir.h, sys/dsl_dataset.h, sys/sa_impl.h, sys/spa_impl.h, sys/zio_checksum.h/zio_compress.h, sys/zil.h.

License: OpenZFS headers are CDDL-1.0 (GPL-incompatible, file-level copyleft). Reference for a clean-room Rust reimplementation, not code to vendor. Apache-2.0 clean-room reader is fine; do not copy CDDL source.

Traversal chain to read /tank/ds1/foo.txt: vdev label → parse nvlist → select active uberblock (max valid ub_txg) → ub_rootbp → MOS meta-dnode → MOS object directory (ZAP) → DSL root dir → DSL dataset ds1 → dataset objset blkptr → ZPL master node (object 1) → ROOT dir dnode → root dir ZAP lookup foo.txt → file dnode → resolve blkptr tree (DVA + decompress + checksum) → file bytes. Every arrow crossing a blkptr also does DVA translation + checksum verify + decompression.

Structures (dependency order): 1. vdev label (vdev_label_t, 256 KiB, 4 copies: L0/L1 front, L2/L3 back). Layout: 8 KiB blank + 8 KiB boot header + 112 KiB packed nvlist config + 128-slot uberblock array (1 KiB each). Front labels precede a 3.5 MiB boot block. 2. nvlist — self-describing config (pool GUID, vdev tree, ashift). On-disk = XDR (big-endian). Its own mini-parser (typed name/value, nested, arrays). 3. uberblock array (128 slots) — active = highest ub_txg with valid checksum + ub_magic == 0x00bab10c; carries ub_rootbp → MOS. (Older valid uberblocks = the forensic point-in-time lever.) 4. blkptr_t — THE central 128-byte structure: up to 3 DVAs (vdev+offset), LSIZE/PSIZE/ASIZE, compression enum, checksum enum + 256-bit checksum, birth txg, lvl, DMU type, flags G(ang)/D(edup)/X(encryption)/E(mbedded). DVA→physical: physical_byte = (offset << 9) + 0x400000 (skips the 2 front labels + boot). lvl>0 = blkptr-tree arrays → recurse to lvl==0 data. 5. MOS — root objset via ub_rootbp; holds the object directory, DSL, space maps. 6. dnode (dnode_phys_t, 512-byte base or multiple) — dn_type, dn_nlevels/ dn_nblkptr, dn_blkptr[], bonus buffer (holds znode SA metadata). 7. DSL — object directory → root_dataset → DSL dir → head/child_dir ZAP; each dataset (dsl_dataset_phys_t in a dnode bonus) points at its objset blkptr + snapshots. 8. ZAPmicro-ZAP (fixed 64-byte mzap_ent_phys_t, name ≤50) vs fat-ZAP (zap_phys_t + pointer table + hash-chained zap_leaf_t). Object directory + every ZPL directory are ZAPs. 9. ZPL — dataset objset: object 1 = master node (ZAP naming ROOT, DELETE_QUEUE, VERSION, SA_ATTRS). Dirs = ZAPs (name→object#); files = dnodes → blkptr tree → data. znode metadata (mode/size/times) in the SA (System Attributes) registry inside the dnode bonus (modern) or legacy znode_phys_t.

csum: fletcher2/4 (fletcher default), sha256, skein/edonr/blake3 (only if used). compression: lzjb, lz4, gzip, zstd.

2. Existing implementations (build-vs-reuse)

Rust pure on-disk readers: effectively none usable. rzfs_lib (crates.io) = 0.0.0 YANKED placeholder; zfs (2016) abandoned/never functional; zfs-rs (clinta) = live ioctls to /dev/zfs; libzetta/libzfs/razor-rs/zfs-core-sys = FFI to a running system's libzfs (admin wrappers, irrelevant to dead-box); illumos-nvpair (Oxide) — the -sys is FFI to libnvpair; evaluate the pure-Rust one for nvlist XDR decode only, but prefer writing a small bounded XDR-nvlist parser ourselves.

Non-Rust references + oracles: - zdb — the canonical ZFS debugger, THE structural oracle: - zdb -l <device|image> — 4 vdev labels + uberblock array + config nvlist. - zdb -u <pool> — active uberblock (txg, rootbp). - zdb -dddd <pool>[/dataset] [objnum] — objsets/dnodes/ZAP/blkptrs (each extra d = more detail; 4×d dumps indirect blkptrs + bonus/SA). - zdb -mmm <pool> — metaslabs/space maps. - zdb -R <pool> <vdev>:<offset>:<size>[:flags]raw block read at a DVA (d = decompress) → byte-for-byte diff of our DVA math + decompressor. - zdb -bbbb/-vvv — traverse-all-blocks (validate full traversal). - OpenZFS libzpool — reference impl (what zdb/ztest link); could be FFI-bound as an alternative to pure-Rust for the hard codecs — but CDDL + C + large tree + clashes with forbid(unsafe)/single-static-binary → rejected for the core. - ZfsSharp (C#, github.com/AustinWise/ZfsSharp, CDDL) — the best-documented educational read-only reimplementation; its build-order write-up ("read the uberblock, then decompression + checksumming, then dnode + blkptr and everything falls out") is the single most useful build-order map — read before writing a line. - Hilgert et al. 2017, "Extending The Sleuth Kit … for pooled storage" (DFRWS, ScienceDirect S1742287617301901) — the ZFS-on-TSK extension: pls command, recovers deleted data by reconstructing old ZFS trees from older uberblocks, handles missing-disk (incomplete) pools zfs import refuses. The zfs-forensic design target. - TSK does NOT support ZFS — architectural (pooled storage breaks TSK's one-filesystem-per-volume model). Only the Hilgert research fork adds it.

Recommendation: build pure-Rust, clean-room from OpenZFS headers + Sun spec + ZfsSharp as a map; zdb = the oracle. Nothing to reuse (yanked stub / live-FFI / abandoned). Binding libzpool breaks the fleet's field-deployability promises. The risky codecs (fletcher4, sha256, lz4/lzjb/gzip/zstd) have mature pure-Rust crates — reuse those (sha2, lz4_flex, flate2, zstd; fletcher is trivial; lzjb is small + oracle-checkable via zdb -R). Defer RAIDZ, dedup, encryption entirely in v1.

3. Real sample data + oracle (Tier-1 plan)

# Parallels Ubuntu VM: sudo apt install zfsutils-linux ; zdb -V (record OpenZFS version)
truncate -s 512M /tmp/zfs-vdev.img
sudo zpool create -o ashift=12 -O compression=off -O encryption=off -O atime=off \
     -O checksum=on tank /tmp/zfs-vdev.img          # SINGLE vdev, no raidz/mirror
sudo zfs create tank/ds1
echo "hello zfs forensics" | sudo tee /tank/ds1/foo.txt
sudo mkdir -p /tank/ds1/sub && echo "nested" | sudo tee /tank/ds1/sub/bar.txt
sudo zfs snapshot tank/ds1@snap1
echo "post-snapshot change" | sudo tee -a /tank/ds1/foo.txt
sha256sum /tank/ds1/foo.txt /tank/ds1/sub/bar.txt > /tmp/zfs-ground-truth.sha256
zpool get all tank ; zfs get all tank/ds1          # record actual defaults
sudo zpool export tank
# oracles
zdb -l /tmp/zfs-vdev.img                    > /tmp/oracle-labels.txt
sudo zpool import -o readonly=on -N -d /tmp tank
zdb -u tank                                 > /tmp/oracle-uberblock.txt
zdb -dddd tank                              > /tmp/oracle-mos.txt
zdb -dddd tank/ds1                          > /tmp/oracle-ds1.txt
zdb -mmm tank                               > /tmp/oracle-metaslabs.txt
# zdb -R tank <dva> :d                       # byte-exact decompression oracle
sudo zpool export tank

Tiering: content Tier-1 (read-only import + sha256 vs OpenZFS-materialized bytes — genuine independent third party). structure Tier-2 (zdb output, real reference-tool, independently produced; zdb -R :d = byte-exact decompression oracle). Synthetic hand-built label bytes = Tier-3 (fast panic/robustness only, never the sole proof of a value path — blkptr/decompress/checksum). Env-gate the oracle tests. Corpora: rare; a TrueNAS/Proxmox VM disk (both default to ZFS root) = good real-world second sample; no NIST-CFReDS ZFS image.

4. Scope/difficulty — brutally honest + phased order

MVP scope (single-vdev, uncompressed OR lz4, unencrypted, non-RAIDZ): label → XDR nvlist → active uberblock → MOS → object directory → one head dataset → ZPL objset → master node → root ZAP → name lookup → file dnode blkptr tree → DVA + checksum + decompress → file bytes.

Difficulty ranking: 1. blkptr indirection + DVA math + per-block checksum + compression — the heart; every block access does all four. Indirect trees, (offset<<9)+0x400000, ditto-copy fallback across 3 DVAs, embedded-blkptrs, gang blocks, 256-bit checksum, decompress. One wrong bit in the packed lvl|type|comp|PSIZE|LSIZE word → garbage. LZNT1-trap zone — validate with zdb -R :d, never self-round-trip. 2. MOS → DSL → ZPL multi-objset traversal (three objset contexts + DSL indirection; off-by-one object# silently reads the wrong dnode). 3. fat-ZAP (hashed pointer-table + leaf-block sub-parser; directories of any size). 4. SA variable znode layout (mode/size/times as a registered variable attribute in the bonus; the SA registry is itself objects — the 2006 spec's fixed znode_phys_t is obsolete here). 5. nvlist XDR decode (bounded). 6. uberblock selection (simple, but "valid" = checksum verify → depends on 1). 7. Compression — reuse lz4_flex/flate2/zstd; lzjb = small clean-room, oracle via zdb -R. Checksums: fletcher2/4 ~30 lines each; sha2 for sha256. 8. Deferred in v1 (each a project): RAIDZ1/2/3, mirror/multi-vdev, dedup (DDT), native encryption (AES-CCM/GCM + key hierarchy), device removal/indirect vdevs, ZIL replay, feature-flag matrix.

Effort multiple: 9+ chained layers vs ext4's ~3, every block checksummed + compressed + CoW + indirection-tree'd, metadata stored as objects-in-objsets. 3–5× ext4 for the MVP file-read path, 5–8× with RAIDZ + snapshots + SA robustness. Plan for a multi-month build (≈2–4 months for a solid single-vdev MVP; RAIDZ/encryption → 6+). The single largest FS reader in the fleet plan.

Pure-Rust vs bind-libzpool: pure-Rust for zfs-core — the only single-static- binary, forbid-unsafe, no-C-toolchain, no-CDDL option; complexity bounded for the single-vdev/uncompressed-or-lz4 MVP; zdb gives byte-exact oracles. Reserve any libzpool FFI for a far-future RAIDZ/encryption module (explicitly marked, publish=false if ever) — not the core.

zfs-core phases: P0 codec primitives + oracle harness (fletcher2/4, sha256, lzjb, lz4, gzip, zstd; DVA translation — build FIRST, validate each vs zdb -R, per ZfsSharp) → P1 vdev label + XDR nvlist (oracle zdb -l) → P2 uberblock array + active selection (oracle zdb -u) → P3 blkptr + dnode + objset (all bitfields, indirect-tree, large dnodes; oracle zdb -dddd on MOS) → P4 MOS object directory + DSL → P5 ZPL + ZAP + SA → read file by path, match Tier-1 sha256 (closes MVP).

zfs-forensic (over zfs-core): uberblock-history/txg point-in-time (enumerate ALL valid uberblocks → reconstruct earlier txgs — the Hilgert technique, highest value); snapshot/clone analysis (DSL snapshot list; diff live vs @snap); deleted- dataset/file recovery (unreferenced-but-intact dnodes from old uberblocks; DSL delete queue); integrity findings (checksum mismatch, blkptr anomaly, txg-ordering, MMP/ checkpoint) → forensicnomicon::report::Finding (ZFS-UBERBLOCK-TXG-GAP, ZFS-BLKPTR-CKSUM-MISMATCH, ZFS-DATASET-ORPHAN); incomplete-pool handling (parse what's present when a vdev is missing — pools zfs import refuses).

Naming: Pattern A zfs-core + zfs-forensic. zfs on crates.io = dead 2016 stub (low-download, not popular) → publishing zfs-core with [lib] name = "zfs" for a clean import is likely available — verify the stub's deletable/transferable status before the 72h window.

Gaps: the Sun 2006 PDF is Flate-compressed (content confirmed via search, extract locally before citing pages); evaluate illumos-nvpair's pure-Rust XDR-label-nvlist decode hands-on before build-vs-reuse (recommendation still: write our own small XDR- nvlist parser); crates.io HTML search 404'd (use the JSON API with a UA).