fstool
Build, inspect, modify, and repack disk images and filesystem images.
In the spirit of genext2fs, but covering whole disks, multiple filesystems,
and round-tripping between formats — all from a TOML spec or directly from
the command line.
fstool ships as a Rust library (fstool) plus a thin CLI binary (fstool).
Public API is unstable until v0.5.
Filesystem support
| Filesystem | Read | Write | In-place edits | Notes |
|---|---|---|---|---|
| ext2 | ✅ | ✅ | ✅ | byte-exact with genext2fs on the same input |
| ext3 | ✅ | ✅ | ✅ | + JBD2 journal — real transactions on open_file_rw (Path A) |
| ext4 | ✅ | ✅ | ✅ | extents (read: any depth; write: depth ≤ 1), FILETYPE, metadata_csum, xattrs, JBD2 |
| FAT32 | ✅ | ✅ | ✅ | VFAT LFN entries, 8.3 short-name aliases |
| exFAT | ✅ | ✅ | ✅ | format + create + remove + flush + open_file_rw |
| tar | ✅ | ✅ | — | ustar + PAX, SCHILY.xattr.* for xattrs; streaming-only |
| XFS | ✅ | ✅ | ✅ | shortform + block / leaf / node + multi-level B-tree dirs + BMBT; leaf-form xattrs; real XLOG transactions (Path A); passes xfs_repair -n single + multi-AG |
| HFS+/HFSX | ✅ | ✅ | ✅ | inline + extents-overflow, symlinks, hard links; decmpfs read (zlib types 3 + 4); real journal (Path A); passes fsck.hfsplus |
| APFS | ✅ | ✅ | 🚧 | multi-level omap + fs-tree; spaceman with IP ring + SFQ free-queues; open_file_rw rebuilds a fresh COW checkpoint (whole-file overwrite only — no partial-extent COW yet); not yet fsck_apfs clean |
| NTFS | ✅ | ✅ | ✅ | MFT, attributes, $DATA + ADS, indexes; xattr map; multi-class $Secure ($SDS/$SDH/$SII); real $LogFile LFS records (Path A) |
| F2FS | ✅ | ✅ | ✅ | CP / NAT / dnodes / inline data + dentries; writer passes fsck.f2fs |
| SquashFS | ✅ | ✅ | — | gzip / xz / lz4 / zstd / lzo / lzma via Cargo features; writer round-trips via unsquashfs; repack-only |
| ISO 9660 | ✅ | ✅ | — | PVD + Joliet (UCS-2) + Rock Ridge (PX/NM/SL/TF) + El Torito boot catalog; repack-only |
| GRF | ✅ | ✅ | ✅ | Gravity Ragnarok Online archive — v0x102 / v0x103 / v0x200; permutation cipher (MIXCRYPT / DES); CP949 filenames |
| qcow2 | ✅ | ✅ | ✅ | v2 + v3 backend, allocate-on-write writer |
| dmg | ✅ | — | — | UDIF v4 trailer + mish chunks (zero / raw / zlib / ADC / bzip2 / LZFSE / LZMA); encrypted v2 (encrcdsa) read with PBKDF2 unlock |
🚧 marks writers / mutation paths with known gaps (see Limitations).
All writable filesystems — ext2/3/4, FAT32, exFAT, XFS, HFS+, NTFS,
APFS, F2FS, SquashFS, ISO 9660, GRF — implement a single
Filesystem trait, so the CLI (build, repack, add, rm) and
the TOML [filesystem] type = "…" spec dispatch through one
codepath; pick a target FS by setting --fs-type on repack or
type = "hfsplus" (etc.) in the TOML spec. "In-place edits"
means an already-flushed image can be re-opened for add / rm /
open_file_rw — for filesystems with a journal, that path commits
through a real transaction so a crash mid-write leaves an image the
host's fsck can replay.
The reader for each FS streams: file contents are never fully resident in memory regardless of size. The writers do the same, two-pass: scan to size the geometry, then stream bytes from each source file into the image.
NTFS metadata that has no POSIX analogue (DOS attributes, ADS, security
descriptors, NT-FILETIME timestamps, short names, reparse data) round-trips
through xattrs under user.ntfs.* and system.ntfs_security.
CLI commands
| Command | What it does |
|---|---|
create |
Build a bare image of any supported FS (-t ext4 / fat32 / xfs / hfs+ / ntfs / f2fs / squashfs / iso / apfs / exfat / grf) from a host directory tree. FS-specific knobs go through -O key=val,key=val. |
build |
Build from a TOML spec — bare FS or a partitioned disk image. |
info |
Print partition table (whole-disk) or FS summary + root listing. |
ls |
List a directory inside an image. |
cat |
Stream a file's bytes out of an image to stdout. |
add |
Copy a host file / tree into an existing image (any mutable FS). |
rm |
Unlink a file, symlink, device, or empty directory. |
shell |
SFTP-style REPL — ls cd pwd cat put rm mkdir info. |
convert |
Byte-level raw ↔ qcow2 conversion with optional grow. |
repack |
Walk one or more source FSes, merge bottom→top with whiteouts, rebuild into a fresh image. |
All commands accept partition-aware disk.img:N targets (1-indexed) — see
"Partitions, block devices, qcow2" below.
All inspection / modification commands accept a disk.img:N (1-indexed)
target to walk into a partition of a GPT or MBR disk image. fstool info disk.img without the suffix prints the partition table itself.
FS-specific options (-O)
Most filesystems expose tunables (block size, label, compression codec,
volume name, journaling on/off, etc.) through a generic -O key=value,key=value flag that is repeatable, modelled on mke2fs -O:
# 4 KiB blocks + custom label on ext4
# Pick a SquashFS codec and tighten the block size
# Force a v0x103 GRF with deflate level 9
Each backend's apply_options validates keys; unknown keys are rejected
with a clear error citing the FS type. The same options are available
through the TOML spec — see "[filesystem.options]" below.
Partitions, block devices, qcow2
- Partition tables — MBR (4 primaries) and GPT (128-entry, CRC32 on
header + entry array, primary + backup, protective MBR). Cross-checked
against
sgdisk -vandfdisk -l. - Block devices — on Unix, fstool can format and mutate real block
devices (
/dev/sdX,/dev/nvme0n1, loop devices). Capacity is queried via the kernel ioctl (BLKGETSIZE64on Linux,DKIOCGETBLOCK*on macOS) and open usesO_EXCLso the kernel refuses if any partition is mounted. Build commands require--forcewhen the output is a block device. - qcow2 —
Qcow2Backendreads QEMU v2 / v3 images and writes fresh v3 ones with allocate-on-write. Path-based factories (block::open_image,block::create_image) auto-dispatch by qcow2 magic or file extension, sofstool create -t ext4 src -o out.qcow2Just Works.
TOML spec
Declarative image descriptions — either a bare filesystem ([filesystem])
or a partitioned disk ([image] + [[partitions]]):
[]
= "64MiB"
= "gpt"
[[]]
= "EFI"
= "esp"
= "16MiB"
[[]]
= "root"
= "linux"
= "remaining"
[]
= "ext4"
= "./rootfs"
source — what to populate the FS with
source accepts three shapes, auto-detected by what the string points at:
[]
= "ext4"
= "./rootfs" # a host directory — walk it recursively
[]
= "ext4"
= "./rootfs.tar.gz" # a tar archive — repack entries into the FS
[]
= "ext4"
= "./old-disk.img:2" # an existing image, optional :N partition
# — walks the source FS, copies every
# entry into the new partition
Recognised tar extensions: .tar, .tar.gz, .tgz, .tar.xz, .txz,
.tar.zst, .tar.lz4, .tar.lzma, .tar.lzo (codecs gated on the
matching Cargo feature). For images, the :N suffix selects partition
N (1-indexed); without it, the source is opened as a bare filesystem.
The source FS may be any readable type — ext{2,3,4}, FAT32, exFAT,
XFS, HFS+, APFS, NTFS, F2FS, SquashFS, ISO 9660, tar, or GRF — and the
destination is sized automatically to fit unless size is set
explicitly.
[filesystem.options] — FS-specific tunables
The same -O key=val knobs the CLI exposes are available in TOML
through a free-form [filesystem.options] table:
[]
= "squashfs"
= "./rootfs"
[]
= "zstd"
= 131072
[]
= "ext4"
= "./rootfs"
[]
= 4096
= "ROOT"
Recognised keys are documented next to each backend's
FormatOpts::apply_options; unknown keys are rejected at spec parse
time with a clear error citing the FS type. The existing flat fields
(block_size, volume_label, mtime, …) continue to work for
backward compatibility.
Architecture
┌────────────────────────────────────────────┐
│ CLI (clap) — bin/fstool │
└────────────────────────────────────────────┘
│
┌────────────────────────────────────────────┐
│ Spec layer (TOML → ImageSpec / FsSpec) │
└────────────────────────────────────────────┘
│
┌────────────────────────────────────────────┐
│ Filesystem trait → ext, fat, xfs, ntfs, … │
└────────────────────────────────────────────┘
│
┌────────────────────────────────────────────┐
│ PartitionTable trait → Mbr, Gpt │
└────────────────────────────────────────────┘
│
┌────────────────────────────────────────────┐
│ BlockDevice trait → File, Mem, Sliced, │
│ Qcow2, Dmg │
└────────────────────────────────────────────┘
Each layer is substitutable. A filesystem implementation talks only to a
BlockDevice; it doesn't know or care whether the device is a real file,
an in-memory buffer in a test, a slice carved out of a larger disk by a
partition table, or a qcow2-backed sparse container. DMG (.dmg) is
treated the same way: open the image, walk the mish table for the
chunk layout, and the rest of the stack reads through it as if it were
a flat block device — including the encrypted (encrcdsa v2) variant
when an unlock password is supplied.
ext-specific niceties
BuildPlanauto-sizes a filesystem to fit a source tree exactly (genext2fs-style "size to fit").Ext::populate_rootdevsdrops aMinimalorStandard/dev/*tree (console, null, zero, ptmx, tty, fuse, random, urandom — plus tty0..15, ttyS0..3, kmsg, mem, port, hda..hdd, sda..sdd + partitions forStandard), so a non-root user can build a Linux root FS withoutCAP_MKNOD.- xattrs round-trip through repack: both inline (extended-inode-body) and
external
file_acl-block sources are read; the destination writes to an external block with a correctly-computed CRC32C whenmetadata_csumis on.debugfs ea_getconfirms identical values after repack.
Cross-FS repack
fstool repack walks the source filesystem and rebuilds the tree into a
fresh image. With --fs-type it changes filesystem on the fly; --shrink
auto-sizes the output to the minimum that fits the content. The ext → ext
path uses a direct FS-to-FS copier (no host-filesystem intermediation),
preserving symlinks, device nodes, mode, uid/gid, and xattrs. tar in either
direction round-trips content + mode + uid/gid + mtime + symlinks + device
nodes + xattrs.
fstool repack writes any destination implementing the Filesystem
trait — ext2/3/4, FAT32, exFAT, tar, XFS, HFS+, APFS, NTFS, F2FS,
SquashFS, ISO 9660, GRF. add / rm go through the same trait,
which means they work on any FS whose writer can re-open an existing
image; today that's all of the mutable backends — ext, FAT32, exFAT,
F2FS, XFS, HFS+, NTFS, APFS, and GRF. SquashFS, ISO 9660, and tar
are repack-only (their MutationCapability is Immutable or
Streaming, so add / rm fail fast with an actionable error and
the user is steered to repack).
Layered merge with whiteouts
repack takes one or more source positional arguments followed by the
destination. With one source it behaves as before; with two or more
it merges the sources bottom→top before writing — later layers
override files of the same path, and tombstones from the upper
layer remove paths from the lower one. Two tombstone conventions are
auto-detected:
| Convention | Marker | Effect |
|---|---|---|
| tar-OCI | .wh.<name> in directory D |
delete D/<name> |
| tar-OCI | .wh..wh..opq in directory D |
drop all lower-layer children of D before this layer's own land |
| OverlayFS | character device with major=0, minor=0 | delete this path |
| OverlayFS | xattr trusted.overlay.opaque = "y" on a dir |
opaque-dir semantics on that dir |
The tombstones themselves never appear in the output. Sources may be host directories, tar archives (compressed or plain), or filesystem images — any mix works.
# OCI-style: rebuild a stack of layers into a flat tar
# Patch an ISO with a tar of replacement files
# Shell globs work — last positional is the destination
Internally the merge folds all layers into a single uncompressed tar held in a tempfile, then drives the existing single-source repack pipeline; the destination FS doesn't know it came from multiple sources.
ISO 9660
ISO 9660 reads cover the bare ECMA-119 layout plus three of the four common extensions:
- Joliet (Microsoft) — UCS-2 BE long names via the supplementary volume descriptor.
- Rock Ridge (IEEE P1282) — POSIX mode + uid + gid via
PX, long names viaNM, symlinks viaSL, timestamps viaTF. Continuation areas (CE) are followed across sector boundaries. - El Torito — boot catalog: validation entry, default entry, and
section headers (
0x90/0x91); the parsed catalog is surfaced infstool info.
The writer is repack-only — ISO is sequential and a single flush()
writes the whole image. It emits a PVD plus optional Joliet SVD,
both L-type and M-type path tables, dual directory record trees (one
for PVD, one for Joliet), and Rock Ridge System Use Areas (NM /
PX / SL) attached to the PVD records. The output round-trips
through isoinfo -lR and back through fstool's own reader.
# Build an ISO from a host directory
# Walk an existing ISO
# Round-trip ISO → tar → ISO
Compression
fstool ships with six compression codecs enabled by default. Each has
its own Cargo feature flag so you can trim the binary down:
| Codec | Feature | Used for |
|---|---|---|
| gzip | gzip |
SquashFS, .tar.gz / .tgz |
| xz | xz |
SquashFS, .tar.xz / .txz |
| lzma | lzma |
SquashFS, .tar.lzma |
| lz4 | lz4 |
SquashFS, .tar.lz4 |
| zstd | zstd |
SquashFS, .tar.zst |
| lzo | lzo |
SquashFS, .tar.lzo |
Compressed tar input / output is detected by filename extension (or by
magic for inputs without a recognisable extension): fstool ls disk.tar.zst / and fstool repack ext.img out.tar.gz Just Work.
Internally the codec is streamed through a temp file so the whole
archive is never resident in RAM.
To disable a codec at build time, e.g. to avoid the bundled C zstd
build on a constrained system:
Limitations
Things explicitly out of scope today, in rough order of likely-to-change:
- ext4 write path: extent trees deeper than depth 1 (the reader
handles arbitrary depth);
flex_bgon the write path (reader is fine). - APFS in-place edits:
open_file_rwrebuilds a fresh COW checkpoint over the entire file content, so it's whole-file granularity — partial-extent COW is not yet implemented, andcreate_file/removeover the rw path piggyback on the same checkpoint. Multiple back-to-back commits are bounded by thexp_descring (the reader doesn't rotate it yet). - APFS reader: snapshots, encryption, and sealed-volume integrity are out of scope.
- APFS / NTFS strict-checker pass: the spaceman +
$Secure/$LogFilestructures are now populated, butfsck_apfsandntfs-3gmount can still flag the images for finer points (free-queue B-trees, journal metadata layout). Read + write work end-to-end; the host-tool gate is the remaining polish. - NTFS reader: compressed and encrypted
$DATA,$ATTRIBUTE_LISTspill, and security-descriptor indirection through$Securebeyond what the resident path handles all returnUnsupported. - XFS reader: B-tree-format (
di_format=BTREE) directories deeper than one level above the leaves returnError::Unsupported(shortform / block / leaf / node and single-level B-tree dirs are covered); writer assumes shortform / extent dirs. Node-form (multi-leaf dabtree) xattrs are read-only. - HFS+ decmpfs: type 3 (zlib inline) + type 4 (zlib resource
fork) work. LZVN (types 7/8) and LZFSE (types 11/12) return
Unsupported. - DMG: read-only — no DMG writer /
convertpath. Encrypted v1 (cdsaencrlegacy 3DES) chunks returnUnsupported; v2 is covered. - Partial-file rewrites on the trait surface —
open_file_rwexists everywhere it's safe, but a typed "patch this byte range on a known-large file" API is not surfaced beyondRead + Write + Seekon the handle.
Try it
&&
Run the test suite:
CI runs the full suite on Linux (with apt-installed e2fsprogs,
dosfstools, mtools, gdisk, qemu-utils for cross-validation) plus a
build + test pass on macOS (Homebrew qemu) and Windows.
Licence
MIT. Copyright © 2026 Karpelès Lab Inc. See LICENSE.