Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The ferrosys command line

The ferrosys-cli crate ships one binary, ferrosys. It writes ext2, ext3, and ext4 filesystems, FAT12, FAT16, and FAT32 volumes, exFAT volumes, and btrfs filesystems. It reports on them, reads their contents back out, and changes what an ext filesystem is known by. 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|- | --to-dir DIR | --cat PATH | --stat PATH | --list)
$ ferrosys detect  [options] IMAGE
$ ferrosys identity [options] IMAGE

The library is modular, and a program that wants one filesystem compiles one. This binary is the deliberate exception: it carries every family, so detect and inspect identify an image whatever it turns out to hold. format -t is where you say which one to write.

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, and it is always on.

That has one consequence worth stating plainly: an identity is required, and --time is required for every family whose format records an instant. Each family names its own identity. That is --uuid for ext, --volume-id for a FAT, --volume-serial for exFAT, and --fsid for btrfs. The tool mints neither an identity nor a time. Pipe an identity in from a tool that does, 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:

CodeMeaning
0The command did what it was asked, and any filesystem it read is sound.
4A filesystem was read, and it is bad.
8The command could not be carried out: the host got in the way, the bytes are not a filesystem at all, or an option named a concept the image’s family does not have.
16The 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. The file therefore stays sparse, and the tool writes a filesystem far larger than memory.

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. Peak memory is therefore 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, and the one that carries a size cap. A stream past four gibibytes is refused, and naming the archive as a file is both the way past it and the cheaper route.

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. 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 is the same on every platform: an empty filesystem, --from-tar, inspect, extract, detect, and every geometry option.

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. Use --owner UID:GID to replace 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

Sizing the image to its contents

--size auto works the size out from what goes in the filesystem, instead of being told:

$ ferrosys format --size auto --from-dir staging \
      --uuid "$(uuidgen)" --time 1700000000 rootfs.img

It finds the smallest filesystem that holds the contents by planning candidate sizes and placing the contents into each. The size it settles on is therefore one that formats, and one allocation unit less does not. Nothing is written while it searches, so a size that cannot be found leaves the destination untouched like any other planning failure. Pair it with --dry-run to learn the number without writing anything at all.

It works for every family, and each measures itself in its own unit. An ext filesystem is searched a block at a time. A FAT volume is searched a sector at a time, because its cluster size is derived from its size rather than given.

The smallest filesystem holding something is one with nothing left in it. That is right for an image that will only be read, and useless for one that will be written to. --slack says how much must stay free:

$ ferrosys format --size auto --slack 20% --from-dir staging \
      --uuid "$(uuidgen)" --time 1700000000 rootfs.img
$ ferrosys format --size auto --slack 64M --from-dir staging \
      --uuid "$(uuidgen)" --time 1700000000 rootfs.img

The share is of the finished filesystem, measured in whatever its own free counter counts: blocks for ext, clusters for FAT. So --slack 20% is what df reports as 80% used. On ext the super-user reservation (--reserved-percent) is separate accounting over the same blocks, so an unprivileged writer sees 15% of it at the 5% default. --slack belongs to --size auto and is a usage error over a size that was named outright, which has no room to find.

On FAT a share can leave a volume unchanged where a byte figure does not. FAT32 needs 65525 clusters whatever it holds, so a small tree already lands on a volume that is almost entirely free. Only --slack 200M and the like ask for more than that floor already gives.

On a small image the floor is often the journal rather than the contents. A log costs about 4 MiB whatever goes in the filesystem, so --size auto -t ext2 is what fits a boot partition to what it holds.

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. 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. A run that cannot succeed therefore never truncates the image already at that path.

--dry-run goes one step further. It reports the geometry the command would realize, without opening the destination at all.

--atomic covers the other end. It writes to a sibling temporary file, and renames it over the destination once the image is whole. A failure part-way through the write is therefore not visible either. Note what --atomic costs: the destination becomes a new file. 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 --atomic the image is written in place.

The two do not combine. --dry-run opens no destination, so there is nothing left for --atomic to decide, and a flag that decides nothing reads as one that worked.

Geometry the command line names, and geometry it does not

A family’s layout can be decided by more than its size. Where it is, the choice is spelled out for the family that records it in its own superblock and reads it back as identity. btrfs takes --sector-size, --node-size, and the two replication profiles. ext takes --block-size and --inode-size for the same reason.

FAT and exFAT derive their cluster size, table count, reserved sectors, and root capacity from the volume’s size, by the rules their reference formatters use. The command line does not offer to override them. The library does. A caller can pin a cluster size, a sector size, an OEM name, or a boot sector. That caller builds a plan request directly and formats through the crate. What comes out of either route is the same filesystem.

Choosing a filesystem

-t (or --type) selects which filesystem to write: ext2, ext3, ext4 (the default), fat12, fat16, fat32, exfat, or btrfs. It names the family as well as the variant. Each family takes its own identity and its own options, and an option belonging to another one is refused by name rather than passed over. A line that named two families has said two things that cannot both be honored, and is told so.

exfat is one word where the others are three, because the format has one revision and every volume records it. The family is the finest answer there is, so there is no variant to choose between. btrfs is one word for a related reason spelled differently. What varies between two btrfs filesystems is a feature word and a geometry. Both are options rather than a variant a -t value could name.

Within the ext family 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, which is 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.

Feature words

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

That clears the journal and the checksums, and leaves the rest of the profile intact. extent, 64bit, flex_bg, huge_file, dir_nlink, and extra_isize are all still set. The image is therefore an ext4 one without a journal, and ferrosys inspect labels it ext4.

