ferrosys
ferrosys builds and reads ext2, ext3, and ext4 filesystem images entirely in userspace,
over ordinary byte streams, in safe Rust (#![forbid(unsafe_code)]). It is self-contained,
pure Rust and runs anywhere Rust runs.
A formatter writes an image from a description of its contents — directories,
files, symlinks, hard links, device / FIFO / socket nodes, extended attributes,
and POSIX ACLs, each with its ownership, modes, and access / change / modification
times — and a reader parses an image back. The contents come from a programmatic
builder, from a tar archive and its PAX metadata (the tar feature), or from a
directory tree on the machine doing the building (the dir feature). The
image carries real superblock and descriptor backups and reserved descriptor
blocks sized to a grow target — a maximum the caller names, or every block the
format can reserve when the caller names none — so the filesystem grows in place
without relocating its descriptor table, and every metadata object carries a
crc32c (metadata_csum) so a checker detects corruption of the filesystem’s own
structures. A format-time jbd2 journal (has_journal), sized from the filesystem,
is written into the journal inode so the kernel journals from the first mount, and
the inodes awaiting deletion are recorded in an orphan file (orphan_file). Files
map their blocks with extent trees of any depth, and a directory that outgrows one
block gains a hash index (dir_index).
It is at once:
- a Rust library you link and call in process;
- rootless and kernel-free — it works the image as ordinary data and runs unprivileged;
- cross-platform — pure Rust, so it builds and reads ext2/3/4 images on Linux, macOS, Windows, and the BSDs;
- deterministic — the default output is byte-reproducible: the filesystem UUID and timestamps are inputs the caller supplies, and names hash the same way whatever machine builds the image;
- resize-safe in the on-disk geometry it writes;
- unbounded by memory —
format_tostreams an image to a seekable destination, writing only the blocks the filesystem uses, and a read windows its way through a file rather than holding it.
This guide
- Safe by construction, Deterministic output, Rootless and cross-platform, and Resize-safe geometry describe the guarantees the crate is built around.
- Installation shows how to add the crate to a project, and Formatting and reading images walks through the API.
- The API reference documents every public item.
Safe by construction
ferrosys forbids unsafe at the crate root:
#![forbid(unsafe_code)]
forbid — not deny — means the prohibition cannot be locally overridden.
There is no unsafe block anywhere in the crate.
On-disk types serialize through explicit byte accessors
ext4 stores its structures little-endian on disk, regardless of the host’s byte order. Every on-disk structure is read and written field by field through explicit little-endian byte accessors, so the byte layout is spelled out at every field. The result is portable across architectures — an image built on a big-endian host is byte-identical to one built on a little-endian host.
Deterministic output
The default materialization is byte-reproducible: the same source and the same inputs produce a byte-identical image on any machine.
Every value a filesystem would normally draw from its environment is an input instead. The two such values are:
- the filesystem UUID is a 16-byte value supplied by the caller;
- timestamps are supplied by the caller.
A caller that wants clock- or random-derived values computes them and passes them in explicitly, keeping that choice out of the default path.
Inode-number assignment is a fixed function of the source, in sorted path order, so two runs over the same source assign identical inode numbers — and therefore write identical bytes. Anything that reaches on-disk order (directory entries, block groups) is sorted or otherwise fixed into a determinate order.
Rootless and cross-platform
ferrosys treats an ext2, ext3, or ext4 image as ordinary data: it builds and
parses the bytes directly, in the calling process, over any byte stream. That one
property is what makes it rootless and portable:
- In userspace — the image is constructed and parsed as a value in memory or a stream on disk, worked entirely within the process; the bytes are the whole interface.
- Unprivileged — working ordinary bytes needs an ordinary user account.
- Self-contained — pure Rust that links no system library, so it builds and ships as a single Rust dependency.
Because it depends on nothing kernel-specific, it builds and reads ext images on Linux, macOS, Windows, and the BSDs alike — and works the same wherever mounting a filesystem is not an option.
The one exception is the dir feature, and it is an exception by nature rather than by
omission: walking a host tree means reading that host’s inode metadata and extended
attributes, which is the one thing a portable byte-level format cannot abstract over. It
is present on Linux. Everything else — the formatter, the reader, the scan, the archive
source and sink — is the same code everywhere.
Rootlessness has a consequence worth stating for the directory walk in particular. An
unprivileged build cannot own the files it stages as root, so the tree it walks records
the building user’s ids; DirectorySource::owner (and format --owner 0:0 on the command
line) is what puts the intended ownership into the image without any privilege at all.
Resize-safe geometry
An ext4 filesystem grows by adding block groups, and each new group needs a slot in the group-descriptor table. If no room was set aside for that table to grow, the kernel converts the filesystem to a distributed descriptor layout the first time it is enlarged — an in-place conversion that can corrupt the filesystem.
ferrosys writes a geometry that never needs that conversion. Two
structures make growth safe, and both are decided when the layout is planned,
before any byte is written:
Reserved descriptor blocks
The formatter takes a maximum grow target — the largest size the image may ever occupy — and reserves exactly enough group-descriptor-table blocks to describe a filesystem that large. The reservation is tracked through the resize inode, whose double-indirect map points at the reserved blocks in the primary group and every backup. Growing the filesystem, up to that target, consumes the reserved blocks in place; the descriptor table never has to move.
The target is named, not derived from the image’s size by a fixed multiplier. A caller who knows the device the image will be flashed to names it; a caller who names none gets the format maximum — as much headroom as the resize inode can address — so an image built without a target still grows onto any device up to that ceiling. A target larger than the reservation can represent is rejected when the layout is planned, rather than written as a filesystem that would need the corrupting conversion to grow.
Superblock and descriptor backups
The superblock and the group-descriptor table are copied into backup groups
following the sparse_super rule — group 1, and the groups that are powers of 3,
5, and 7. A checker can recover the filesystem from a backup if the primary copy
is damaged, and the backups are placed so that growth does not disturb them.
Flex block groups
The block bitmaps, inode bitmaps, and inode tables of the groups in a flex block group are packed together in the flex group’s first group, leaving the remaining groups as contiguous data space. The packing is computed to the byte, including the partial final group and the table slots a single-group flex block group reserves for later growth.
Together these give the property the crate is built around: an image that a kernel can grow, in place and online, up to its declared maximum, and that a checker accepts clean at every size along the way.
Installation
Both crates build on Rust 1.88 or newer.
The library
ferrosys is a Rust library. Add it to a project with:
cargo add ferrosys
or by editing Cargo.toml:
[dependencies]
ferrosys = "0.2"
The build is pure Rust and depends only on thiserror. Four features shape it:
| Feature | Default | What it adds | What it depends on |
|---|---|---|---|
ext | on | The whole filesystem surface, under the ext module: the formatter, the reader, the feature model, and the on-disk structures | — |
tar | off | ArchiveSource and ArchiveSink: a filesystem built from a tar stream, and one written back out as one, with PAX times, SCHILY.xattr.* attributes, and SCHILY.acl.* records | tar |
dir | off | DirectorySource: a filesystem built from a directory tree on this machine, with its modes, ownership, times, hard links, special files, and extended attributes. Built on Linux, whose metadata and extended attributes the walk reads | rustix |
serde | off | Serialize on the scan taxonomy, the planned geometry, and the feature model, for embedding them in a document of your own | serde |
default-features = false is a real build, not a smaller version of the same one: it
leaves the family-agnostic substrate the crate root carries — the crc32c primitive and
detect, which says which filesystem an image holds — and no formatter, reader, or
FeatureSet. It is the build for a consumer that classifies images without reading them.
Each of the three optional features enables ext, since each is a way of describing or
emitting an ext filesystem.
[dependencies]
ferrosys = { version = "0.2", features = ["tar", "dir"] }
The API reference documents the public surface.
The command line
The ferrosys binary writes, inspects, and reads back filesystems without
writing any Rust. Install it from the registry:
cargo install ferrosys-cli
or from a checkout of the workspace:
cargo install --path crates/ferrosys-cli
It writes to a regular file, takes its identifiers and timestamps as inputs, and
exits as e2fsck does. The command-line chapter covers it.
Formatting and reading images
ferrosys has two halves: a formatter that writes an ext2, ext3, or ext4 image
from a description of its contents, and a reader that parses an image back into
inodes, directories, and file data.
Describing the contents
A TreeBuilder collects the entries to place in the filesystem — directories,
files, symlinks, hard links, device / FIFO / socket nodes, and their extended
attributes — each with its ownership, mode, and times. The root directory and
/lost+found always exist and are not added:
extern crate ferrosys;
use ferrosys::ext::ondisk::Timestamp;
use ferrosys::ext::{Metadata, TreeBuilder};
let time = Timestamp::from_secs(1_700_000_000);
let source = TreeBuilder::new()
.directory(b"/etc".to_vec(), Metadata::new(0o755, time))
.file(b"/etc/hostname".to_vec(), b"ferrosys\n".to_vec(), Metadata::new(0o644, time))
.symlink(b"/etc/mtab".to_vec(), b"/proc/self/mounts".to_vec(), Metadata::new(0o777, time))
.directory(b"/bin".to_vec(), Metadata::new(0o755, time))
.file(b"/bin/busybox".to_vec(), vec![0x7f, b'E', b'L', b'F'], Metadata::new(0o755, time))
.hardlink(b"/bin/sh".to_vec(), b"/bin/busybox".to_vec(), Metadata::new(0o755, time));
let _ = source;
Order of addition does not matter — inode numbers are assigned in sorted path order — but every parent directory must be present somewhere in the source. An input the format cannot represent, such as a name over 255 bytes or a hard link to a directory, is a typed error rather than a silently dropped entry.
The builder also places device, FIFO, and socket nodes and attaches extended
attributes and POSIX ACLs. xattr applies to the entry added just before it, and
an ACL is encoded and attached under its system.posix_acl_* name:
extern crate ferrosys;
use ferrosys::ext::acl::{EXEC, READ, WRITE};
use ferrosys::ext::ondisk::Timestamp;
use ferrosys::ext::{Acl, AclEntry, AclQualifier, Metadata, TreeBuilder};
let time = Timestamp::from_secs(1_700_000_000);
let acl = Acl::new(vec![
AclEntry { who: AclQualifier::UserObj, perm: READ | WRITE | EXEC },
AclEntry { who: AclQualifier::GroupObj, perm: READ | EXEC },
AclEntry { who: AclQualifier::Other, perm: READ },
])
.unwrap();
let source = TreeBuilder::new()
.directory(b"/dev".to_vec(), Metadata::new(0o755, time))
.char_device(b"/dev/null".to_vec(), 1, 3, Metadata::new(0o666, time))
.file(b"/ping".to_vec(), b"ELF".to_vec(), Metadata::new(0o755, time))
.xattr(b"security.capability".to_vec(), vec![0u8; 20])
.directory(b"/srv".to_vec(), Metadata::new(0o755, time))
.xattr(Acl::ACCESS_NAME.to_vec(), acl.encode());
let _ = source;
Each entry’s access, change, and modification times default to the one timestamp
passed to Metadata::new; Metadata::with_times sets them independently. A
fixed-time option on the format call overrides every entry’s times for
byte-reproducible output regardless of the source.
With the tar feature enabled, an ArchiveSource parses a tar archive — its PAX
timestamps, SCHILY.xattr.* attributes, and SCHILY.acl.* ACL records — into the
same entries a TreeBuilder produces, so the rest of the pipeline is identical.
It has two constructors, and they differ only in where a regular file’s contents
live. ArchiveSource::from_reader takes any stream and reads every body into memory.
ArchiveSource::from_path opens the archive itself, records where each body lies, and
reads it only when that file is placed — so a format needs the largest single member
rather than the sum of them all. Both write byte-identical images. The handles keep the
archive open, so it must not be modified in place until the format finishes; replacing
it by writing a new file and renaming it over the old one is safe, because the original
inode stays readable.
With the dir feature enabled, a DirectorySource walks a directory tree on this machine
into the same entries. The directory it is pointed at becomes the filesystem root, and
everything under it keeps its path relative to that: modes, ownership, all three times to
the nanosecond, symlinks (recorded, never followed), hard links, device, FIFO and socket
nodes, and extended attributes with their POSIX ACLs, which are translated from the
version-2 form the syscall boundary speaks into the compact form ext stores.
The metadata and the extended attributes the walk reads are Linux’s, so DirectorySource
is built on Linux; on another platform the feature compiles and the type is absent, and
ArchiveSource is the portable way to describe a tree. Everything else the crate
does — planning, writing, reading, and scanning — is the same everywhere.
extern crate ferrosys;
use ferrosys::ext::{DirectorySource, FormatOptions, format_to};
// A build that does not run as root records its own uid on every file it walks, so the
// override is what makes the image root-owned.
let source = DirectorySource::from_path("staging/rootfs")?.owner(0, 0);
let out = std::fs::File::create("rootfs.img")?;
format_to(source, 512 << 20, options, out)?;
The walk sorts its entries by path and its attributes by name, and where several names share an inode the first in that sorted order carries the file while the rest become hard links to it — so the same tree walks to the same entry list whatever order the host listed its directories in. Each file’s bytes are read as that file is placed and no descriptor is held in between, so a tree may hold any number of files and the peak memory is the largest single one.
The times on those entries are the host’s. A walk reads every directory and every symlink
to learn what it holds, and a host that maintains access times records that read, so the
access time an entry carries is the one the host held when the walk reached it. A tree
read from a noatime mount walks to the same bytes every time; where the host moves those
times and the bytes must not move with them, set the times on the entries a source of your
own yields.
A file’s contents are a FileContent: either Owned bytes or a Range of a host file.
Both coexist in one entry list, which is what lets a caller take an archive-backed list
and replace one entry’s contents with bytes it computed while every other entry stays on
disk. TreeBuilder::file takes anything that converts into one — a Vec<u8>, a String,
a borrowed &[u8], &[u8; N], or &str, each copied into the entry, or a FileRange,
which names host bytes and reads them when the file is placed. FileContent::read hands
back a Cow, so owned bytes are borrowed rather than copied and a format never holds two
copies of one file.
A FileRange comes in two forms. FileRange::new carries an open descriptor, shared, so
a hundred ranges into one archive cost one descriptor; FileRange::at_path carries the
path alone and opens it for each read, which is what lets a source name a range in each of
a hundred thousand separate files. Either way the bytes are read when the file is placed,
so the file must not be modified in place before the format finishes.
Formatting
format takes the source, the image size, and the identity and grow inputs in
FormatOptions. The maximum grow target sizes the reserved
group-descriptor-table blocks; it is the largest size the image may later occupy:
extern crate ferrosys;
use ferrosys::ext::ondisk::Timestamp;
use ferrosys::ext::{format, FormatOptions, GrowReservation, Metadata, TreeBuilder};
let time = Timestamp::from_secs(1_700_000_000);
let source = TreeBuilder::new()
.file(b"/etc/hostname".to_vec(), b"ferrosys\n".to_vec(), Metadata::new(0o644, time))
.directory(b"/etc".to_vec(), Metadata::new(0o755, time));
let uuid = [0x11; 16];
let hash_seed = [0u8; 16];
let mut options = FormatOptions::new(uuid, time, hash_seed);
// Reserve descriptor blocks to grow online up to 32 GiB.
options.grow = GrowReservation::UpTo(32 << 30);
let image = format(source, 64 << 20, options).expect("format a 64 MiB image");
assert_eq!(image.as_bytes().len(), 64 << 20);
The returned Image exposes the bytes directly (as_bytes, into_bytes) or
streams them to any writer (write_to) — for instance
image.write_to(std::fs::File::create("rootfs.img")?).
FormatOptions also carries the block size (through its feature set: 1024, 2048,
or 4096 bytes), the journal size, and how directory names hash — the algorithm and
whether their bytes are read as signed or unsigned. The image records both, so a
reader reproduces a name’s hash from the image rather than from its own host.
The feature set is the source of truth for which family an image is, and a
Profile is the two-way lens over it. FormatOptions::profile(Profile::Ext2)
seeds the whole set from the ext2, ext3, or ext4 baseline — the words mke2fs -t
writes — and is chainable from new; set individual features on feature
afterward to depart from it. Profile::of(feature) names the family a set
classifies to, which is what Reader::profile reports for an image on the way
back in.
A feature word is a promise about the structures the filesystem carries, so the words and
the bytes written under them have to agree. FeatureSet::validate refuses a set that
contradicts itself before any planning happens — metadata_csum_seed without the
checksums it seeds, orphan_file without the journal its entries are written through,
resize_inode at a 4096-byte block without large_file, since the resize inode is itself
a file of 4 GiB. A source the set cannot describe is refused the same way and names the
entry: extended attributes without ext_attr, a regular file of LARGE_FILE_MIN_SIZE or
more without large_file. Nothing is silently dropped and no feature is silently added.
Pinning the bytes across versions
Images are byte-reproducible: the UUID, the timestamps, and the hash seed are inputs, so the same source and the same options write the same bytes, always. That holds across versions of this crate too — but only for a feature set that is itself fixed.
FeatureSet::DEFAULT, EXT2, and EXT3 are fixed and stay fixed, so pinning to one of
them pins the layout. FeatureSet::LATEST deliberately does not: it tracks what a
current mke2fs writes for ext4, so it may gain a feature in any release and the bytes
under it may change with it. Name LATEST when parity with the current tool is what you
want, and DEFAULT when reproducible bytes are.
To record exactly what a build resolved to, FeatureSet::pin emits the whole set as one
canonical document — every feature word twice over, as exact bits and as readable names,
plus the block and inode sizes that a feature-name list would omit:
ferrosys-feature-pin 1
compat 0x0000103c has_journal ext_attr resize_inode dir_index orphan_file
incompat 0x000022c2 filetype extent 64bit flex_bg metadata_csum_seed
ro_compat 0x0000046b sparse_super large_file huge_file dir_nlink extra_isize metadata_csum
block_size 4096
inode_size 256
Record it verbatim and compare it string for string on the next build: a difference is
drift in the on-disk layout, surfaced as a diff a person reads rather than as changed
image bytes nobody notices. FeatureSet::EMPTY is the base to replay a recorded list of
feature names back through with_feature, which is how the readable half of a pin is
checked against the exact half.
Deciding everything before the destination is touched
A format writes only the blocks the filesystem uses, so every byte of the destination it does not write must already read as zero — which means creating the file, or truncating one that is already there, is part of formatting rather than something done beforehand. A run that then failed would have destroyed what was at that path for nothing.
FormatPlan is the fallible half of a format as a value, and it is what makes that
impossible. FormatPlan::new takes the source, the size, and the options and does every
piece of work that can fail — parsing the source, planning the geometry, building the
inode model and checking it against that geometry, sizing the journal. What it returns can
only be written:
extern crate ferrosys;
use ferrosys::ext::FormatPlan;
// Nothing has been opened yet; a failure here leaves whatever is at the path untouched.
let plan = FormatPlan::new(source, 512 << 20, options)?;
println!("{} blocks, {} inodes used", plan.layout().total_blocks, plan.used_inodes());
let out = std::fs::File::create("rootfs.img")?;
let layout = plan.write_to(out)?;
format and format_to both route through it, so there is one derivation of a layout
rather than two. layout() and used_inodes() report what the write will realize before
a byte is written, which is what a caller reporting a geometry, or deciding whether to
write at all, needs.
Three more fields tune what the size alone would decide, each defaulting to what the
size implies. volume_name labels the filesystem, up to sixteen bytes NUL-padded into
s_volume_name. inodes (an InodeCount) sets how many inodes it holds — a
bytes-per-inode density or an exact count — overriding the size-driven default; a
density past what a group’s bitmap indexes is refused rather than reduced. reserved
(a ReservedRatio) sets the share of blocks held back for the super-user, in exact
hundredths of a percent, defaulting to 5%.
Streaming a large image
format builds the whole image in memory. format_to instead writes it to any
seekable destination, touching only the blocks the filesystem uses, so the
destination stays sparse and the image never exists in memory at once. It returns
the Layout the bytes realize:
extern crate ferrosys;
use ferrosys::ext::ondisk::Timestamp;
use ferrosys::ext::{format_to, FormatOptions, GrowReservation, TreeBuilder};
let time = Timestamp::from_secs(1_700_000_000);
let size = 512u64 << 30; // 512 GiB
let mut options = FormatOptions::new([0x11; 16], time, [0u8; 16]);
options.grow = GrowReservation::UpTo(size);
let file = std::fs::File::create("big.img").unwrap();
let layout = format_to(TreeBuilder::new(), size, options, file).unwrap();
assert_eq!(layout.total_blocks, size / u64::from(layout.block_size));
Every byte of the destination that is not written must read back as zero, which a freshly created file satisfies. Block numbers past 2^32 are written where the size needs them, so a filesystem beyond 16 TiB addresses its blocks correctly.
Three things are held while the bytes stream out, and none of them is the image:
- The entry list, and the inode model built from it, for the whole run. This grows with the number of entries, not with their size.
- A file’s contents, while it is placed. How long that is depends on the source: an
entry holding
FileContent::Ownedbytes holds them from the moment the source was built, so a list of them costs the sum of every file, while aFileContent::Rangeis read at placement and dropped after, so a list of them costs the largest single file.ArchiveSource::from_pathis what makes that difference for a tar source. - The allocator’s used-block bitmap, for the whole run, at one bit per filesystem
block:
total_blocks / 8bytes, 128 MiB for a 4 TiB image at a 4 KiB block.
So peak memory grows with the entry count, the largest file, and the block count — never with the image’s size in bytes.
Reading
The Reader opens over an image’s bytes and parses it back. It walks the directory
tree from the root (inode 2) and returns file and symlink contents:
extern crate ferrosys;
use ferrosys::ext::ondisk::Timestamp;
use ferrosys::ext::{format, FormatOptions, GrowReservation, Metadata, Reader, TreeBuilder};
let time = Timestamp::from_secs(1_700_000_000);
let source = TreeBuilder::new()
.file(b"/greeting".to_vec(), b"hello\n".to_vec(), Metadata::new(0o644, time));
let mut options = FormatOptions::new([0x11; 16], time, [0u8; 16]);
options.grow = GrowReservation::UpTo(32 << 30);
let image = format(source, 64 << 20, options).unwrap();
// The reader reads over any `Read + Seek` source; wrap the bytes in a cursor.
let mut reader = Reader::open(std::io::Cursor::new(image.as_bytes())).unwrap();
let (_, file) = reader.lookup(b"/greeting").unwrap();
assert_eq!(reader.read_data(&file).unwrap(), b"hello\n");
Reading without holding the file
read_data returns a whole file, which is right for a configuration file and wrong for a
kernel image. Three methods read without ever holding one:
read_into(&inode, offset, buf)fillsbuffromoffsetand returns how many bytes it read, short only at the end of the file. It maps a window of the file at a time, so reading the last megabyte of a large file does not build the whole mapping first.read_data_to(&inode, writer)streams the whole file to anyWrite, a window at a time, and returns the byte count. Peak memory is the window, whatever the file’s size.walk_with(|reader, entry| …)walks the tree lazily, handing each entry to the callback as it is reached rather than collecting the whole listing first. The callback receives the reader, so it may read each file as it walks. It is generic over the callback’s error type, so a consumer’s own failure comes straight back out.
extern crate ferrosys;
// Copy every regular file out of the image without holding any of them.
reader.walk_with(|reader, entry| -> Result<(), MyError> {
if entry.inode.mode & 0o170000 == 0o100000 { // a regular file
let mut out = std::fs::File::create(destination(&entry.path))?;
reader.read_data_to(&entry.inode, &mut out)?;
}
Ok(())
})?;
walk collects the whole listing into a Vec and is what to reach for when the tree,
rather than its contents, is the subject.
With the tar feature enabled, ArchiveSink is that loop already written:
ArchiveSink::new(writer).write_tree(&mut reader) streams the whole filesystem out as a
tar archive — ownership, modes, times to the nanosecond, symlinks, hard links, device and
FIFO nodes, extended attributes, and POSIX ACLs, all carried in PAX records — with each
member’s body streamed rather than buffered. An archive that makes the round trip through
ArchiveSource describes the same filesystem at both ends. A socket has no tar entry type
at all, so a filesystem holding one is a typed error rather than an archive quietly missing
a file.
Filesystems other tools made
The reader reads any conformant ext image, whatever tool wrote it. It follows the
on-disk format, so an image mke2fs or the kernel produced reads the same as one
this crate wrote:
- Any inode size. The 128-byte inode has no extended area, so it carries no
creation time, no sub-second timestamps, and no
i_checksum_hi; every field past the classic inode is read only when the inode actually holds it. That is the same condition the kernel applies, and it is why the reserved inodes of a filesystem another formatter wrote — which declare no extended area — verify against the low half of their checksum alone. - Either mapping. An inode flagged for extents roots an extent tree; every other inode uses the classic direct/indirect block map, with a zero pointer standing for a hole at any level. ext2 and ext3 map every file that way.
- Checksums verified against the object’s own bytes, so a field the filesystem
carries and this crate does not model —
l_i_version, which the kernel bumps on every inode update, or the superblock’s error record — is part of the checksum it was part of when it was computed.
Resolving a path
lookup resolves a path to its inode, following symbolic links. Targets resolve
against the image’s own root, never the host’s, and resolution stops at a bounded
number of links, so a cycle terminates:
extern crate ferrosys;
// A merged-`/usr` root filesystem, where `/lib` is a link into `/usr/lib`.
let (_, modules) = reader.lookup(b"/lib/modules")?; // follows the link
let (_, link) = reader.lookup_no_follow(b"/lib")?; // the link itself
assert_eq!(reader.read_symlink(&link)?, b"usr/lib");
walk yields the literal tree and does not descend through links, so on a
merged-/usr layout the paths under /lib appear only under /usr/lib. lookup is
what reaches them by the name a system actually uses.
Checking an image
scan walks the whole image and reports every deviation it finds as a structured
Anomaly rather than stopping at the first — a checksum that does not match, a
reference out of range, a structure that does not parse, an inode carrying a structure
its superblock’s feature words deny. It reports what is wrong; it does not refuse the
image. verify_checksums is the strict counterpart, failing on the first object whose
stored checksum does not match its recomputed value.
scan is the path to point at an image you have no reason to trust. Every allocation it
makes is bounded by the bytes the source holds rather than by a count the image claims:
the groups and inodes it walks are capped at what the source can physically hold, each
metadata block is read once however many references name it, and the findings stop at
Limits::max_anomalies — ScanReport::MAX_ANOMALIES unless you set another — with
ScanReport::is_truncated recording that they did. A truncated report is a floor: the
image holds at least these findings, worst_severity and has_fatal are floors too, and
is_clean is false whatever the report holds, since a scan that stopped short never saw
enough to call an image clean.
walk is bounded the same way — a well-formed filesystem spends at least a directory
record’s worth of its own blocks per name, so the source’s length bounds how many names
it can describe. Reaching that bound is an error rather than a short list: a caller
extracting a tree from a truncated walk would write an incomplete one and see success.
A report projects to JSON, to a fixed-column table, and to a SARIF log. The JSON document
opens with a schema field holding ext::read::SCAN_SCHEMA_VERSION: a downstream parser depends on
the emitted shape, and no Rust signature describes it, so the shape names its own version.
Opening options
Reader::open reads from the start of a source, strictly, with the default limits.
Reader::open_with takes an OpenOptions carrying everything else: where the filesystem
begins within the source (a partition offset), the ReadPolicy to hold it to, a Limits
capping what one read may allocate, and a metadata_csum seed to verify against when the
image’s stored seed and UUID no longer agree.
extern crate ferrosys;
use ferrosys::ext::{Limits, OpenOptions, ReadPolicy, Reader};
// A filesystem one mebibyte into a disk image, read leniently so a scan can describe
// what is wrong with it, and held to a gigabyte per file read.
let options = OpenOptions::new()
.base(1 << 20)
.policy(ReadPolicy::Lenient)
.limits(Limits::new().max_file_bytes(1 << 30));
let mut reader = Reader::open_with(file, &options)?;
The limits default to imposing nothing, so an image of any size this crate wrote reads
back whole at the default settings. max_file_bytes bounds the logical-to-physical
mapping a read builds as well as the buffer it returns: the mapping costs eight bytes per
logical block against one byte per byte returned, so on the crafted i_size the cap
exists for it is the larger of the two.
A file past the cap is a ReadError::FileTooLarge naming the size and the bound — not a
short read. A truncated file that looked like a whole one would be the worse outcome by
far: a caller extracting a tree would write it out, see success, and carry a silently
incomplete file forward. Where a large file is legitimate, read_data_to streams it
without the cap applying to a buffer that is never allocated.
Saying what an image is
detect reads a source and reports the Filesystem family it holds — the crate root’s
own vocabulary, independent of which families are compiled in. detect_with does the same
at an offset within the source, for a partition inside a whole-disk image or a region a
carver located:
extern crate ferrosys;
use ferrosys::{DetectOptions, Filesystem, detect_with};
let what = detect_with(disk, &DetectOptions::new().base(1 << 20))?;
assert!(matches!(what, Filesystem::Ext4));
Detection asks what an image is, not whether it is sound: it classifies leniently, so an
image with a quirk a strict read would refuse still answers here. Reader and scan are
what say whether a filesystem is well-formed.
The ferrosys command line
The ferrosys-cli crate ships one binary, ferrosys, which writes ext2, ext3, and ext4
filesystems, reports on them, and reads their contents back out. It is the library’s
surface for anyone not writing Rust.
$ ferrosys format --size SIZE --uuid HEX --time SECS [options] OUT.img
$ ferrosys inspect [options] IMAGE
$ ferrosys extract [options] IMAGE (--to-tar F|- | --cat PATH | --stat PATH | --list)
$ ferrosys detect [options] IMAGE
Install it from the workspace:
$ cargo install --path crates/ferrosys-cli
Everything the image depends on is an input
Everything an image’s bytes depend on is an input you supply, so two runs given the same inputs write the same image, byte for byte. Reproducibility is the only mode the tool has; it is always on.
That has one consequence worth stating plainly: --uuid is required and --time is
required. The tool takes its UUID as an input, so pipe one in from a tool that mints them
— of whatever version you like — and pass the time explicitly or set SOURCE_DATE_EPOCH:
$ ferrosys format --size 512M --uuid "$(uuidgen)" --time 1700000000 rootfs.img
$ SOURCE_DATE_EPOCH=1700000000 ferrosys format --size 512M --uuid "$(uuidgen)" rootfs.img
The directory-hash seed defaults to the UUID’s bytes, so it too is an identity you
supplied. --hash-seed overrides it.
Streams and exit codes
The standard output carries exactly one artifact per run: a report, a listing, a tar
stream, or one file’s bytes. Everything else — a format’s summary, warnings, errors —
goes to the standard error. So ferrosys extract img --to-tar - | tar -t never has a
summary line spliced into its input, and ferrosys inspect --json img > report.json
never has a warning in it.
The exit codes mirror e2fsck’s. The line between 4 and 8 is whether an opinion about a
filesystem could be formed at all:
| Code | Meaning |
|---|---|
0 | The command did what it was asked, and any filesystem it read is sound. |
4 | A filesystem was read, and it is bad. |
8 | The command could not be carried out: the host got in the way, or the bytes are not an ext filesystem at all. |
16 | The command line could not be understood. |
format
$ ferrosys format --size 512M --uuid f0e17055-0000-4000-8000-000000000000 \
--time 1700000000 --from-tar rootfs.tar rootfs.img
The image streams out through the library’s streaming writer, which touches only the blocks the filesystem uses, so the file stays sparse and a filesystem far larger than memory can be written.
Where the contents come from
--from-tar FILE and --from-dir DIR are the two sources; without either, the filesystem
is empty but for /lost+found. Giving both is a usage error, since nothing here decides
the rules for a merge.
--from-tar FILE reads an uncompressed tar archive. A named file is left on disk and
each member is read as its file is placed, so peak memory is the largest single member,
not the archive. --from-tar - reads the standard input, which cannot be sought back over
and so is held whole — that is the one case where a large archive needs the memory to
match. A compressed archive is named as such rather than reported as malformed tar:
$ gunzip -c rootfs.tar.gz | ferrosys format ... --from-tar - rootfs.img
--from-dir DIR walks a directory tree on this machine. DIR itself becomes the
filesystem root, and modes, ownership, all three times, symlinks, hard links, device,
FIFO and socket nodes, and extended attributes with their POSIX ACLs all come across.
Each file is read as it is placed, so peak memory is the largest single file.
The walk records Linux inode metadata and Linux extended attributes, so this is the one
option carried out on Linux alone; a binary built elsewhere refuses it by name and exits
8, having opened nothing. Every other part of the tool — an empty filesystem,
--from-tar, inspect, extract, detect, and every geometry option — is the same on
every platform.
The walk records the uid and gid the host files carry, which for a build that does not run
as root is that user’s own. --owner UID:GID replaces them, and a rootless build
almost always wants --owner 0:0:
$ ferrosys format --size 512M --uuid "$(uuidgen)" --time 1700000000 \
--from-dir staging/rootfs --owner 0:0 rootfs.img
Two more things about format
The destination must be a regular file. A format writes only the blocks the filesystem uses and extends the file to its full size with a single byte at the end, so every byte it does not write must already read as zero — which creating or truncating a regular file guarantees and a block device does not. Formatting a device would leave whatever it held interleaved with the new filesystem, so the tool refuses.
A failing run leaves the destination alone. The source is parsed, the geometry planned,
and the inode model built and checked before the destination is opened, so a run that
cannot succeed never truncates the image already at that path. --dry-run goes one step
further and reports the geometry the command would realize without opening the destination
at all; --atomic covers the other end, writing to a sibling temporary file and renaming
it over the destination once the image is whole, so a failure part-way through the write is
not visible either. Note what --atomic costs: the destination becomes a new file, so
its mode comes from this process’s umask and any ownership, ACLs, or extra hard links the
old file carried do not survive the rename. Without it the image is written in place.
Choosing a base filesystem
-t (or --type) selects which of the ext family to write — ext2, ext3, or ext4
(the default). It seeds the whole feature set from that profile’s baseline; the -O list
and the geometry options layer on top, exactly as they do for mke2fs -t:
$ ferrosys format --size 256M --uuid "$(uuidgen)" --time 1700000000 -t ext2 rootfs.img
$ ferrosys format --size 256M --uuid "$(uuidgen)" --time 1700000000 -t ext3 rootfs.img
An image is judged by the features it carries, not the profile it started from, so the
two compose freely: -t ext2 -O has_journal writes exactly what -t ext3 does — a
journal over the ext2 baseline — and ferrosys inspect labels either one ext3. The
order of -t and -O on the line does not matter; the profile is always the base and
-O always layers on top.
Features are named on disk, and -O turns them on and off left to right over the selected
profile (ext4 unless -t says otherwise):
$ ferrosys format --size 64M --uuid "$(uuidgen)" --time 1 \
-O ^has_journal,^orphan_file,^metadata_csum_seed,^metadata_csum small.img
Clearing the ext4-layer features one by one this way lands on the same ext2 baseline that
-t ext2 selects directly — the flag is the shortcut. A combination that must never reach
disk is refused by name — dropping has_journal while orphan_file remains, for
instance, since the orphan file’s entries are journalled.
The same rule covers what the source holds, because a feature word is a promise about the
structures the filesystem carries. Dropping ext_attr from a source whose entries have
extended attributes, or large_file from one holding a file of 2 GiB or more, is refused
by name and by path: the attribute is neither dropped nor written into a filesystem whose
words deny it. ^large_file at the 4096-byte block size is refused on its own, before any
source is read, because the resize inode a growable filesystem carries is itself a file
that large.
--grow sizes the reserved descriptor blocks that let the filesystem grow online without
relocating its descriptor table. It defaults to max, which reserves the most the format
allows; --grow 4G reserves exactly enough to reach a known target, and --grow none
reserves nothing.
--label names the filesystem, up to sixteen bytes. A longer label is refused rather than
truncated, so the name a filesystem carries is the one that was asked for:
$ ferrosys format --size 64M --uuid "$(uuidgen)" --time 1 --label rootfs fs.img
--inodes and --bytes-per-inode set how many inodes the filesystem has, overriding the
count the size alone would choose. --inodes 20000 names the count directly;
--bytes-per-inode 16384 names the density it is derived from — one inode for every so
many bytes. The two share one setting, and the last given wins. A density that would need
more inodes in a group than its one-block bitmap indexes is refused rather than quietly
reduced.
A named count is a target, not a floor. It is spread across the groups, and each group’s
share is rounded up to fill whole inode-table blocks and then down to a multiple of eight,
so the group’s inodes end on a byte boundary in the inode bitmap — the same
s_inodes_per_group mke2fs derives for the request. The rounding meets or exceeds the
request wherever an inode-table block holds a multiple of eight inodes; at the 1024- and
2048-byte block sizes, where a block of the default inode size holds only four, the
multiple-of-eight step can leave the realized total a few inodes short. ferrosys inspect
reports the count the filesystem actually carries.
--reserved-percent sets the share of blocks held back for the super-user, from 0 to 50,
with up to two decimal places — --reserved-percent 1.5 reserves 1.5%. It defaults to 5.
The count is exact, floor(blocks × percent) in integer arithmetic, so a filesystem’s
reservation is reproducible to the block.
--json prints the geometry the format realized on the standard output:
$ ferrosys format --size 64M --uuid "$(uuidgen)" --time 1 --json fs.img
{"schema":1,"uuid":"f0e17055-...","volume_name":"","created":1,"profile":"ext4",...}
Every JSON document this tool writes opens with the same "schema" field: the shape is a
contract of its own that no command-line signature describes, so it names its own version.
The receipt also carries free_blocks and free_inodes, read back from the filesystem
that was just written — a format’s overhead is otherwise invisible, and on a small image
it is most of it. On a --dry-run, where no filesystem was written to have them, both are
null and "written" is false.
A small partition
A format’s defaults are sized for a general-purpose filesystem, and on a small one they
cost more than they are worth. Two of them dominate: the growth headroom (--grow) and the
journal, which is a real file costing its size in free space — 4 MiB of a 16 MiB
filesystem. On a partition that will not grow and does not need a journal, say so:
$ ferrosys format --size 16M --uuid "$(uuidgen)" --time 1700000000 \
-t ext2 --grow none --reserved-percent 0 boot.img
That leaves 3830 of the 16 MiB image’s 4096 blocks free — 93.5% of it usable — against
2710 at the defaults. Keep the journal (-t ext3) if the partition will be mounted
read-write and power loss is a real risk; drop it when the partition is written once and
read afterwards, which is what a boot partition usually is. The format summary reports what
was reserved and what is left free, so the cost of any combination is one command away.
inspect
$ ferrosys inspect rootfs.img
Filesystem UUID: f0e17055-0000-4000-8000-000000000000
Filesystem magic number: 0xEF53
Filesystem features: has_journal ext_attr resize_inode dir_index orphan_file ...
Filesystem profile: ext4
Inode count: 16384
Block count: 16384
...
no anomalies
The whole image is scanned by default: every group descriptor, bitmap, inode, extent
tree, and directory block, with each metadata checksum recomputed. That is what makes a
bad image bad (exit 4) rather than merely described. --quick reports the superblock
alone and reaches no verdict.
--fail-on moves the line at which the scan’s findings make the filesystem bad. It
defaults to integrity: a filesystem is bad when its own bytes contradict each other — a
checksum that does not match what it covers — or when a structure the reader must follow
cannot be.
The threshold below it, conformance, means something else, and is worth knowing about
before you reach for it: it faults a filesystem that is valid ext4 but not the form this
tool writes. A filesystem mke2fs made is exactly that, and it is not thereby broken —
so conformance is a check on this tool’s own output, an opt-in self-check, and not what
inspect does by default. --fail-on structural faults only an image whose structures
cannot be followed at all; --fail-on never reports every finding and exits 0 regardless,
which is what to use when you want the report and not the judgement.
A scan reads an image it has no reason to trust, so what it collects is bounded: it stops
at ten thousand findings and says so, in the table, in the "truncated" field of the JSON
report, and as a SARIF notification. A truncated report is a floor — the image holds at
least these findings, and the rest of it went unread — so the verdict it reaches reads
“at least n anomalies”. Ten thousand findings is far past what a filesystem needs to be
called bad.
--groups adds every block group’s descriptor. --json reports the same data as a JSON
document carrying a "schema" field, the feature names split by word, the ext2/ext3/ext4
profile those words classify to, the unknown feature bits (reported whether or not there
are any, so an image carrying a feature the tool does not know can never read as one it
understood), and the scan’s findings.
--sarif reports the scan’s findings — and those alone, not the superblock description —
as a SARIF 2.1.0 log, so a static-analysis or
forensic pipeline can consume them as it would any other tool’s. Each anomaly becomes one
result: its severity maps to the SARIF level (structural and integrity are error,
conformance is warning, cosmetic is note), and the exact severity together with
the block, group, or inode it sits at travels in the result’s properties. Because it
reports scan findings, --sarif runs the scan and so cannot be combined with --quick,
and it selects a different output format from --json:
$ ferrosys inspect --sarif rootfs.img > findings.sarif
--offset reads a filesystem that begins partway into a larger file — a partition inside
a whole-disk image:
$ ferrosys inspect --offset 1M disk.img
extract
Exactly one of four things comes out.
A tar archive. Ownership, modes, symlinks, hard links, and device and FIFO nodes travel in the header; the paths, the ids, the times (to the nanosecond, and negative for a file older than the epoch), the extended attributes, and the POSIX ACLs travel in PAX records, because the header cannot hold them. GNU tar and bsdtar both read it.
$ ferrosys extract rootfs.img --to-tar rootfs.tar
$ ferrosys extract rootfs.img --to-tar - | tar -tv
The archive opens with a ./ member describing the root directory and omits
/lost+found, which every filesystem makes for itself. What comes out is what
format --from-tar reads back in, so a filesystem survives a round trip through the
archive unchanged.
A socket is the one thing tar cannot express: it has no entry type for one. An image holding a socket is a typed error rather than an archive quietly missing a file.
One file’s bytes, and nothing else on the standard output:
$ ferrosys extract rootfs.img --cat /etc/hostname
ferrosys
The path is a path inside the image, taken as the bytes you typed — an ext4 name need
not be text. Symbolic links are followed against the image’s own root, so /lib/modules
resolves on a merged-/usr tree where /lib is a link into /usr.
A listing:
$ ferrosys extract rootfs.img --list
drwxr-xr-x 2 0 0 4096 2023-11-14T22:13:20Z /etc
-rw-r--r-- 1 1000 1000 9 2023-11-14T22:13:20Z /etc/hostname
lrwxrwxrwx 1 0 0 17 2023-11-14T22:13:20Z /etc/mtab -> /proc/self/mounts
--list --json produces the same listing as a JSON document, with each entry’s inode
number — which is what tells one file with two names from two files with the same
contents — its extended attributes, and any POSIX ACL decoded into readable entries.
One path’s metadata, which is the answer to “what is /usr/bin/ping, exactly” without
listing a hundred thousand other lines:
$ ferrosys extract rootfs.img --stat /usr/bin/ping
Path: /usr/bin/ping
Inode: 15
Type: file
Mode: 0755 (-rwxr-xr-x)
Owner: 0:0
Links: 1
Size: 76672
Blocks: 152
Accessed: 2023-11-14T22:13:20Z
Modified: 2023-11-14T22:13:20Z
Changed: 2023-11-14T22:13:20Z
Created: 2023-11-14T22:13:20Z
Xattr security.capability: 0x0100000200200000...
It reports the type, the mode both ways, ownership, link count, size, all four times, a
device node’s numbers, a symlink’s target, and every extended attribute — with a stored
POSIX ACL decoded rather than shown as bytes. A path naming a symlink describes the link
itself, not its target. --json reports the same as a document.
In a JSON document the mode field is the permission bits as a decimal number, since
JSON has no octal literal — 509 is 0o775 — and mode_octal beside it carries the usual
spelling.
Reading an image you do not trust
A file’s size is the image’s own claim about it, and a sparse file legitimately dwarfs the
filesystem holding it, so nothing structural bounds what --cat would allocate.
--max-file-bytes N is the bound to set on an image that has not earned trust:
$ ferrosys extract suspect.img --cat /etc/passwd --max-file-bytes 64M
Over the cap the read is an error, not a short file. A truncated file that looked whole would be the worse outcome: a pipeline would carry it forward and never know.
detect
$ ferrosys detect rootfs.img
ext4
$ ferrosys detect --offset 1M disk.img
ext2
One word on the standard output — ext2, ext3, ext4, or unrecognized — so it reads
well in a shell test, and --json for a document. --offset points it at a partition
inside a whole-disk image or a region a carver located.
This asks what an image is, not whether it is sound: an image with a quirk inspect
would refuse still classifies here. An unrecognized image exits 8, since there is no
filesystem to have an opinion about.
A round trip, end to end
$ tar -cf rootfs.tar -C rootfs .
$ ferrosys format --size 512M --uuid "$(uuidgen)" --time 1700000000 \
--from-tar rootfs.tar rootfs.img
$ ferrosys inspect rootfs.img
$ ferrosys extract rootfs.img --to-tar - | tar -tv
Every time is UTC, printed as YYYY-MM-DDTHH:MM:SSZ. The tool has no time zone and no
locale.
API reference
The complete API reference is generated by rustdoc and published alongside this guide:
Open the ferrosys API reference →
To build it locally from the workspace root:
cargo doc --no-deps --open