#![forbid(unsafe_code)]
mod args;
mod dest;
mod detect;
mod extract;
mod format;
mod identity;
mod inspect;
mod json;
mod parse;
mod render;
use std::ffi::OsString;
use std::io::{self, Write};
use std::path::Path;
use std::process::ExitCode;
use ferrosys::DetectError;
#[cfg(any(target_os = "linux", target_os = "android"))]
use ferrosys::ext::HostError;
use ferrosys::ext::{ArchiveError, FormatError, GeometryError, IdentityError, ReadError, Severity};
use crate::args::{Command, Topic, UsageError};
pub mod exit {
pub const OK: u8 = 0;
pub const IMAGE_BAD: u8 = 4;
pub const OPERATIONAL: u8 = 8;
pub const USAGE: u8 = 16;
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Usage(#[from] UsageError),
#[error("{what}: {source}")]
Io {
what: String,
#[source]
source: io::Error,
},
#[error(
"{0}: not a regular file — a format writes only the blocks the filesystem uses, \
so every other byte of the destination must already read as zero"
)]
NotARegularFile(String),
#[error("{path}: {source}")]
NotDetected {
path: String,
#[source]
source: DetectError,
},
#[error("{path}: not an ext filesystem: {source}")]
NotExt {
path: String,
#[source]
source: ReadError,
},
#[error("{path}: {source}")]
Identity {
path: String,
#[source]
source: ferrosys::ext::IdentityError,
},
#[error(transparent)]
Format(#[from] FormatError),
#[error(transparent)]
Archive(#[from] ArchiveError),
#[cfg(any(target_os = "linux", target_os = "android"))]
#[error(transparent)]
Host(#[from] HostError),
#[cfg(not(any(target_os = "linux", target_os = "android")))]
#[error(
"--from-dir is not available on this platform: walking a tree records Linux inode \
metadata and Linux extended attributes, so the directory source is built on Linux \
alone. --from-tar reads an archive anywhere"
)]
NoDirectorySource,
#[cfg(not(any(target_os = "linux", target_os = "android")))]
#[error(
"--to-dir is not available on this platform: writing a tree out sets Linux inode \
metadata and Linux extended attributes, so the directory sink is built on Linux \
alone. --to-tar writes the same contents as an archive anywhere"
)]
NoDirectorySink,
#[error("reading the image: {0}")]
ImageIo(String),
#[error("no such path in the filesystem: {}", String::from_utf8_lossy(.0))]
NoSuchPath(Vec<u8>),
#[error("the filesystem is malformed: {0}")]
Image(#[source] ReadError),
#[error(
"the filesystem holds {}{count} {}, the worst of them {}",
if *truncated { "at least " } else { "" },
if *count == 1 { "anomaly" } else { "anomalies" },
worst.as_str()
)]
Verdict {
count: usize,
worst: Severity,
truncated: bool,
},
#[error("{}: not a regular file", String::from_utf8_lossy(.0))]
NotAFile(Vec<u8>),
}
impl Error {
fn hint(&self) -> Option<String> {
match self {
Error::Format(FormatError::Geometry(GeometryError::TooSmall {
reserved_gdt_blocks,
..
})) if *reserved_gdt_blocks > 0 => Some(format!(
"{reserved_gdt_blocks} of those blocks are growth headroom: `--grow none` \
reserves none, and `--grow SIZE` reserves only what growing to SIZE needs"
)),
Error::Format(FormatError::FilesystemTooSmallForJournal { minimum, .. }) => {
Some(format!(
"a journal needs {minimum} blocks of its own: `-t ext2` builds a \
filesystem without one, as does `-O ^has_journal,^orphan_file`"
))
}
Error::Format(FormatError::JournalDoesNotFit { .. }) => Some(
"`--journal N` sets the log's size in filesystem blocks, and `-t ext2` \
builds without one"
.to_string(),
),
_ => None,
}
}
fn exit_code(&self) -> u8 {
match self {
Error::Usage(_) => exit::USAGE,
Error::Image(_) | Error::Verdict { .. } => exit::IMAGE_BAD,
Error::Archive(ArchiveError::Read(e)) => match e {
ReadError::Io { .. } => exit::OPERATIONAL,
_ => exit::IMAGE_BAD,
},
Error::Archive(ArchiveError::Acl { .. }) => exit::IMAGE_BAD,
Error::Identity { source, .. } => match source {
IdentityError::Read(ReadError::Io { .. }) | IdentityError::Io(_) => {
exit::OPERATIONAL
}
IdentityError::Read(_)
| IdentityError::BackupNotASuperblock { .. }
| IdentityError::SuperblockChecksumMismatch { .. } => exit::IMAGE_BAD,
_ => exit::OPERATIONAL,
},
#[cfg(any(target_os = "linux", target_os = "android"))]
Error::Host(_) => exit::OPERATIONAL,
#[cfg(not(any(target_os = "linux", target_os = "android")))]
Error::NoDirectorySource | Error::NoDirectorySink => exit::OPERATIONAL,
Error::Io { .. }
| Error::NotARegularFile(_)
| Error::NotDetected { .. }
| Error::NotExt { .. }
| Error::Format(_)
| Error::Archive(_)
| Error::ImageIo(_)
| Error::NoSuchPath(_)
| Error::NotAFile(_) => exit::OPERATIONAL,
}
}
fn io(what: impl AsRef<Path>, source: io::Error) -> Self {
Error::Io {
what: what.as_ref().display().to_string(),
source,
}
}
}
fn from_read(e: ReadError) -> Error {
match e {
ReadError::Io { message, .. } => Error::ImageIo(message),
ReadError::NotFound { path, .. } | ReadError::NotADirectory { path, .. } => {
Error::NoSuchPath(path)
}
other => Error::Image(other),
}
}
fn main() -> ExitCode {
let argv: Vec<OsString> = std::env::args_os().skip(1).collect();
let source_date_epoch = std::env::var_os("SOURCE_DATE_EPOCH");
match run(argv, source_date_epoch) {
Ok(()) => ExitCode::from(exit::OK),
Err(e) => {
eprintln!("{}: {e}", args::TOOL);
if let Some(hint) = e.hint() {
eprintln!("hint: {hint}");
}
if matches!(e, Error::Usage(_)) {
eprintln!("try `{} --help`", args::TOOL);
}
ExitCode::from(e.exit_code())
}
}
}
fn run(argv: Vec<OsString>, source_date_epoch: Option<OsString>) -> Result<(), Error> {
match args::parse(argv, source_date_epoch)? {
Command::Format(a) => format::run(*a),
Command::Inspect(a) => inspect::run(a),
Command::Extract(a) => extract::run(a),
Command::Detect(a) => detect::run(a),
Command::Identity(a) => identity::run(a),
Command::Help(topic) => emit(help(topic).as_bytes()),
Command::Version => {
emit(format!("{} {}\n", args::TOOL, env!("CARGO_PKG_VERSION")).as_bytes())
}
}
}
fn emit(bytes: &[u8]) -> Result<(), Error> {
let stdout = io::stdout();
let mut out = stdout.lock();
out.write_all(bytes).map_err(stdout_failed)?;
out.flush().map_err(stdout_failed)
}
fn stdout_failed(source: io::Error) -> Error {
Error::Io {
what: "standard output".to_string(),
source,
}
}
fn help(topic: Topic) -> &'static str {
match topic {
Topic::General => GENERAL_HELP,
Topic::Format => FORMAT_HELP,
Topic::Inspect => INSPECT_HELP,
Topic::Extract => EXTRACT_HELP,
Topic::Detect => DETECT_HELP,
Topic::Identity => IDENTITY_HELP,
}
}
const GENERAL_HELP: &str = "\
ferrosys — write, inspect, and read back ext2/3/4 filesystems
usage:
ferrosys format [options] OUT.img write a filesystem
ferrosys inspect [options] IMAGE report on a filesystem
ferrosys extract [options] IMAGE read a filesystem's contents back out
ferrosys detect [options] IMAGE say which filesystem an image holds
ferrosys identity [options] IMAGE change what a filesystem is known by
ferrosys <command> --help the options one command takes
ferrosys --version the version
exit codes (as e2fsck's):
0 the command did what it was asked
4 a filesystem was read and it is bad
8 the command could not be carried out
16 the command line could not be understood
The standard output carries exactly one artifact per run — a report, a listing, a tar
stream, or one file's bytes. Everything else goes to the standard error.
The tool reads neither the clock nor a random source, so a format's output is a function
of its inputs alone: the same inputs write the same bytes.
";
const FORMAT_HELP: &str = "\
ferrosys format — write an ext2, ext3, or ext4 filesystem
usage:
ferrosys format --size SIZE --uuid HEX --time SECS [options] OUT.img
ferrosys format --size 512M --uuid \"$(uuidgen)\" --time \"$(date +%s)\" rootfs.img
ferrosys format --size auto --slack 20% --from-dir staging \\
--uuid \"$(uuidgen)\" --time \"$(date +%s)\" rootfs.img
Both --uuid and --time are required because an image's bytes are a function of its
inputs alone: the tool reads neither the clock nor a random source, so the same inputs
write the same bytes. SOURCE_DATE_EPOCH supplies --time when it is set.
required:
--size SIZE|auto the filesystem's size: a byte count, optionally suffixed K, M, G,
or T — or `auto`, which sizes the filesystem to what goes in it.
`auto` finds the smallest filesystem that holds the contents by
planning candidate sizes and placing the contents into each, so
the size it settles on is one that formats, and one block less
does not. Use --slack to leave room in it
--uuid HEX the filesystem UUID, dashed or bare (32 hex digits). The tool
mints none: pipe in `uuidgen`, of whatever version you like
--time SECS the filesystem's creation time, in seconds since the epoch. Taken
from SOURCE_DATE_EPOCH when the option is absent
contents (at most one):
--from-tar FILE|- populate the filesystem from a tar archive. A named FILE is left on
disk and each member read as its file is placed, so peak memory is
the largest single member; `-` reads the standard input, which
cannot be sought back over and so is held whole. The archive must
be uncompressed — decompress it into `-` with `gunzip -c f.tar.gz |
ferrosys format ... --from-tar -`
--from-dir DIR populate the filesystem from a directory tree on this machine. DIR
itself becomes the filesystem root. Modes, ownership, all three
times, symlinks, hard links, device and FIFO nodes, sockets, and
extended attributes with their POSIX ACLs are all carried; symlinks
are recorded, never followed. Each file is read as it is placed, so
peak memory is the largest single file. Walking a tree records Linux
inode metadata and Linux extended attributes, so this option is
carried out on Linux alone; --from-tar reads an archive anywhere
--owner UID:GID own every entry of a --from-dir tree by this user and group,
whatever the host files say. A build that does not run as root
usually wants --owner 0:0: without it the image is owned by the
user that built it
labelling:
--label NAME the volume label, up to 16 bytes. A longer one is refused rather
than truncated
profile:
-t, --type ext2|ext3|ext4 the base feature set to write (default ext4). -O and the
geometry options layer on top, so `-t ext2 -O has_journal` is ext3.
The image is judged by the features it carries, not the profile it
started from
geometry:
--slack PCT%|SIZE with --size auto, how much of the filesystem must still be free
once the contents are written: `20%` of it, or `64M` of it. Without
this, `auto` leaves nothing — the right answer for an image that
will only be read, and useless for one that will be written to.
The share is of the finished filesystem, so `--slack 20%` is what
`df` reports as 80% used. Up to 90%
--block-size N 1024, 2048, or 4096 (the default)
--inode-size N a power of two from 128 up to the block size (default 256)
--inodes N the inode count, rounded up to fill each group's tables. Overrides
the size-driven default
--bytes-per-inode N one inode per N bytes of filesystem — the density the count is
derived from. This and --inodes share one setting; the last wins
--reserved-percent P blocks held back for the super-user, from 0 to 50, with up to two
decimal places (default 5)
-O feat,^feat,none turn features on and off, left to right, over the selected profile.
`none` clears every feature. The names are the on-disk ones:
64bit, metadata_csum, has_journal, … . filetype names the directory
format this tool always writes, so clearing it is refused. Clearing
extent drops to the block-mapped ext2/ext3 family, which then carries
none of the ext4-layer features (flex_bg, 64bit, metadata_csum, …);
-t is the direct way to that base
--grow none|max|SIZE reserved descriptor blocks, which are what let the filesystem grow
online without relocating its descriptor table. They are empty
blocks held at the front of the filesystem and cost free space 1:1,
and a filesystem that will not grow has no use for them:
none reserve nothing. The smallest image, and still growable
offline by an unmounted `resize2fs`
SIZE reserve exactly what growing online to SIZE needs — 3
blocks for 32G, 127 for 1T. The precise answer when the
largest device the image will be written to is known
max (default) as much as the format allows without spending
more than 1/64 of the filesystem on it: the full ~8 TiB
reach from 256M up, and a proportional share below that,
so a 16M image reserves 64 blocks and still grows to 512G
The format summary reports what was reserved and what is left free
--journal auto|N the journal's size in filesystem blocks (default: sized from the
filesystem). The journal is a real file and costs its size in free
space — 4 MiB of a 16 MiB filesystem — so `-t ext2` is the way to
build a small filesystem without one
--errors continue|remount-ro|panic what the kernel does on a detected filesystem
error (`s_errors`): note it and carry on, remount read-only, or
panic. Defaults to `continue`, the kernel's own default
determinism:
--fixed-time SECS force every inode's times to this value, whatever the source says
--hash half_md4|tea|legacy the directory-hash algorithm (default half_md4)
--hash-signedness signed|unsigned how a name's bytes are read when hashed. Unsigned by
default, which makes the bytes independent of the host
--hash-seed HEX the 16-byte directory-hash seed. Defaults to the UUID's bytes
output:
--json print the geometry the format realized, as JSON, on the standard
output. Without it, a summary goes to the standard error
--dry-run report the geometry this command would realize and write nothing.
The destination is not opened, created, or truncated
destination:
--atomic write the image to a sibling temporary file and rename it over the
destination once it is complete, so the destination holds either the
image that was there before or the whole new one — never a partial
one. Note that it becomes a new file: its mode comes from this
process's umask, and any ownership, ACLs, or extra hard links the
old file had do not survive. Without it the image is written in
place, and a failure part-way through leaves a partial image
The destination must be a regular file. A format writes only the blocks the filesystem
uses, so every byte it does not write must already read as zero — which a block device
does not guarantee, and which is why the file is created or truncated as part of
formatting. That happens only once the archive has parsed and the geometry has planned, so
a run that fails for any other reason leaves the file that was there untouched.
";
const INSPECT_HELP: &str = "\
ferrosys inspect — report on an ext filesystem
usage:
ferrosys inspect [options] IMAGE
options:
--offset N where the filesystem begins within the file, for a partition
inside a whole-disk image. A byte count, optionally suffixed K, M,
G, or T
--json report as JSON rather than as text
--sarif report the scan's findings as a SARIF 2.1.0 log, for a static-
analysis or forensic pipeline; reports findings alone, not the
superblock description, so it needs the scan and cannot pair with
--quick
--groups report every block group's descriptor as well
--quick report the superblock alone, without scanning the image
--fail-on SEVERITY|never
the severity at which the scan's findings make the filesystem bad:
cosmetic, conformance, integrity (the default), structural, or
never. `integrity` faults a filesystem whose own bytes contradict
each other; `conformance` also faults one that is valid ext but not
the form this tool writes, which is a check on this tool's own output
rather than on ext
The whole image is scanned unless --quick says otherwise, so an image that is bad is
reported as bad (exit 4) rather than merely described. What counts as bad is --fail-on,
which defaults to `integrity`: a filesystem whose own bytes contradict each other fails,
and a valid ext filesystem that another tool wrote does not. That is the default a CI
gate inherits, and `--fail-on conformance` is the stricter line to draw deliberately.
";
const EXTRACT_HELP: &str = "\
ferrosys extract — read an ext filesystem's contents back out
usage:
ferrosys extract [--offset N] IMAGE --to-tar FILE|-
ferrosys extract [--offset N] IMAGE --to-dir DIR
ferrosys extract [--offset N] IMAGE --cat PATH
ferrosys extract [--offset N] IMAGE --stat PATH [--json]
ferrosys extract [--offset N] IMAGE --list [--json]
exactly one of:
--to-tar FILE|- write the whole tree as a tar archive; `-` writes the standard
output. Ownership, modes, times (to the nanosecond), symlinks,
hard links, device and FIFO nodes, extended attributes, and POSIX
ACLs all survive, carried in PAX records
--to-dir DIR write the whole tree into a directory on this host, the inverse of
`format --from-dir`. DIR is made if it is not there and must be
empty. Everything the archive carries is carried here too, set on
the files themselves; DIR takes the filesystem root's own mode,
ownership, times, and attributes, and `/lost+found` is not written.
A device node needs CAP_MKNOD and a recorded owner needs CAP_CHOWN,
so an unprivileged run stops at the first of either unless
--skip-privileged is given. Two things no host lets a caller set:
an inode's change time and its creation time, so the tree carries
the times it was written for those two alone
--cat PATH write one file's bytes to the standard output, and nothing else.
PATH is a path inside the image, taken as the bytes you typed
--stat PATH report everything the filesystem records about one path: its type,
mode (octal and symbolic), ownership, link count, size, all four
times, a device node's numbers, a symlink's target, and its extended
attributes with any POSIX ACL decoded. A path naming a symlink
describes the link, not its target; --json reports it as JSON
--list list the tree; --json lists it as JSON, with each entry's extended
attributes and decoded ACLs
options:
--offset N where the filesystem begins within the file
--max-file-bytes N refuse to read a file larger than N bytes, for an image whose
declared sizes have not earned trust: a file's size is the image's
own claim, and a sparse file legitimately dwarfs the filesystem
holding it, so nothing structural bounds it. Over the cap the read
is an error rather than a short file
--skip-privileged with --to-dir, write what this process may rather than failing on
what it may not: a device node it cannot create is left out, the
tree is owned by this process, and a security or trusted extended
attribute it may not set is not set. What was left out is named on
the standard error, so an incomplete tree says so
--atomic with --to-tar FILE, write the archive to a sibling temporary file
and rename it over FILE once the walk is complete, so a walk that
fails part-way leaves whatever was at FILE untouched. --to-dir has
no equivalent — no rename publishes a whole tree at once — which is
why its destination must start empty
Reading holds no whole file: --cat streams to the standard output and --to-tar streams each
member into the archive, so a multi-gigabyte file costs a working set rather than its size.
The archive holds a `./` member for the root and skips `/lost+found`, so what comes out
is what `ferrosys format --from-tar` reads back in; --to-dir writes the tree the same way,
so `format --from-dir` reads that one back.
A JSON mode's `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.
";
const DETECT_HELP: &str = "\
ferrosys detect — say which filesystem an image holds
usage:
ferrosys detect [--offset N] [--json] IMAGE
options:
--offset N where the filesystem begins within the file, for a partition inside
a whole-disk image or a region a carver located. A byte count,
optionally suffixed K, M, G, or T
--json report as JSON rather than as one word
The answer is one word on the standard output — ext2, ext3, ext4, or `unrecognized` — so it
reads well in a shell test. An unrecognized image exits 8, since there is no filesystem to
have an opinion about. A fourth word, `unknown`, is the answer when the library classifies
a family this build has no name for: something recognized the image, so calling it
unrecognized would be wrong.
This asks what an image *is*, not whether it is sound: an image with a quirk `inspect` would
refuse still classifies here. Use `inspect` to be told whether a filesystem is well-formed.
";
const IDENTITY_HELP: &str = "\
ferrosys identity — change what an existing filesystem is known by
usage:
ferrosys identity [--uuid HEX] [--label TEXT] [--set-checksum-seed] [--json] IMAGE
options:
--uuid HEX the new filesystem UUID: 32 hex digits, dashed or bare
--label TEXT the new volume label, at most 16 bytes
--set-checksum-seed record the seed the current UUID implies and set
metadata_csum_seed, so the UUID can change without invalidating the
filesystem's metadata checksums
--json report what was written as JSON rather than as text
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.
Every superblock copy is rewritten — the primary and each group's backup — along with the
journal's own record of the UUID, so no copy is left claiming the old identity. Each copy
is patched in place: it keeps every field this change does not name.
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 where it
lies, and a temporary copy would mean writing every byte of it to change sixteen.
A filesystem with metadata_csum and without metadata_csum_seed seeds every checksum it
holds from the UUID itself, so changing the UUID would invalidate all of them at once.
That is refused, and --set-checksum-seed is the way through: it records the seed the
current UUID implies, after which the UUID moves and every existing checksum stays valid.
It sets an incompatible feature, so a kernel that does not know metadata_csum_seed will
not mount the result — which is why it is asked for rather than assumed.
";
#[cfg(test)]
mod tests {
use super::*;
use ferrosys::ext::ondisk::Timestamp;
use ferrosys::ext::{FormatOptions, Reader, TreeBuilder};
#[test]
fn every_failure_names_the_exit_code_it_reports() {
assert_eq!(Error::Usage(UsageError::NoCommand).exit_code(), exit::USAGE);
assert_eq!(
Error::Image(ReadError::BadDirectory).exit_code(),
exit::IMAGE_BAD
);
assert_eq!(
Error::Verdict {
count: 1,
worst: Severity::Structural,
truncated: false,
}
.exit_code(),
exit::IMAGE_BAD
);
assert_eq!(
Error::Verdict {
count: 10_000,
worst: Severity::Structural,
truncated: true,
}
.to_string(),
"the filesystem holds at least 10000 anomalies, the worst of them structural"
);
assert_eq!(
Error::Verdict {
count: 1,
worst: Severity::Integrity,
truncated: false,
}
.to_string(),
"the filesystem holds 1 anomaly, the worst of them integrity"
);
assert_eq!(
Error::NotExt {
path: "blob".into(),
source: ReadError::BadJournal
}
.exit_code(),
exit::OPERATIONAL
);
assert_eq!(
Error::NoSuchPath(b"/nowhere".to_vec()).exit_code(),
exit::OPERATIONAL
);
}
#[test]
fn a_read_failure_is_the_image_s_only_when_it_is_about_the_image() {
assert_eq!(
from_read(ReadError::BadDirectory).exit_code(),
exit::IMAGE_BAD
);
let host_failed = ReadError::from(std::io::Error::other("disk on fire"));
assert_eq!(from_read(host_failed).exit_code(), exit::OPERATIONAL);
let time = Timestamp::from_secs(1_700_000_000);
let image = ferrosys::ext::format(
TreeBuilder::new(),
64 << 20,
FormatOptions::new([0x11; 16], time, [0; 16]),
)
.expect("format a minimal image");
let mut reader =
Reader::open(std::io::Cursor::new(image.as_bytes())).expect("open the image");
let absent = reader
.lookup(b"/nowhere")
.expect_err("the image has no such path");
assert_eq!(from_read(absent).exit_code(), exit::OPERATIONAL);
}
#[test]
fn every_help_topic_has_text() {
for topic in [
Topic::General,
Topic::Format,
Topic::Inspect,
Topic::Extract,
Topic::Detect,
] {
let text = help(topic);
assert!(text.starts_with("ferrosys"), "{topic:?} names the tool");
assert!(text.contains("usage:"), "{topic:?} states its usage");
}
let general = help(Topic::General);
for command in ["format", "inspect", "extract", "detect"] {
assert!(
general.contains(command),
"the general help lists the `{command}` command"
);
assert!(
help_for(command).contains(command),
"the `{command}` help names its own command"
);
}
}
fn help_for(command: &str) -> &'static str {
match command {
"format" => help(Topic::Format),
"inspect" => help(Topic::Inspect),
"extract" => help(Topic::Extract),
"detect" => help(Topic::Detect),
other => panic!("no help topic for {other}"),
}
}
}