-t ext2 is not a shorthand for a list of ^ words. It selects a different base, and reaching that base by clearing words means clearing every ext4-layer word, extent included. Name the profile you want, and layer -O on top of it.

A combination that must never reach disk is refused by name. Dropping has_journal while orphan_file remains is one, since the orphan file’s entries are journaled. That is why the line above clears both.

The same rule covers what the source holds, because a feature word is a promise about the structures the filesystem carries. Two examples are refused by name and by path. Dropping ext_attr from a source whose entries have extended attributes is one. Dropping large_file from one holding a file of 2 GiB or more is the other. 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. The resize inode a growable filesystem carries is itself a file that large.

-O reads btrfs’s feature words too, in btrfs’s own vocabulary — the words its tooling takes and ferrosys inspect prints back, which are not ext’s:

$ ferrosys format --size 1G --fsid "$(uuidgen)" --time 1700000000 -t btrfs \
      -O ^block-group-tree old-kernel.img

That is the one most worth naming. A filesystem carrying block-group-tree needs a kernel of 6.1 or newer. Clearing it is how you write an image for an older one.

The grammar is the same in both families. A bare word sets a feature, ^ clears it, and none starts from nothing. The list applies left to right. The vocabularies are disjoint, so a word belonging to the other family is refused rather than quietly ignored. A feature this tool does not write is refused by name, and so is one whose prerequisites were not asked for. block-group-tree rests on free-space-tree and no-holes, so clearing either of those means clearing it in the same list.

ext geometry and defaults

--grow

--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.

--block-size and --inode-size

--block-size and --inode-size set the two sizes every other ext number is derived from. A block is 1024, 2048, or 4096 bytes, and 4096 by default. An inode is a power of two from 128 up to the block size, and 256 bytes by default. A 128-byte inode holds no nanosecond field and no creation time, so it is the choice that decides which times an image can carry.

--journal

--journal sizes the journal in filesystem blocks, where the default sizes it from the filesystem. The journal is a real file and costs its size in free space, which is four mebibytes of a sixteen-mebibyte filesystem. -t ext2 writes none, and is the way to spend that space on contents instead.

--errors

--errors sets what a kernel does when it detects an error in the filesystem. continue notes it and carries on, remount-ro remounts read-only, and panic stops the machine. It defaults to continue, which is the kernel’s own default.

--fixed-time

--fixed-time forces every inode’s times to one instant, whatever the source recorded. It is for the image whose contents came from a tree whose timestamps are not reproducible. --time alone stamps the filesystem, and this stamps everything in it.

--hash and --hash-signedness

--hash and --hash-signedness decide how a directory’s names are hashed for its index. The hash is half_md4 by default, and tea and legacy are the alternatives. The signedness decides whether a name’s bytes are read as signed or unsigned, and it is unsigned by default. That is what makes the hash independent of the host. A char is signed on one architecture and unsigned on another. A directory hashed one way is not readable by a kernel hashing the other.

--label

--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

--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, which is 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. Each group’s share is rounded up to fill whole inode-table blocks, and then down to a multiple of eight. The group’s inodes therefore end on a byte boundary in the inode bitmap. That is 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. Where a block holds fewer than eight, the multiple-of-eight step can leave the realized total a few inodes short. That is the 1024-byte block at the default 256-byte inode, and the 2048-byte block once --inode-size 512 halves how many fit. ferrosys inspect reports the count the filesystem actually carries.

--reserved-percent

--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

--json prints the geometry the format realized on the standard output:

$ ferrosys format --size 64M --uuid "$(uuidgen)" --time 1 --json fs.img
{"schema":2,"family":"ext","variant":"ext4","uuid":"f0e17055-...","volume_name":"",
 "created":1,"features":[...],...}

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.

Writing a FAT volume

An EFI System Partition is FAT and has no alternative, so it is the case this family exists for:

$ ferrosys format -t fat32 --size 512M --volume-id "$(od -An -N4 -tx1 /dev/urandom | tr -d ' ')" \
      --time 1700000000 --label ESP --owner 0:0 \
      --accept-loss change-time,time-precision --from-dir esp-staging esp.img

--volume-id is this family’s identity. It is a 32-bit serial number, taken as eight hex digits in either the bare form or the dashed one every tool prints (1A2B-3C4D). It is a separate option from --uuid rather than four bytes cut from one. A value silently narrowed is a value you did not choose.

The type is what the geometry must derive to, not what gets written down. Nothing in a FAT volume records which of the three it is, and every driver counts the clusters and compares against two thresholds. So -t fat32 on a size that cannot reach a FAT32 cluster count is refused rather than written as a FAT16 wearing a FAT32 label.

The command line names a type outright. The library additionally offers the type the geometry reaches on its own, and a FAT32 volume below the cluster count the specification sets for one. That is a shape every mainstream driver reads and no specification blesses. Asking for it is therefore explicit, and the command line does not ask.

--label goes through this family’s own rules: eleven bytes, upper-cased, in the OEM character set. A label the field cannot hold is refused rather than truncated, as on the ext side. So is --time. A FAT directory entry represents 1980-01-01 through 2107-12-31 at a two-second granularity. An instant outside that is refused rather than written as a plausible-looking one inside it.

What a FAT volume cannot carry, and why the build stops

A FAT directory entry holds a name, one attribute byte, three coarse timestamps, a first cluster, and a length. There is no field for an owner, a group, or permission bits. There is none for a symbolic link, a second name for a file, a device number, or an extended attribute. So a build that would drop any of those fails and names the entry and the property. --accept-loss is how you name the ones that can go:

