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 | Notes |
|---|---|---|---|
| ext2 | ✅ | ✅ | byte-exact with genext2fs on the same input |
| ext3 | ✅ | ✅ | + JBD2 journal |
| ext4 | ✅ | ✅ | extents, FILETYPE, metadata_csum, xattrs |
| FAT32 | ✅ | ✅ | VFAT LFN entries, 8.3 short-name aliases |
| exFAT | ✅ | ✅ | format + create + remove + flush |
| tar | ✅ | ✅ | ustar + PAX, SCHILY.xattr.* for xattrs |
| XFS | ✅ | ✅ | shortform + block / leaf / node dirs + BMBT; writer passes xfs_repair -n single + multi-AG; B-tree dirs deferred |
| HFS+/HFSX | ✅ | ✅ | inline + extents-overflow, symlinks, hard links; writer passes fsck.hfsplus with optional journal stub |
| APFS | ✅ | 🚧 | multi-level omap + fs-tree; writer is single-volume + stub spaceman (no snapshots / encryption); not yet fsck_apfs clean |
| NTFS | ✅ | 🚧 | MFT, attributes, $DATA + ADS, indexes; xattr map; writer passes ntfsfix --no-action but isn't ntfs-3g-mountable yet (system files in root $I30 pending) |
| 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 |
| qcow2 | ✅ | ✅ | v2 + v3, allocate-on-write writer |
| dmg | 🚧 | — | UDIF v4 trailer parsed; chunk decoder TBD |
🚧 marks writers that exist at the library level but have known gaps
(see Limitations). The high-level CLI only writes ext2/3/4, FAT32, and
tar today — the other writers are reachable via the library API.
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 |
|---|---|
build |
Build from a TOML spec — bare FS or a partitioned disk image. |
ext-build |
Bare ext2 / ext3 / ext4 image from a host directory tree. |
fat-build |
Bare FAT32 image from a host directory tree. |
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 (ext or FAT). |
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 source FS, rebuild into a fresh image, optionally a different FS. |
fstool … |
Plus ext-build, fat-build, partition-aware disk.img:N targets. |
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.
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 ext-build 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, or tar
on the inside of an image — and the destination is sized automatically
to fit unless size is set explicitly.
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 │
└────────────────────────────────────────────┘
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.
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 ext2/3/4, FAT32, and tar destinations today;
the XFS / HFS+ / APFS / NTFS / F2FS / SquashFS writers exist as a
library API (see the support table) but aren't wired into repack /
add / rm yet — call them directly through the crate for now.
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:
- High-level CLI (
build,add,rm,repack) only writes ext2/3/4, FAT32, and tar. The library writers for XFS / HFS+ / APFS / NTFS / F2FS / SquashFS work, just not via the CLI. - NTFS writer: produced image isn't
ntfs-3g-mountable — root$I30doesn't index the system files yet;ntfsfix --no-actionis clean. - NTFS reader: compressed and encrypted
$DATA,$ATTRIBUTE_LISTspill, and$Securesecurity-descriptor indirection beyond what the resident path handles all returnUnsupported. - APFS writer: single volume, stub space-manager →
fsck_apfsflags the spaceman;mount_apfstypically refuses the image. - APFS reader: snapshots, encryption, and sealed-volume integrity are out of scope.
- XFS reader: B-tree-format directories (block / leaf / node formats are covered); writer assumes shortform / extent dirs.
- ext4
flex_bgon the write path (the reader handles it). - Partial-file rewrites — in-place modification is whole-file granularity.
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.