use std::sync::Arc;
use clap::{Arg, ArgAction, ArgMatches, Command as Cmd};
use fs_core::cli::{CliError, Json, Outcome, Tool};
use fs_ext4::block_io::{BlockDevice, FileDevice};
use fs_ext4::mkfs::{
format_filesystem, is_valid_block_size, DEFAULT_BLOCK_SIZE, MAX_BLOCK_SIZE, MIN_BLOCK_SIZE,
};
pub const TOOL: Tool = Tool {
name: "mkfs.ext4",
verb: "mkfs",
section: 8,
usage_exit: fs_core::cli::output::EXIT_USAGE,
about: "Create an ext4 filesystem on a device or an image file",
command,
run,
};
pub const IGNORED_FLAGS_WITH_ARG: &[char] = &['m', 'N', 'i', 'E', 'O', 'T'];
pub const IGNORED_BOOLEAN_FLAGS: &[char] = &['c'];
const MAX_LABEL_BYTES: usize = 16;
fn command() -> Cmd {
let mut cmd = Cmd::new("mkfs.ext4")
.about("Create an ext4 filesystem on a device or an image file")
.long_about(
"Create an ext4 filesystem on a device or a pre-sized image file.\n\n\
The device or file must already exist at the target size (`truncate -s 64M \
out.img`), unless --size is given for an image file that does not exist yet.\n\n\
A JSON report of what was written, read back from the new superblock, goes \
to stdout; progress goes to stderr.",
)
.arg(
Arg::new("device")
.value_name("TARGET")
.help("Block device or image file to format")
.required(true),
)
.arg(
Arg::new("label")
.short('L')
.long("label")
.value_name("LABEL")
.help("Volume label, at most 16 bytes of UTF-8")
.value_parser(parse_label),
)
.arg(
Arg::new("block-size")
.short('b')
.long("block-size")
.value_name("BYTES")
.help(format!(
"Block size: a power of two, {MIN_BLOCK_SIZE}..={MAX_BLOCK_SIZE}. \
Default: {DEFAULT_BLOCK_SIZE}."
))
.value_parser(parse_block_size),
)
.arg(
Arg::new("uuid")
.short('U')
.long("uuid")
.value_name("UUID")
.help("Volume UUID, 32 hex digits, dashes optional. Default: random.")
.value_parser(parse_uuid),
)
.arg(
Arg::new("force")
.short('F')
.long("force")
.help("Format even if the device looks in use (accepted; nothing is inspected yet)")
.action(ArgAction::Count),
)
.arg(
Arg::new("dry-run")
.short('n')
.long("dry-run")
.help("Open the device and report, but write nothing")
.action(ArgAction::Count),
)
.arg(
Arg::new("quiet")
.short('q')
.long("quiet")
.help("No progress or warnings on stderr")
.action(ArgAction::Count),
)
.arg(
Arg::new("size")
.long("size")
.visible_alias("create-size")
.value_name("SIZE")
.help(
"Create TARGET as an image file of SIZE bytes first, if it does not exist \
(K/M/G/T suffixes, 1024-based). Refused for block and character devices.",
)
.value_parser(parse_size),
)
.args(fs_core::cli::format_args())
.after_help(
"Examples:\n \
mkfs.ext4 --size 64M --label BACKUP disk.img\n \
truncate -s 1G disk.img && mkfs.ext4 -b 4096 disk.img\n \
mkfs.ext4 -n disk.img open and report, write nothing\n \
mkfs.ext4 --text -q disk.img print nothing on success\n\n\
Accepted and ignored, with a warning, because scripts pass them: \
-m -N -i -E -O -T (each takes a value), and -c.",
);
for &flag in IGNORED_FLAGS_WITH_ARG {
cmd = cmd.arg(
Arg::new(ignored_id(flag))
.short(flag)
.value_name("VALUE")
.hide(true)
.action(ArgAction::Append)
.allow_hyphen_values(true),
);
}
for &flag in IGNORED_BOOLEAN_FLAGS {
cmd = cmd.arg(
Arg::new(ignored_id(flag))
.short(flag)
.hide(true)
.action(ArgAction::Count),
);
}
cmd
}
fn ignored_id(flag: char) -> &'static str {
match flag {
'm' => "ignored-m",
'N' => "ignored-N",
'i' => "ignored-i",
'E' => "ignored-E",
'O' => "ignored-O",
'T' => "ignored-T",
'c' => "ignored-c",
other => unreachable!("-{other} is not an ignored flag"),
}
}
#[derive(Debug)]
struct Opts {
device: String,
label: Option<String>,
block_size: Option<u32>,
uuid: Option<[u8; 16]>,
dry_run: bool,
quiet: bool,
create_size: Option<u64>,
warnings: Vec<String>,
}
fn opts(matches: &ArgMatches) -> Opts {
let mut warnings: Vec<(usize, String)> = Vec::new();
for &flag in IGNORED_FLAGS_WITH_ARG {
let id = ignored_id(flag);
if let (Some(values), Some(indices)) =
(matches.get_many::<String>(id), matches.indices_of(id))
{
for (value, index) in values.zip(indices) {
warnings.push((index, format!("-{flag} {value} not yet honored, ignoring")));
}
}
}
for &flag in IGNORED_BOOLEAN_FLAGS {
let id = ignored_id(flag);
if matches.value_source(id) != Some(clap::parser::ValueSource::CommandLine) {
continue;
}
if let Some(indices) = matches.indices_of(id) {
for index in indices {
warnings.push((index, format!("-{flag} not yet honored, ignoring")));
}
}
}
warnings.sort_by_key(|(index, _)| *index);
Opts {
device: matches
.get_one::<String>("device")
.cloned()
.expect("clap requires the device"),
label: matches.get_one::<String>("label").cloned(),
block_size: matches.get_one::<u32>("block-size").copied(),
uuid: matches.get_one::<[u8; 16]>("uuid").copied(),
dry_run: matches.get_count("dry-run") > 0,
quiet: matches.get_count("quiet") > 0,
create_size: matches.get_one::<u64>("size").copied(),
warnings: warnings.into_iter().map(|(_, w)| w).collect(),
}
}
fn run(matches: &ArgMatches) -> Result<Outcome, CliError> {
let opts = opts(matches);
let say = |line: String| {
if !opts.quiet {
eprintln!("mkfs.ext4: {line}");
}
};
for warning in &opts.warnings {
say(format!("warning: {warning}"));
}
let device = opts.device.as_str();
let block_size = opts.block_size.unwrap_or(DEFAULT_BLOCK_SIZE);
let mut would_create: Option<u64> = None;
if let Some(n) = opts.create_size {
match std::fs::metadata(device) {
Ok(meta) => {
let ft = meta.file_type();
#[cfg(unix)]
{
use std::os::unix::fs::FileTypeExt;
if ft.is_block_device() || ft.is_char_device() {
return Err(CliError::failed(format!(
"--create-size refuses to apply to {device}: looks like a real \
block/char device, not a regular file. Did you mean to leave \
--create-size off?"
)));
}
}
if !ft.is_file() {
return Err(CliError::failed(format!(
"--create-size: {device} exists but is not a regular file"
)));
}
say(format!(
"--create-size: {device} already exists ({} bytes); leaving as-is",
meta.len()
));
}
Err(_) if opts.dry_run => {
say(format!("--create-size: would create {device} ({n} bytes)"));
would_create = Some(n);
}
Err(_) => {
let f = std::fs::File::create(device).map_err(|e| {
CliError::failed(format!("--create-size: create {device}: {e}"))
})?;
f.set_len(n).map_err(|e| {
CliError::failed(format!("--create-size: set_len({n}) on {device}: {e}"))
})?;
drop(f);
say(format!("--create-size: created {device} ({n} bytes)"));
}
}
}
let (dev, size) = match would_create {
Some(n) => (None, n),
None => {
let dev = FileDevice::open_rw(device)
.map_err(|e| CliError::failed(format!("open {device} read-write: {e:?}")))?;
let size = dev.size_bytes();
(Some(dev), size)
}
};
if size == 0 {
return Err(CliError::failed(format!(
"device {device} reports size 0 — pre-create with truncate / fsutil first"
)));
}
say(format!(
"formatting {device} ({size} bytes, block_size={block_size}{})",
if opts.dry_run { ", dry-run" } else { "" }
));
let base = [
("fs", Json::from("ext4")),
("device", Json::from(device)),
("device_bytes", Json::from(size)),
("dry_run", Json::from(opts.dry_run)),
];
if opts.dry_run {
say("dry-run — no writes performed".to_string());
let mut report: Vec<(&str, Json)> = base.to_vec();
report.push(("formatted", Json::from(false)));
report.push(("block_size", Json::from(block_size)));
report.push(("label", Json::from(opts.label.clone())));
return Ok(Outcome::report(Json::object(report)).with_text(String::new()));
}
let Some(dev) = dev else {
unreachable!("only a dry run leaves the device unopened");
};
format_filesystem(&dev, opts.label.as_deref(), opts.uuid, size, block_size)
.map_err(|e| CliError::failed(format!("format failed: {e:?}")))?;
dev.flush()
.map_err(|e| CliError::failed(format!("flush failed: {e:?}")))?;
say(format!("{device} formatted successfully"));
let fs = fs_ext4::Filesystem::mount(Arc::new(dev))
.map_err(|e| CliError::failed(format!("read back {device} after formatting: {e:?}")))?;
let sb = &fs.sb;
let mut report: Vec<(&str, Json)> = base.to_vec();
report.push(("formatted", Json::from(true)));
report.push((
"label",
if sb.volume_name.is_empty() {
Json::Null
} else {
Json::from(sb.volume_name.as_str())
},
));
report.push(("uuid", Json::from(format_uuid(&sb.uuid))));
report.push(("block_size", Json::from(sb.block_size())));
report.push((
"total_bytes",
Json::from(sb.blocks_count * u64::from(sb.block_size())),
));
report.push(("total_blocks", Json::from(sb.blocks_count)));
report.push(("free_blocks", Json::from(sb.free_blocks_count)));
report.push(("total_inodes", Json::from(sb.inodes_count)));
report.push(("free_inodes", Json::from(sb.free_inodes_count)));
Ok(Outcome::report(Json::object(report)).with_text(String::new()))
}
pub fn format_uuid(u: &[u8; 16]) -> String {
let hex: String = u.iter().map(|b| format!("{b:02x}")).collect();
format!(
"{}-{}-{}-{}-{}",
&hex[0..8],
&hex[8..12],
&hex[12..16],
&hex[16..20],
&hex[20..32]
)
}
fn parse_label(v: &str) -> Result<String, String> {
if v.len() > MAX_LABEL_BYTES {
return Err(format!(
"label too long ({} bytes); ext4 max is {MAX_LABEL_BYTES} bytes UTF-8",
v.len()
));
}
Ok(v.to_string())
}
fn parse_block_size(v: &str) -> Result<u32, String> {
let n: u32 = v.parse().map_err(|_| format!("not a valid number: {v}"))?;
if !is_valid_block_size(n) {
return Err(format!(
"block size must be a power of two in {MIN_BLOCK_SIZE}..={MAX_BLOCK_SIZE}, got {n}"
));
}
Ok(n)
}
pub fn parse_size(s: &str) -> Result<u64, String> {
let trimmed = s.trim();
if trimmed.is_empty() {
return Err("empty size argument".to_string());
}
let s = trimmed.strip_suffix(['B', 'b']).unwrap_or(trimmed);
let (num, mult): (&str, u64) = match s.chars().last() {
Some('K' | 'k') => (&s[..s.len() - 1], 1 << 10),
Some('M' | 'm') => (&s[..s.len() - 1], 1 << 20),
Some('G' | 'g') => (&s[..s.len() - 1], 1 << 30),
Some('T' | 't') => (&s[..s.len() - 1], 1 << 40),
Some(c) if c.is_ascii_digit() => (s, 1),
_ => return Err(format!("unrecognised size suffix in {s:?}")),
};
let n: u64 = num
.parse()
.map_err(|_| format!("not a valid number: {num:?}"))?;
n.checked_mul(mult)
.ok_or_else(|| format!("{s} overflows u64"))
}
fn parse_uuid(s: &str) -> Result<[u8; 16], String> {
let cleaned: String = s.chars().filter(|c| *c != '-').collect();
if let Some(bad) = cleaned.chars().find(|c| !c.is_ascii_hexdigit()) {
return Err(format!(
"UUID must be hex digits and dashes only, got {bad:?}"
));
}
if cleaned.len() != 32 {
return Err(format!(
"UUID must be 32 hex chars (with optional dashes), got {} chars",
cleaned.len()
));
}
let mut out = [0u8; 16];
for (i, byte) in out.iter_mut().enumerate() {
*byte = u8::from_str_radix(&cleaned[i * 2..i * 2 + 2], 16)
.map_err(|_| format!("UUID has non-hex character near position {}", i * 2))?;
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(argv: &[&str]) -> Result<Opts, clap::Error> {
let mut full = vec!["mkfs.ext4"];
full.extend_from_slice(argv);
command().try_get_matches_from(full).map(|m| opts(&m))
}
#[test]
fn the_help_states_the_default_block_size_from_the_constant() {
let help = command().render_long_help().to_string();
assert!(
help.contains(&format!("Default: {DEFAULT_BLOCK_SIZE}.")),
"{help}"
);
}
#[test]
fn dash_c_is_boolean_and_leaves_the_device_alone() {
let opts = parse(&["-c", "/x/disk.img"]).expect("parse");
assert_eq!(opts.device, "/x/disk.img");
assert_eq!(opts.warnings, vec!["-c not yet honored, ignoring"]);
}
#[test]
fn argument_taking_ignored_flags_consume_their_value() {
for &flag in IGNORED_FLAGS_WITH_ARG {
let dash = format!("-{flag}");
let opts = parse(&[&dash, "1", "/x/disk.img"]).expect("parse");
assert_eq!(opts.device, "/x/disk.img", "{dash} ate the device");
assert_eq!(
opts.warnings,
vec![format!("{dash} 1 not yet honored, ignoring")]
);
}
}
#[test]
fn argument_taking_ignored_flag_without_a_value_is_an_error() {
assert!(parse(&["-m"]).is_err());
}
#[test]
fn out_of_range_block_sizes_are_rejected_at_parse_time() {
for bad in ["3000", "512", "131072", "0"] {
let err = parse(&["-b", bad, "/x/disk.img"])
.expect_err(bad)
.to_string();
assert!(
err.contains("block size must be a power of two"),
"{bad}: {err}"
);
}
}
#[test]
fn in_range_block_sizes_parse() {
for good in ["1024", "4096", "65536"] {
let opts = parse(&["-b", good, "/x/disk.img"]).expect("parse");
assert_eq!(opts.block_size, Some(good.parse().unwrap()));
}
}
#[test]
fn quiet_is_independent_of_flag_order() {
let early = parse(&["-q", "-m", "1", "/x/disk.img"]).expect("parse");
let late = parse(&["-m", "1", "-q", "/x/disk.img"]).expect("parse");
assert!(early.quiet && late.quiet);
assert_eq!(early.warnings, late.warnings);
}
#[test]
fn warnings_keep_command_line_order_across_flags() {
let opts = parse(&["-T", "small", "-c", "-m", "0", "/x/disk.img"]).expect("parse");
assert_eq!(
opts.warnings,
vec![
"-T small not yet honored, ignoring",
"-c not yet honored, ignoring",
"-m 0 not yet honored, ignoring",
]
);
}
#[test]
fn unknown_flags_are_still_rejected() {
assert!(parse(&["-Z", "/x/disk.img"]).is_err());
}
#[test]
fn size_is_the_shared_spelling_and_create_size_still_works() {
for flag in ["--size", "--create-size"] {
let opts = parse(&[flag, "64M", "/x/disk.img"]).expect("parse");
assert_eq!(opts.create_size, Some(64 << 20), "{flag}");
}
}
#[test]
fn a_second_device_is_refused() {
assert!(parse(&["/x/a.img", "/x/b.img"]).is_err());
}
#[test]
fn long_labels_are_refused() {
assert!(parse(&["-L", "seventeen-bytes-x", "/x/disk.img"]).is_err());
assert!(parse(&["-L", "sixteen-bytes-xx", "/x/disk.img"]).is_ok());
}
#[test]
fn repeated_flags_are_accepted_as_the_standard_formatter_accepts_them() {
assert!(parse(&["-F", "-F", "-q", "-q", "/x/disk.img"]).is_ok());
}
}