$ ferrosys format -t fat32 --size 512M --volume-id 1A2B3C4D --time 1 \
      --from-dir staging esp.img
ferrosys: /EFI: a FAT volume cannot carry the ownership of this entry

Refusing is the default because the alternative is worse than inconvenient. A root filesystem written to FAT with silent mode loss is a security bug wearing a convenience feature’s clothes. No report after the fact makes that acceptable.

A property counts as lost when the value does not survive, rather than when the format has no field for it. That distinction is what keeps the acknowledgement meaningful. A read of a FAT image fills an owner and a mode in from --assume-owner and --assume-modes, which default to root and 0644/0755. A root-owned tree of conventionally moded files therefore goes in, comes back unchanged, and loses nothing by them.

Two losses a tree walked off a host always takes:

  • change-time, because the format records no change time on anything.
  • time-precision, because it stores a write time to two seconds and an access time to the day.

So the line above is the realistic minimum for --from-dir. Properties are named one by one on purpose. A caller who accepted losing permission bits must not thereby have accepted every symbolic link in the tree disappearing. That is the surprise the option exists to prevent. --accept-loss all is the deliberate exception, and it covers a property a later version names as well.

Set --assume-owner and --assume-modes to whatever the extraction will use, so the two ends of a round trip agree about what survived. What the build did cost is the last thing the summary prints, in the same words --accept-loss reads:

DIRECTION  PROPERTY             ENTRIES
dropped    time-precision       4
dropped    change-time          4

A build that lost nothing says nothing dropped or synthesized rather than printing an empty table, so silence never has to be interpreted.

Writing an exFAT volume

An SDXC card specifies exFAT, and every current desktop operating system reads one as it ships. This is the family for a card or a stick that has to be read somewhere else:

$ ferrosys format -t exfat --size 8G \
      --volume-serial "$(od -An -N4 -tx1 /dev/urandom | tr -d ' ')" \
      --label MEDIA --owner 0:0 \
      --accept-loss change-time,time-precision --from-dir card-staging card.img

--volume-serial is this family’s identity. It is the VolumeSerialNumber both boot regions record, taken as eight hex digits in either the bare form or the dashed one every tool prints. It is a third option rather than a reuse of --volume-id, because it is a different field of a different format. The two being the same width is a coincidence of the lineage, not a shared value.

There is no --time here, and the absence is the format’s. An exFAT volume records no instant of its own anywhere. Every time on it belongs to an entry, and comes from the source that named it. The flag is refused for this family, the way every option of a family that was not named is.

--label goes through this family’s own rules again. It takes up to eleven UTF-16 code units, which is eleven characters rather than eleven bytes for anything outside ASCII. It takes text rather than bytes. The format stores a label as code units, so there is no encoding to guess for bytes that are not text. As everywhere else, a label the field cannot hold is refused rather than truncated.

This family does two things its neighbor cannot. A name is stored whole, up to 255 UTF-16 code units, in the case it was given, with no second shortened name derived beside it. What goes in is therefore what a listing shows. A lookup also folds through the volume’s own up-case table. A path in any case therefore reaches the entry a driver reading the same volume would reach:

$ ferrosys extract card.img --cat /DCIM/IMG_0001.JPG
$ ferrosys extract card.img --cat /dcim/img_0001.jpg   # the same file

The one thing this family does not do is --size auto. That search plans candidate sizes, and places the contents into each until the smallest one that holds them is found. The search is a family’s own, and this one has none. --size auto -t exfat is therefore refused while the command line is being read, before a source is opened, so nothing is walked to say so. Name a size.

What an exFAT volume cannot carry

The same properties a FAT volume cannot, for the same reason. An entry set records a name, five attribute bits, three times, and two lengths. It has no field for an owner, a group, or permission bits. It has none for a symbolic link, a second name for a file, a device number, or an extended attribute.

--accept-loss, --assume-owner and --assume-modes mean exactly what they mean for FAT. A build that would lose something you have not named fails, and says which entry and which property.

What differs is how much time survives, and it is worth knowing before choosing between the two families. exFAT records a creation and a modification time to ten milliseconds, and each of its three times with a UTC offset. FAT records a write time to two seconds, an access time to the day, and no zone at all. A volume this tool writes says its times are UTC rather than leaving a reader to guess a locality. A host tree still loses time-precision, because exFAT’s access time is two-second granular like FAT’s. It loses far less of it.

Writing a btrfs filesystem

Fedora and openSUSE default to btrfs, and both lay out subvolumes rather than one flat tree. This is therefore the family for a distribution root image, and the layout is the point rather than a refinement:

$ ferrosys format -t btrfs --size 8G --fsid "$(uuidgen)" --time 1700000000 \
      --label fedora --owner 0:0 \
      --subvol "$(uuidgen):/@" --subvol "$(uuidgen):/@home" --default-subvol /@ \
      --from-dir root-staging root.img

--fsid is this family’s identity. It is the id blkid reports, and the id a UUID= line in /etc/fstab names. It is taken as 32 hex digits in either the bare form or the dashed one. It is a fourth option rather than a reuse of --uuid, because it is a different field of a different format. The two being the same width is a coincidence.

A btrfs records five identifiers, and the other four have options of their own. --metadata-uuid is the id every tree block is stamped with, where it is to differ from the visible one. That is what lets the visible id change later without rewriting every block, and what a filesystem records as a feature bit in that state.

--chunk-tree-uuid, --device-uuid, and --subvolume-uuid are the chunk tree’s, the device’s, and the top-level subvolume’s. Each defaults to zero, which is a legitimate value and an obviously unset one. None of them is derived from another. A filesystem whose bytes you can reproduce is one that states all five. A value this tool invented would be a value you could not state.

--subvol [ro:]UUID:PATH makes the source directory at PATH the root of a subvolume of its own. It is repeatable, and each needs its own identifier. The filesystem’s UUID tree is keyed by them, so two subvolumes sharing one would produce a tree with a repeated key. The command line says so by name, rather than letting the writer discover it. ro: in front makes one read-only. The identifier leads and the path is everything after it, so a path can contain a colon.

--default-subvol PATH says which subvolume a mount that was told none lands on. Without it a mount lands on the top-level tree every btrfs starts with, which subvolid=5 names. That is rarely what a root image wants:

# mount -o subvol=@ root.img /mnt        # what --default-subvol /@ makes the default

A subvolume root is still a directory. The source declares it as one and --subvol says how to lay it out, which is why the same staging tree feeds every family unchanged. A hard link cannot span two subvolumes, and no btrfs holds one. A source naming one is refused rather than silently written as two files.

--label is up to 255 bytes, taken as they come. The superblock’s label field records no encoding, so what you supply is what every reader of the image sees. A label the field cannot hold is refused rather than truncated, as everywhere else.

The geometry is four options. --sector-size is the smallest addressable unit of file data, and --node-size is the size of a tree block. Each is a power of two from 4K to 64K, defaulting to 4096 for the sector size and 16K for the node. The node size decides how much a leaf holds, and so how large a file can be before it stops fitting inside the metadata.

--metadata-profile and --data-profile say how each kind of block group is replicated. dup writes two copies on the one device, which is what protects the trees against a bad sector, and single writes one. Metadata defaults to dup and data to single, which is what the format’s own tooling does on one device.

Unlike the other three families, nothing is lost on the way in. btrfs has a field for every property a source states:

  • An owner and a group.
  • A full mode.
  • A link count.
  • A device number.
  • Four timestamps, each to the nanosecond.
  • As many names per object as you give it.
  • An extended attribute, as a record of its own.

--accept-loss is therefore not an option this family takes, and a build here never refuses a tree for what it cannot hold. The format summary says so in a line rather than by silence.

--size auto is refused for this family, as it is for exFAT and for the same reason. The search behind it plans candidate sizes and places the contents into each, and that search is a family’s own. Name a size.

The smallest a btrfs can be is 109 MiB at the default profile pairing, and 45 MiB at --metadata-profile single. A volume below whichever applies is refused with the number it would have taken.

Two btrfs images from one directory can differ, and it is the directory that changed

Everything above holds the general rule: the same inputs write the same bytes. What is worth knowing for this family in particular is what counts as the same inputs when the input is a host directory tree.

Walking a tree reads its files, and on a filesystem that records access times, reading a file updates that file’s access time. btrfs is the one family here that stores an access time to the nanosecond, so it is the one where that shows. Two --from-dir builds of one tree, run one after the other, can differ by a millisecond in every entry’s atime. The tool read no clock. The tree it was pointed at was not the same tree twice.

If images have to be byte-identical across runs, give the build a source that reading does not change. --from-tar is one, and its member times are in the archive. Mounting the staging tree noatime is another, and so is stamping the tree’s times before each build.

Every other input is already fixed. --time and the five identifiers are values you state, and nothing else in the tool reads anything but the source.

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) is one. The journal is the other, and it is a real file costing its size in free space, which is 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, which is 93.5% of it usable, against 2710 at the defaults. If the partition will be mounted read-write and power loss is a real risk, keep the journal (-t ext3). Drop it where 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 family:          ext
Filesystem variant:         ext4
Filesystem size:            67108864
Allocation unit:            4096
Filesystem identifier:      f0e17055-0000-4000-8000-000000000000

Filesystem UUID:            f0e17055-0000-4000-8000-000000000000
Filesystem magic number:    0xEF53
Filesystem features:        has_journal ext_attr resize_inode dir_index orphan_file ...
Inode count:                16384
Block count:                16384
...

no findings

Every report opens with the same five lines, whatever the image holds. Those are which family, which variant of it, how large it is, what it allocates in, and what identifies it. Everything after them is that family’s own:

  • An ext image describes itself in superblock fields.
  • A FAT volume describes itself in its parameter block.
  • An exFAT volume describes itself in its boot region.
  • A btrfs describes itself in its superblock, its chunk map, and its trees.

So a tool that only wants to know what an image is, and whether it is sound, reads the head and stops:

$ ferrosys inspect esp.img
Filesystem family:          fat
Filesystem variant:         fat32
Filesystem size:            67108864
Allocation unit:            512
Filesystem identifier:      1A2B-3C4D

Volume label:               ESP
Volume serial number:       1A2B-3C4D
Type string:                FAT32
OEM name:                   ferrosys
Bytes per cluster:          512
Allocation tables:          2
Sectors per table:          1009
Clusters:                   129022
...

no findings

The FAT body reports the volume’s own claim about its type beside the count the head’s variant was derived from. No driver reads that string, because the type follows from the cluster count and from nothing else. A volume whose string disagrees with its geometry is read by its geometry, and this is the one place the disagreement is visible.

An exFAT report is the same envelope over a third body, and the four fields it leads with are ones no boot sector holds:

$ ferrosys inspect card.img
Filesystem family:          exfat
Filesystem variant:         exfat
Filesystem size:            8589934592
Allocation unit:            32768
Filesystem identifier:      5E71-A10C

Volume label:               MEDIA
Volume state:               clean
Percent in use:             3%
Bytes per cluster:          32768
Clusters:                   262016

Allocation bitmap at cluster: 2
Up-case table at cluster:   3
Root directory at cluster:  4
...

no findings

Where the allocation bitmap and the up-case table are, and how long each is, are recorded nowhere but in the root directory. The format stores them as ordinary directory entries. Those lines are therefore what a reading of that directory recovered, rather than a field read off the boot sector.

Volume state is the other thing only this family reports. It is the two flags a mounted driver writes, which sit outside the boot checksum precisely so it can. A volume that was not cleanly unmounted is reported and is not a fault. A strict read of a card somebody pulled out of a reader therefore still succeeds.

A btrfs report is the same envelope again, over the one body in this tool that describes two layers, because the format has two. Above is a logical address space with the trees on it. Below is a chunk map that translates every address in it onto the device. A filesystem whose trees all verify and whose map is missing a range is a different report from one where both are sound. Both are therefore there:

$ ferrosys inspect fedora-root.img
Filesystem family:          btrfs
Filesystem variant:         btrfs
Filesystem size:            8589934592
Allocation unit:            4096
Filesystem identifier:      1b4e28ba-2fa1-11d2-883f-0016d3cca427

Label:                      fedora
Metadata identifier:        1b4e28ba-2fa1-11d2-883f-0016d3cca427
Generation:                 42
Bytes used:                 1735983104
Sector size:                4096
Tree block size:            16384
Filesystem features:        free-space-tree free-space-tree-valid block-group-tree ...
Superblock copies:          present, present, outside the device

Mapped chunks:              5
Mapped bytes:               2214592512

ROOT_TREE at:               30523392 (level 0)
CHUNK_TREE at:              22036480 (level 0)
EXTENT_TREE at:             30507008 (level 1)
...

Subvolume <top-level>:      id 5
Subvolume root:             id 256, default
Subvolume home:             id 257

no findings

The last section is the one nothing else in this tool has. A subvolume is a filesystem tree inside the filesystem, with inode numbers of its own. Someone points this command at a btrfs to ask which ones are on an image. They ask, too, which is the default a mount reaches with no subvol=. The top-level tree is a subvolume too, and it is the one no directory entry names.

Filesystem features is one list across the three words the superblock carries, in the words format -O takes. A feature read off a report can therefore be typed straight back into a format. Nothing has to be translated from the all-capitals spelling the format’s own header uses.

A bit no feature of this crate’s own table covers is reported on its own line, as the word it belongs to and its value. That line is there whether or not any such bit is set. An image carrying something this tool does not understand can therefore never read as one it does.

Superblock copies is listed one entry at a time rather than counted, because which copy is damaged is what a person acts on. The format writes a copy at each of three fixed locations the device is long enough to hold all of. A filesystem smaller than 256 GiB therefore has two, and two is the whole count for it.

An option belonging to one family is refused for another rather than passed over. --groups reports the block-group descriptors of an ext filesystem. A FAT or exFAT volume has one flat cluster heap, and a btrfs is divided by a chunk tree instead. Asking for them there is a question with no answer, and is told so. A report that quietly omitted the section would read as a volume with no groups in it, which is a different claim.

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. It cannot be combined with --fail-on, which is a verdict on the scan --quick skips. The two together would be a CI gate that looks armed and exits 0 on a filesystem whose bytes are destroyed. They are refused rather than accepted with one of them inert.

--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, which is a checksum that does not match what it covers. It is bad, too, when a structure the reader must follow cannot be followed.

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 for its format but not the form this tool writes. A filesystem mke2fs or mkfs.fat 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.

The four severities mean the same thing for every family. The categories a finding falls into do not, and are each family’s own. A superblock and a boot sector are not one subsystem under two names. --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 judgment.

--groups is bounded the same way and for the same reason. A superblock’s group count is its own claim, and a crafted one reaches four billion. The listing stops at a million groups and says how many of the claimed total it showed. It does not grow to a count no image behind it could hold. The JSON report carries the same fact as "groups_complete".

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 findings”. 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, shaped as the same envelope the table is. That is a head which means the same thing whatever the image holds, then a body named for the family:

$ ferrosys inspect --json rootfs.img
{"schema":2,"family":"ext","variant":"ext4","size":67108864,"allocation_unit":4096,
 "identifier":"f0e17055-...","offset":0,"findings":{"schema":2,"clean":true,"count":0,
 "truncated":false,"findings":[]},"ext":{"superblock":{...},"features":{...}}}

The head is six fields and the findings. The body under "ext" carries the superblock, the feature names split by word, the unknown feature bits, and with --groups every group descriptor. The unknown bits are reported whether or not there are any. An image carrying a feature the tool does not know can therefore never read as one it understood.

A consumer that reads only the head never learns what a block group is. One that wants ext4’s geometry reads the body. A later filesystem family adds a body beside "ext" rather than reshaping anything above it.

The two dialects differ in one way on purpose. The table omits a row it has nothing to say in, because a person reading a report wants what is there. An image with no journal grows no journal rows.

The JSON body’s shape is fixed instead. The same keys are present for every image of a family, carrying the value the filesystem holds. A consumer therefore indexes a key without first asking whether this image has one. That is why the document carries a field or two the table does not print.

Every finding carries its severity, the family that found it, that family’s own word for the subsystem, and the byte offset when there is one. It carries that family’s own coordinates as well. Those are {"group":3,"inode":12,"block":40} for an ext image, and whatever addresses a finding in some other format.

--sarif reports the scan’s findings as a SARIF 2.1.0 log, so a static-analysis or forensic pipeline can consume them as it would any other tool’s. It reports those findings alone, and not the superblock description.

Each finding becomes one result. Its severity maps to the SARIF level, where structural and integrity are error, conformance is warning, and cosmetic is note. The exact severity travels in the result’s properties, together with the family, the subsystem, the byte offset, and the coordinates it sits at. A rule is named family/subsystem, so two formats that both call something a directory do not merge into one rule.

--sarif reports scan findings, so it runs the scan and cannot be combined with --quick. 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

The JSON document carries that coordinate back as offset, the same field detect --json carries. A caller that scanned a disk and then described what it found lines the two documents up by it.

extract

Exactly one of five things comes out.

Whatever the family, what comes out is the filesystem rather than one of its parts. On a btrfs that is worth saying plainly, because the format has parts a path could stop at and does not. A subvolume is a filesystem tree of its own, with inode numbers that start again from the same value the top-level tree’s do. Every mode here crosses that boundary the way it crosses a directory.

/home/user/notes reaches the file whether or not /home is a subvolume, and a listing of the image names everything in every subvolume once. Symbolic links are resolved through as well. A path continuing through /bin on a tree where that is a link into /usr therefore reaches what it names.

A btrfs also stores a file’s bytes compressed where a mount was told to. Every distribution that defaults to this filesystem tells it to for at least some of the tree. This binary carries the three decoders the format defines, which are DEFLATE, LZO1X, and Zstandard. A file stored that way comes out as the file rather than as a refusal. The checksums are checked either way. They cover the bytes the filesystem stored, so inspect verifies a compressed extent without expanding it.

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, because it has no entry type for one. An image holding a socket is a typed error rather than an archive quietly missing a file.

That refusal comes part-way through the walk, as an inode that does not read does, and as an ACL that does not decode does. A named destination is created and truncated before the walk starts. --atomic covers that, as it does for format. The archive is written to a sibling temporary file, and renamed over the destination once the walk is complete. A walk that fails therefore leaves whatever was at that path untouched.

--atomic applies to --to-tar FILE, the only mode with a destination to rename into. Asking for it anywhere else is refused rather than accepted and ignored.

$ ferrosys extract rootfs.img --to-tar rootfs.tar --atomic

A directory tree

This is format --from-dir in reverse:

$ ferrosys extract rootfs.img --to-dir unpacked
Names written:          1284

The destination is made if it is not there, and it must be empty. An extraction states what the filesystem holds. A name already present would be an entry that could not be created, found part-way through with the tree half written. The destination takes the filesystem root’s own mode, ownership, times, and extended attributes. Everything the filesystem holds appears beneath it at the path it holds inside the image. /lost+found is not written, so the tree is one format --from-dir reads straight back.

Everything the archive carries is carried here too, set on the files themselves. That is modes, ownership, symlinks, hard links, device and FIFO nodes, sockets, extended attributes, and POSIX ACLs. Two things no host lets a caller set, so the tree carries the time it was written for those two alone. They are an inode’s change time and its creation time. Access and modification times are set exactly, to the nanosecond.

Four parts of a tree need privileges:

  • A device node needs CAP_MKNOD.
  • Setting a recorded owner needs CAP_CHOWN.
  • An extended attribute in the security or trusted namespace is the host’s to write.
  • A destination filesystem with no notion of a second name for a node refuses a hard link.

An unprivileged run therefore stops at the first of them and names it. That is the right answer for a tree meant to be faithful. A rootfs quietly missing /dev/null is a rootfs that boots differently, and one whose ping lost its security.capability is one that no longer runs unprivileged.

--skip-privileged is the opt-in for a run that wants what it can have. What it left out is named on the standard error rather than assumed:

$ ferrosys extract rootfs.img --to-dir unpacked --skip-privileged
Names written:          1282
Ownership:              not applied — this process may not set another owner
Attributes:             not applied — this process may not set security or trusted attributes
Skipped:                /dev/console
Skipped:                /dev/null

The reserved attributes are what an unprivileged extraction of a real root filesystem meets first. A Debian tree carries security.capability on the binaries that hold one, and a tree built under SELinux carries security.selinux on nearly every inode.

A file’s contents are written as the filesystem reports them, and a hole reads as zeros. A sparse file therefore lands in the destination fully allocated. A tree holding one occupies more space on the host than it does in the image.

A directory’s mode is applied once its contents are in place, so one the image records as read-only still receives them. One recorded without owner-search permission waits until the whole run is done. A second name for a hard-linked file is written by traversing to the first, and a directory that cannot be searched cannot be traversed. Applying such a mode early is what would make an ordinary user’s extraction of an ordinary image fail part-way.

The image is untrusted input, and a name in it is not a path to resolve. Every directory is created and then opened, and everything beneath it is created through that open handle by its single-component name. A name holding a separator, a .., or a NUL is therefore refused rather than followed, and nothing lands outside the destination.

The reader refuses such a name too, where it resolves it. Both --to-dir and --to-tar are therefore safe against one, and neither depends on the other’s check. No path through the destination tree is walked a second time. Nothing swapped into it while a run is in flight can redirect a write. Symbolic links are written exactly as the image records them, absolute targets and all, which is safe precisely because nothing here ever follows one.

There is no --atomic for a tree: no rename publishes a whole tree at once, and inventing one would promise something the run cannot do. The empty destination is what stands in its place. A failure part-way leaves a partial tree in a directory that held nothing, rather than mixed into one that did.

How strictly the image is held to its format

Extraction is the one command whose output is the image’s contents. What it makes of a filesystem it does not fully understand therefore matters more here than anywhere else. An image carrying a feature this reader does not follow can be interpreted best-effort, and the result looks complete without being it.

So the strict read is tried first, always. --strict makes its refusal the answer. Without it, the read falls back to a lenient one, which is what makes a damaged or unfamiliar image recoverable at all. That read names on the standard error the deviation it decided to interpret through:

$ ferrosys extract odd.img --to-dir unpacked
ferrosys: reading odd.img leniently: unsupported incompat features: 0x400
ferrosys: what it holds is interpreted best-effort; --strict refuses instead

A run that says nothing read an image it could hold to its format entirely.

What a filesystem does not record

--assume-owner U:G and --assume-modes F:D say what to record where the filesystem being read has no field for it. They change nothing about an ext image. ext records an owner, a mode, and three times on every entry, so what comes out is what was stored. They change nothing about a btrfs either, which records those and a creation time besides.

They exist because a FAT volume does not. A format with no notion of an owner still has to become host files with some owner, and something has to decide which. Every driver that mounts such a format has uid=, gid=, fmask=, and dmask= mount options for exactly this reason. Here the same decision is a flag, and its default is the conservative one. That default is root ownership, 0644 for a file and 0755 for a directory, and never anything more permissive. A tree that landed world-writable because nothing was named would be a security bug made out of a format limitation.

Whatever was assumed is reported on the standard error beside what the host refused. An extraction therefore says which parts of the tree came from the image, and which were policy:

Assumed:                ownership (1282 entries)
Assumed:                permissions (1282 entries)
Assumed:                change-time (1282 entries)

The properties are named in the same words format --accept-loss reads. A property an extraction reports can therefore be typed straight back into the build that would preserve it.

One file’s bytes

Nothing else goes to 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, because 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 .. component ascends, whether you wrote it or a link in the image stores it. /usr/lib64 -> ../lib is the shape a multiarch root filesystem has.

At the root there is nothing to ascend to, so a run of them stays there. Every path names something inside the image, and nothing here reaches the machine reading it.

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. It adds each entry’s inode number, its extended attributes, and any POSIX ACL decoded into readable entries. The inode number is what tells one file with two names from two files with the same contents.

A field the family has no notion of is absent rather than null or zero. A FAT entry carries no inode and no links, because the format has neither inode numbers nor a second name for a node. A one there would be this tool answering a question the format never asked. What it does carry is a synthesized list naming the properties the report filled in rather than read. That list is always present, empty or not, so “the image recorded everything” and “this document did not say” stay distinguishable. In the table the link-count column reads - for such a family, which keeps the columns where a reader expects them.

One path’s metadata

This 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 (0 ns)
Modified:               2023-11-14T22:13:20Z (0 ns)
Changed:                2023-11-14T22:13:20Z (0 ns)
Created:                2023-11-14T22:13:20Z (0 ns)
Xattr security.capability: \x01\x00\x00\x02\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00

It reports the type, the mode both ways, ownership, size, and times. Where the family records them, it also reports the inode number, the link count, a device node’s numbers, a symlink’s target, and every extended attribute. A stored POSIX ACL is 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.

On a family that records fewer of them the report is shorter, and it says what it assumed:

$ ferrosys extract esp.img --stat /EFI/BOOT/BOOTX64.EFI
Path:                   /EFI/BOOT/BOOTX64.EFI
Type:                   file
Mode:                   0644 (-rw-r--r--)
Owner:                  0:0
Size:                   76672
Accessed:               2023-11-14T00:00:00Z (0 ns)
Modified:               2023-11-14T22:13:20Z (0 ns)
Changed:                2023-11-14T22:13:20Z (0 ns)
Created:                2023-11-14T22:13:20Z (640000000 ns)
Assumed:                ownership, permissions, change-time

Three things to read out of that. There is no Inode: and no Links: line, because the format has neither. The access time is midnight, because FAT records a date and no time of day for it. And the change time equals the modification time, because the format records no change time at all and the closest thing it has stands in. That is what the Assumed: line is there to say.

An attribute’s value is bytes, and it is rendered the way a name is. Printable characters go out as themselves, and everything else as a \xNN escape, with the backslash escaping itself so the rendering names exactly one value.

A bidirectional formatting character is escaped too, as \u{202e}. It is not a control character, and left alone it reorders the rest of the line, so photo\u{202e}gnp.exe would display as photo exe.png. To read the bytes rather than look at them, take --json. A value that is not text carries a value_hex field beside it holding the bytes exactly. The field’s presence is itself the signal that the value is not text.

A JSON document escapes both classes as well, as the \uXXXX escape the grammar defines. A parser therefore reads back the character the name held, and nothing between the document and the parser acts on it. The same rule covers every name a document carries: a path, a symlink target, an attribute name, a volume label.

In a JSON document the mode field is the permission bits as a decimal number, since JSON has no octal literal, so 509 is 0o775. mode_octal beside it carries the usual spelling.

--stat reports more of an entry than --list does. It adds a creation time, the extended attributes, a device node’s numbers, and, where the family records it, how many blocks the entry occupies. A listing leaves those out on every family, including the families that record them. Reading them costs a read per entry, and a listing is the shape a caller uses to walk a whole tree.

So a field’s absence from a listing entry means “a listing does not carry this”. A field’s absence from --stat means “this filesystem does not record it”. When the question is what one path holds, --stat is the answer that leaves nothing out.

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. Nothing structural therefore bounds what a read of one would write. An inode claiming sixteen tebibytes and mapping nothing costs an extraction sixteen tebibytes of zeros, from an image of a hundred kilobytes. A hole reads back as zeros and occupies nothing.

So extract caps it whether or not you ask. The default is sixteen times the length of the filesystem being read, which no ordinary file approaches and no crafted one survives. --max-file-bytes N names a different cap, for an image that holds a file legitimately sparser than that:

$ ferrosys extract suspect.img --cat /etc/passwd --max-file-bytes 64M
$ ferrosys extract sparse.img --to-dir out --max-file-bytes 512G

There is no spelling for “no cap”, and none is needed. The cap is a size, so a run that means to read a file of some size names that size.

Over the cap the read is an error, not a short file. A truncated file that looked whole would be the worse outcome, because a pipeline would carry it forward and never know. Where the cap that stopped a read was the default rather than one you named, the command says so on the standard error. It also says what raises it.

The cap is the same cap in every mode and on every family. --cat streams a file into the standard output and holds nothing, and it answers to the cap all the same. What a stream writes follows the length the image declares, so a mode that streamed without the cap would be the one way past it. A flag that meant something different depending on which family answered would be the worst of both.

detect

$ ferrosys detect rootfs.img
ext4
$ ferrosys detect --offset 1M disk.img
ext2
$ ferrosys detect esp.img
fat32

One word goes to the standard output, so it reads well in a shell test. That word is ext2, ext3, ext4, fat12, fat16, fat32, exfat, btrfs, or unrecognized. --json produces a document instead.

For every family this tool writes it is the same word format -t takes. What detect says about such an image is therefore what you would type to write another like it. btrfs is the word for a family it reads. --offset points it at a partition inside a whole-disk image, or at a region a carver located.

One further answer, unknown, is what a tool prints when the library classifies a family that build has no name for. Something recognized the image, so unrecognized would be the wrong word for it.

The families are tried in order of how distinctive their magic is. ext, and any family with a multi-byte magic at a fixed offset, are classified first. FAT is classified last and never by its boot signature. 0xAA55 at the end of sector 0 appears on every bootable sector ever written. That includes the master boot record of a disk whose partition holds an ext4 filesystem.

A FAT volume is claimed only when the whole parameter block is internally consistent. That is what keeps a healthy filesystem of another family from being misidentified as this one.

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.

identity

$ ferrosys identity --uuid "$(uuidgen)" --label rootfs rootfs.img
3 superblock copies written, journal superblock updated

Changes what an existing ext filesystem is known by: its UUID, its volume label, or the seed its metadata checksums derive from. It is the one command that writes to an image it did not create, and the only one whose destination already holds something worth keeping.

Every superblock copy is rewritten, the primary and each group’s backup alike. So is the journal’s own record of the UUID. No copy is left claiming the old identity. Each copy is patched where it lies, and keeps every field this change does not name, including the ones this tool has no opinion about.

Nothing is written until every copy has been read and every check has passed, so a refusal leaves the image exactly as it was. There is no --atomic. An image is rewritten in place, and a sibling temporary file would mean copying every byte of it to change sixteen.

The journal keeps its own copy of the UUID, and on most images a checksum over it. Linux sets csum_v3 on the log of any metadata_csum filesystem the first time it mounts one. An image that has ever been used therefore carries a crc32c covering the whole journal superblock. A rescue image being cloned is one such image, and so is a rootfs that was booted once and is now being re-stamped per device. That word is recomputed with the UUID, so the log stays one the kernel will load.

A journal whose stored checksum does not match its contents is refused, as a damaged filesystem superblock is. Writing a correct checksum over wrong bytes would replace a fault a checker finds with one it does not.

At least one of --uuid, --label, and --set-checksum-seed is required. A run that would write nothing is a command line that meant to say something.

--offset points it at a filesystem inside a whole-disk image, as every image-reading verb takes one. This command rewrites ext identity fields. Pointed at a sound volume of another family, it refuses with the word detect prints for what the image holds. The volume is fine and the request does not apply to it. That is a different verdict from a damaged filesystem, and it carries a different exit code.

When the UUID is the checksum seed

$ ferrosys identity --uuid "$(uuidgen)" legacy.img
legacy.img: changing the UUID would invalidate every metadata checksum: this filesystem
has metadata_csum without metadata_csum_seed, so its checksums are seeded from the UUID
itself — set the checksum seed to keep them valid
$ ferrosys identity --uuid "$(uuidgen)" --set-checksum-seed legacy.img
1 superblock copies written, journal superblock updated, metadata_csum_seed set

Under metadata_csum, every metadata object in the filesystem carries a checksum seeded from the filesystem’s seed. Those objects are each group descriptor, inode, bitmap, directory block, and extent node. Where metadata_csum_seed is set, that seed is a superblock field and the UUID is free to move. Where it is not, the seed is the UUID, so changing the UUID invalidates every checksum in the image at once.

That is refused rather than half-performed. --set-checksum-seed is the way through. It records the seed the current UUID implies and turns metadata_csum_seed on, after which the UUID moves and every existing checksum stays valid. It is asked for rather than assumed because it sets an incompatible feature. A kernel that does not know metadata_csum_seed will not mount the result.

Images ferrosys format writes carry metadata_csum_seed already, so this is a question about filesystems made elsewhere.

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

Or with a tree at both ends, and the size taken from the tree rather than named:

$ ferrosys format --size auto --slack 20% --uuid "$(uuidgen)" --time 1700000000 \
      --from-dir rootfs --owner 0:0 rootfs.img
$ ferrosys extract rootfs.img --to-dir unpacked --skip-privileged

Every time is UTC, printed as YYYY-MM-DDTHH:MM:SSZ. The tool has no time zone and no locale.