use std::ffi::{OsStr, OsString};
use std::path::PathBuf;
use ferrosys::ext::ondisk::Timestamp;
use ferrosys::ext::{
ErrorBehavior, FeatureError, FeatureSet, GrowReservation, HashSignedness, HashVersion,
InodeCount, JournalSize, Profile, ReservedRatio, Severity, Slack,
};
use crate::parse::{self, ValueError};
pub mod os {
use std::ffi::{OsStr, OsString};
#[cfg(unix)]
pub fn bytes(s: &OsStr) -> &[u8] {
std::os::unix::ffi::OsStrExt::as_bytes(s)
}
#[cfg(not(unix))]
pub fn bytes(s: &OsStr) -> &[u8] {
s.as_encoded_bytes()
}
#[cfg(unix)]
pub fn string(b: &[u8]) -> Option<OsString> {
Some(<OsStr as std::os::unix::ffi::OsStrExt>::from_bytes(b).to_owned())
}
#[cfg(not(unix))]
pub fn string(b: &[u8]) -> Option<OsString> {
std::str::from_utf8(b).ok().map(OsString::from)
}
}
pub const TOOL: &str = "ferrosys";
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum Command {
Format(Box<FormatArgs>),
Inspect(InspectArgs),
Extract(ExtractArgs),
Detect(DetectArgs),
Identity(IdentityArgs),
Help(Topic),
Version,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Topic {
General,
Format,
Inspect,
Extract,
Detect,
Identity,
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum Stream {
Std,
File(PathBuf),
}
impl Stream {
fn from_value(v: OsString) -> Self {
if v == OsStr::new("-") {
Stream::Std
} else {
Stream::File(PathBuf::from(v))
}
}
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum Contents {
Tar(Stream),
Dir(PathBuf),
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Size {
Bytes(u64),
Fit(Slack),
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct FormatArgs {
pub out: PathBuf,
pub size: Size,
pub uuid: [u8; 16],
pub time: Timestamp,
pub contents: Option<Contents>,
pub owner: Option<(u32, u32)>,
pub feature: FeatureSet,
pub errors: ErrorBehavior,
pub inodes: InodeCount,
pub reserved: ReservedRatio,
pub volume_name: [u8; 16],
pub grow: GrowReservation,
pub journal: JournalSize,
pub fixed_time: Option<Timestamp>,
pub hash_version: HashVersion,
pub hash_signedness: HashSignedness,
pub hash_seed: [u8; 16],
pub json: bool,
pub atomic: bool,
pub dry_run: bool,
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct InspectArgs {
pub image: PathBuf,
pub offset: u64,
pub json: bool,
pub sarif: bool,
pub groups: bool,
pub quick: bool,
pub fail_on: Option<Severity>,
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct DetectArgs {
pub image: PathBuf,
pub offset: u64,
pub json: bool,
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct IdentityArgs {
pub image: PathBuf,
pub uuid: Option<[u8; 16]>,
pub volume_name: Option<[u8; 16]>,
pub set_checksum_seed: bool,
pub json: bool,
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct ExtractArgs {
pub image: PathBuf,
pub offset: u64,
pub mode: ExtractMode,
pub max_file_bytes: Option<u64>,
pub atomic: bool,
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum ExtractMode {
ToTar(Stream),
ToDir {
path: PathBuf,
skip_privileged: bool,
},
Cat(Vec<u8>),
Stat {
path: Vec<u8>,
json: bool,
},
List {
json: bool,
},
}
#[derive(Clone, PartialEq, Eq, Debug, thiserror::Error)]
pub enum UsageError {
#[error("no command given")]
NoCommand,
#[error("{0}: not a command")]
UnknownCommand(String),
#[error(fmt = fmt_unknown_flag)]
UnknownFlag {
command: &'static str,
flag: String,
},
#[error("{0} needs a value")]
MissingValue(String),
#[error("{0} takes no value")]
UnexpectedValue(String),
#[error("{command}: {flag} is required")]
MissingRequired {
command: &'static str,
flag: &'static str,
},
#[error("{command}: no {what} given")]
MissingArgument {
command: &'static str,
what: &'static str,
},
#[error("{command}: unexpected argument {value}")]
UnexpectedArgument {
command: &'static str,
value: String,
},
#[error("{flag}: {source}")]
Value {
flag: String,
#[source]
source: ValueError,
},
#[error(transparent)]
Feature(#[from] FeatureError),
#[error("format: give at most one of --from-tar or --from-dir")]
TwoSources,
#[error(
"format: --slack is the room to leave in a filesystem sized to its contents, so \
it goes with --size auto"
)]
SlackWithoutFit,
#[error(
"format: --owner replaces the ownership a walked directory tree records, so it \
goes with --from-dir"
)]
OwnerWithoutDir,
#[error("extract: give exactly one of --to-tar, --to-dir, --cat, --stat, or --list")]
ExtractMode,
#[error("extract: --skip-privileged applies to --to-dir")]
SkipPrivilegedWithoutDir,
#[error("extract: --json applies to --list and --stat")]
JsonWithoutReport,
#[error("extract: --atomic applies to --to-tar FILE")]
AtomicWithoutFile,
#[error("inspect: --sarif and --json are different output formats; give one")]
SarifWithJson,
#[error("inspect: --sarif reports scan findings, which --quick skips")]
SarifWithQuick,
#[error("inspect: --sarif reports scan findings; --groups has no place in one")]
SarifWithGroups,
#[error("{0}: the value is not text this platform can name a file with")]
NotAFilename(String),
}
fn fmt_unknown_flag(
command: &str,
flag: &str,
f: &mut std::fmt::Formatter<'_>,
) -> std::fmt::Result {
if command.is_empty() {
write!(f, "{flag}: not an option")
} else {
write!(f, "{command}: {flag}: not an option")
}
}
#[derive(Clone, PartialEq, Eq, Debug)]
enum Arg {
Long(String, Option<OsString>),
Short(char, Option<OsString>),
Positional(OsString),
}
struct Args {
rest: std::vec::IntoIter<OsString>,
ended: bool,
}
impl Args {
fn new(argv: Vec<OsString>) -> Self {
Self {
rest: argv.into_iter(),
ended: false,
}
}
fn next(&mut self) -> Result<Option<Arg>, UsageError> {
let Some(token) = self.rest.next() else {
return Ok(None);
};
if self.ended {
return Ok(Some(Arg::Positional(token)));
}
let bytes = os::bytes(&token);
if bytes == b"--" {
self.ended = true;
return self.next();
}
if let Some(rest) = bytes.strip_prefix(b"--") {
let (name, value) = match rest.iter().position(|&b| b == b'=') {
Some(i) => (&rest[..i], Some(&rest[i + 1..])),
None => (rest, None),
};
let name = flag_name(name).ok_or_else(|| UsageError::UnknownFlag {
command: "",
flag: token.to_string_lossy().into_owned(),
})?;
let value = match value {
Some(v) => Some(os::string(v).ok_or_else(|| {
UsageError::NotAFilename(token.to_string_lossy().into_owned())
})?),
None => None,
};
return Ok(Some(Arg::Long(name, value)));
}
if bytes.len() > 1 && bytes[0] == b'-' {
let letter = char::from(bytes[1]);
if !letter.is_ascii_alphabetic() {
return Err(UsageError::UnknownFlag {
command: "",
flag: token.to_string_lossy().into_owned(),
});
}
let value = if bytes.len() > 2 {
Some(os::string(&bytes[2..]).ok_or_else(|| {
UsageError::NotAFilename(token.to_string_lossy().into_owned())
})?)
} else {
None
};
return Ok(Some(Arg::Short(letter, value)));
}
Ok(Some(Arg::Positional(token)))
}
fn value(&mut self, flag: &str, attached: Option<OsString>) -> Result<OsString, UsageError> {
match attached {
Some(v) => Ok(v),
None => self
.rest
.next()
.ok_or_else(|| UsageError::MissingValue(flag.to_string())),
}
}
fn no_value(flag: &str, attached: Option<OsString>) -> Result<(), UsageError> {
match attached {
None => Ok(()),
Some(_) => Err(UsageError::UnexpectedValue(flag.to_string())),
}
}
}
fn flag_name(bytes: &[u8]) -> Option<String> {
let name = std::str::from_utf8(bytes).ok()?;
name.is_ascii().then(|| name.to_string())
}
fn value_err(flag: &str) -> impl Fn(ValueError) -> UsageError + '_ {
move |source| UsageError::Value {
flag: flag.to_string(),
source,
}
}
pub fn parse(
argv: Vec<OsString>,
source_date_epoch: Option<OsString>,
) -> Result<Command, UsageError> {
let mut args = Args::new(argv);
let Some(first) = args.next()? else {
return Err(UsageError::NoCommand);
};
match first {
Arg::Positional(name) if name == OsStr::new("format") => {
format(&mut args, source_date_epoch)
}
Arg::Positional(name) if name == OsStr::new("inspect") => inspect(&mut args),
Arg::Positional(name) if name == OsStr::new("extract") => extract(&mut args),
Arg::Positional(name) if name == OsStr::new("detect") => detect(&mut args),
Arg::Positional(name) if name == OsStr::new("identity") => identity(&mut args),
Arg::Positional(name) if name == OsStr::new("help") => Ok(Command::Help(Topic::General)),
Arg::Long(name, attached) if name == "help" => {
Args::no_value("--help", attached)?;
Ok(Command::Help(Topic::General))
}
Arg::Long(name, attached) if name == "version" => {
Args::no_value("--version", attached)?;
Ok(Command::Version)
}
Arg::Short('h', attached) => {
Args::no_value("-h", attached)?;
Ok(Command::Help(Topic::General))
}
Arg::Short('V', attached) => {
Args::no_value("-V", attached)?;
Ok(Command::Version)
}
Arg::Positional(name) => Err(UsageError::UnknownCommand(
name.to_string_lossy().into_owned(),
)),
Arg::Long(name, _) => Err(UsageError::UnknownFlag {
command: TOOL,
flag: format!("--{name}"),
}),
Arg::Short(letter, _) => Err(UsageError::UnknownFlag {
command: TOOL,
flag: format!("-{letter}"),
}),
}
}
fn format(args: &mut Args, source_date_epoch: Option<OsString>) -> Result<Command, UsageError> {
const CMD: &str = "format";
let mut out: Option<PathBuf> = None;
let mut size: Option<u64> = None;
let mut fit = false;
let mut slack: Option<Slack> = None;
let mut uuid: Option<[u8; 16]> = None;
let mut time: Option<i64> = None;
let mut from_tar: Option<Stream> = None;
let mut from_dir: Option<PathBuf> = None;
let mut owner: Option<(u32, u32)> = None;
let mut profile: Option<Profile> = None;
let mut block_size: Option<u32> = None;
let mut inode_size: Option<u16> = None;
let mut feature_ops: Vec<OsString> = Vec::new();
let mut errors = ErrorBehavior::default();
let mut inodes = InodeCount::default();
let mut reserved = ReservedRatio::default();
let mut volume_name = [0u8; 16];
let mut grow = GrowReservation::default();
let mut journal = JournalSize::Auto;
let mut fixed_time: Option<i64> = None;
let mut hash_version = HashVersion::default();
let mut hash_signedness = HashSignedness::default();
let mut hash_seed: Option<[u8; 16]> = None;
let mut json = false;
let mut atomic = false;
let mut dry_run = false;
while let Some(arg) = args.next()? {
match arg {
Arg::Long(name, attached) => {
let flag = format!("--{name}");
match name.as_str() {
"help" => {
Args::no_value(&flag, attached)?;
return Ok(Command::Help(Topic::Format));
}
"size" => {
let value = args.value(&flag, attached)?;
if value == "auto" {
(size, fit) = (None, true);
} else {
size = Some(parse::size(&value).map_err(value_err(&flag))?);
fit = false;
}
}
"slack" => {
slack = Some(
parse::slack(&args.value(&flag, attached)?)
.map_err(value_err(&flag))?,
);
}
"uuid" => {
uuid = Some(
parse::hex16(&args.value(&flag, attached)?)
.map_err(value_err(&flag))?,
);
}
"time" => {
time = Some(
parse::seconds(&args.value(&flag, attached)?)
.map_err(value_err(&flag))?,
);
}
"from-tar" => from_tar = Some(Stream::from_value(args.value(&flag, attached)?)),
"from-dir" => from_dir = Some(PathBuf::from(args.value(&flag, attached)?)),
"owner" => {
owner = Some(
parse::owner(&args.value(&flag, attached)?)
.map_err(value_err(&flag))?,
);
}
"type" => {
profile = Some(
parse::profile(&args.value(&flag, attached)?)
.map_err(value_err(&flag))?,
);
}
"block-size" => {
block_size = Some(
parse::count_u32(&args.value(&flag, attached)?)
.map_err(value_err(&flag))?,
);
}
"inode-size" => {
let v = parse::count_u32(&args.value(&flag, attached)?)
.map_err(value_err(&flag))?;
inode_size = Some(u16::try_from(v).map_err(|_| UsageError::Value {
flag: flag.clone(),
source: ValueError::OutOfRange(v.to_string()),
})?);
}
"inodes" => {
let count = parse::count_u32(&args.value(&flag, attached)?)
.map_err(value_err(&flag))?;
inodes = InodeCount::Count(count);
}
"bytes-per-inode" => {
inodes = parse::bytes_per_inode(&args.value(&flag, attached)?)
.map_err(value_err(&flag))?;
}
"reserved-percent" => {
reserved = parse::reserved_percent(&args.value(&flag, attached)?)
.map_err(value_err(&flag))?;
}
"label" => {
let value = args.value(&flag, attached)?;
volume_name = parse::label(os::bytes(&value)).map_err(value_err(&flag))?;
}
"grow" => {
grow =
parse::grow(&args.value(&flag, attached)?).map_err(value_err(&flag))?;
}
"journal" => {
journal = parse::journal(&args.value(&flag, attached)?)
.map_err(value_err(&flag))?;
}
"errors" => {
errors = parse::error_behavior(&args.value(&flag, attached)?)
.map_err(value_err(&flag))?;
}
"fixed-time" => {
fixed_time = Some(
parse::seconds(&args.value(&flag, attached)?)
.map_err(value_err(&flag))?,
);
}
"hash" => {
hash_version = parse::hash_version(&args.value(&flag, attached)?)
.map_err(value_err(&flag))?;
}
"hash-signedness" => {
hash_signedness = parse::hash_signedness(&args.value(&flag, attached)?)
.map_err(value_err(&flag))?;
}
"hash-seed" => {
hash_seed = Some(
parse::hex16(&args.value(&flag, attached)?)
.map_err(value_err(&flag))?,
);
}
"json" => {
Args::no_value(&flag, attached)?;
json = true;
}
"atomic" => {
Args::no_value(&flag, attached)?;
atomic = true;
}
"dry-run" => {
Args::no_value(&flag, attached)?;
dry_run = true;
}
_ => {
return Err(UsageError::UnknownFlag { command: CMD, flag });
}
}
}
Arg::Short('O', attached) => feature_ops.push(args.value("-O", attached)?),
Arg::Short('t', attached) => {
profile =
Some(parse::profile(&args.value("-t", attached)?).map_err(value_err("-t"))?);
}
Arg::Short('h', attached) => {
Args::no_value("-h", attached)?;
return Ok(Command::Help(Topic::Format));
}
Arg::Short(letter, _) => {
return Err(UsageError::UnknownFlag {
command: CMD,
flag: format!("-{letter}"),
});
}
Arg::Positional(value) => {
if out.is_some() {
return Err(UsageError::UnexpectedArgument {
command: CMD,
value: value.to_string_lossy().into_owned(),
});
}
out = Some(PathBuf::from(value));
}
}
}
let time = match time {
Some(t) => t,
None => match source_date_epoch {
Some(v) => parse::seconds(&v).map_err(value_err("SOURCE_DATE_EPOCH"))?,
None => {
return Err(UsageError::MissingRequired {
command: CMD,
flag: "--time (or SOURCE_DATE_EPOCH)",
});
}
},
};
let uuid = uuid.ok_or(UsageError::MissingRequired {
command: CMD,
flag: "--uuid",
})?;
let size = match (size, fit) {
(_, true) => Size::Fit(slack.unwrap_or_default()),
(Some(bytes), false) => {
if slack.is_some() {
return Err(UsageError::SlackWithoutFit);
}
Size::Bytes(bytes)
}
(None, false) => {
return Err(UsageError::MissingRequired {
command: CMD,
flag: "--size",
});
}
};
let out = out.ok_or(UsageError::MissingArgument {
command: CMD,
what: "output file",
})?;
let contents = match (from_tar, from_dir) {
(None, None) => None,
(Some(stream), None) => Some(Contents::Tar(stream)),
(None, Some(path)) => Some(Contents::Dir(path)),
(Some(_), Some(_)) => return Err(UsageError::TwoSources),
};
if owner.is_some() && !matches!(contents, Some(Contents::Dir(_))) {
return Err(UsageError::OwnerWithoutDir);
}
let mut feature = profile.unwrap_or_default().feature_set();
if let Some(block_size) = block_size {
feature.block_size = block_size;
}
if let Some(inode_size) = inode_size {
feature.inode_size = inode_size;
}
for op in &feature_ops {
feature = parse::features(feature, op).map_err(value_err("-O"))?;
}
feature.validate()?;
Ok(Command::Format(Box::new(FormatArgs {
out,
size,
uuid,
time: Timestamp::from_secs(time),
contents,
owner,
feature,
errors,
inodes,
reserved,
volume_name,
grow,
journal,
fixed_time: fixed_time.map(Timestamp::from_secs),
hash_version,
hash_signedness,
hash_seed: hash_seed.unwrap_or(uuid),
json,
atomic,
dry_run,
})))
}
fn inspect(args: &mut Args) -> Result<Command, UsageError> {
const CMD: &str = "inspect";
let mut image: Option<PathBuf> = None;
let mut offset = 0u64;
let mut json = false;
let mut sarif = false;
let mut groups = false;
let mut quick = false;
let mut fail_on = Some(Severity::Integrity);
while let Some(arg) = args.next()? {
match arg {
Arg::Long(name, attached) => {
let flag = format!("--{name}");
match name.as_str() {
"help" => {
Args::no_value(&flag, attached)?;
return Ok(Command::Help(Topic::Inspect));
}
"offset" => {
offset =
parse::size(&args.value(&flag, attached)?).map_err(value_err(&flag))?;
}
"fail-on" => {
fail_on = parse::fail_on(&args.value(&flag, attached)?)
.map_err(value_err(&flag))?;
}
"json" => {
Args::no_value(&flag, attached)?;
json = true;
}
"sarif" => {
Args::no_value(&flag, attached)?;
sarif = true;
}
"groups" => {
Args::no_value(&flag, attached)?;
groups = true;
}
"quick" => {
Args::no_value(&flag, attached)?;
quick = true;
}
_ => {
return Err(UsageError::UnknownFlag { command: CMD, flag });
}
}
}
Arg::Short('h', attached) => {
Args::no_value("-h", attached)?;
return Ok(Command::Help(Topic::Inspect));
}
Arg::Short(letter, _) => {
return Err(UsageError::UnknownFlag {
command: CMD,
flag: format!("-{letter}"),
});
}
Arg::Positional(value) => {
if image.is_some() {
return Err(UsageError::UnexpectedArgument {
command: CMD,
value: value.to_string_lossy().into_owned(),
});
}
image = Some(PathBuf::from(value));
}
}
}
let image = image.ok_or(UsageError::MissingArgument {
command: CMD,
what: "image",
})?;
if sarif && json {
return Err(UsageError::SarifWithJson);
}
if sarif && quick {
return Err(UsageError::SarifWithQuick);
}
if sarif && groups {
return Err(UsageError::SarifWithGroups);
}
Ok(Command::Inspect(InspectArgs {
image,
offset,
json,
sarif,
groups,
quick,
fail_on,
}))
}
fn detect(args: &mut Args) -> Result<Command, UsageError> {
const CMD: &str = "detect";
let mut image: Option<PathBuf> = None;
let mut offset = 0u64;
let mut json = false;
while let Some(arg) = args.next()? {
match arg {
Arg::Long(name, attached) => {
let flag = format!("--{name}");
match name.as_str() {
"help" => {
Args::no_value(&flag, attached)?;
return Ok(Command::Help(Topic::Detect));
}
"offset" => {
offset =
parse::size(&args.value(&flag, attached)?).map_err(value_err(&flag))?;
}
"json" => {
Args::no_value(&flag, attached)?;
json = true;
}
_ => {
return Err(UsageError::UnknownFlag { command: CMD, flag });
}
}
}
Arg::Short('h', attached) => {
Args::no_value("-h", attached)?;
return Ok(Command::Help(Topic::Detect));
}
Arg::Short(letter, _) => {
return Err(UsageError::UnknownFlag {
command: CMD,
flag: format!("-{letter}"),
});
}
Arg::Positional(value) => {
if image.is_some() {
return Err(UsageError::UnexpectedArgument {
command: CMD,
value: value.to_string_lossy().into_owned(),
});
}
image = Some(PathBuf::from(value));
}
}
}
let image = image.ok_or(UsageError::MissingArgument {
command: CMD,
what: "image",
})?;
Ok(Command::Detect(DetectArgs {
image,
offset,
json,
}))
}
fn identity(args: &mut Args) -> Result<Command, UsageError> {
const CMD: &str = "identity";
let mut image: Option<PathBuf> = None;
let mut uuid: Option<[u8; 16]> = None;
let mut volume_name: Option<[u8; 16]> = None;
let mut set_checksum_seed = false;
let mut json = false;
while let Some(arg) = args.next()? {
match arg {
Arg::Long(name, attached) => {
let flag = format!("--{name}");
match name.as_str() {
"help" => {
Args::no_value(&flag, attached)?;
return Ok(Command::Help(Topic::Identity));
}
"uuid" => {
uuid = Some(
parse::hex16(&args.value(&flag, attached)?)
.map_err(value_err(&flag))?,
);
}
"label" => {
let value = args.value(&flag, attached)?;
volume_name =
Some(parse::label(os::bytes(&value)).map_err(value_err(&flag))?);
}
"set-checksum-seed" => {
Args::no_value(&flag, attached)?;
set_checksum_seed = true;
}
"json" => {
Args::no_value(&flag, attached)?;
json = true;
}
_ => {
return Err(UsageError::UnknownFlag { command: CMD, flag });
}
}
}
Arg::Short('h', attached) => {
Args::no_value("-h", attached)?;
return Ok(Command::Help(Topic::Identity));
}
Arg::Short(letter, _) => {
return Err(UsageError::UnknownFlag {
command: CMD,
flag: format!("-{letter}"),
});
}
Arg::Positional(value) => {
if image.is_some() {
return Err(UsageError::UnexpectedArgument {
command: CMD,
value: value.to_string_lossy().into_owned(),
});
}
image = Some(PathBuf::from(value));
}
}
}
let image = image.ok_or(UsageError::MissingArgument {
command: CMD,
what: "image",
})?;
if uuid.is_none() && volume_name.is_none() && !set_checksum_seed {
return Err(UsageError::MissingRequired {
command: CMD,
flag: "--uuid, --label, or --set-checksum-seed",
});
}
Ok(Command::Identity(IdentityArgs {
image,
uuid,
volume_name,
set_checksum_seed,
json,
}))
}
fn extract(args: &mut Args) -> Result<Command, UsageError> {
const CMD: &str = "extract";
let mut image: Option<PathBuf> = None;
let mut offset = 0u64;
let mut to_tar: Option<Stream> = None;
let mut to_dir: Option<PathBuf> = None;
let mut skip_privileged = false;
let mut cat: Option<Vec<u8>> = None;
let mut stat: Option<Vec<u8>> = None;
let mut list = false;
let mut json = false;
let mut max_file_bytes: Option<u64> = None;
let mut atomic = false;
while let Some(arg) = args.next()? {
match arg {
Arg::Long(name, attached) => {
let flag = format!("--{name}");
match name.as_str() {
"help" => {
Args::no_value(&flag, attached)?;
return Ok(Command::Help(Topic::Extract));
}
"offset" => {
offset =
parse::size(&args.value(&flag, attached)?).map_err(value_err(&flag))?;
}
"to-tar" => to_tar = Some(Stream::from_value(args.value(&flag, attached)?)),
"to-dir" => to_dir = Some(PathBuf::from(args.value(&flag, attached)?)),
"skip-privileged" => {
Args::no_value(&flag, attached)?;
skip_privileged = true;
}
"cat" => {
let value = args.value(&flag, attached)?;
cat = Some(os::bytes(&value).to_vec());
}
"stat" => {
let value = args.value(&flag, attached)?;
stat = Some(os::bytes(&value).to_vec());
}
"max-file-bytes" => {
max_file_bytes = Some(
parse::size(&args.value(&flag, attached)?).map_err(value_err(&flag))?,
);
}
"list" => {
Args::no_value(&flag, attached)?;
list = true;
}
"json" => {
Args::no_value(&flag, attached)?;
json = true;
}
"atomic" => {
Args::no_value(&flag, attached)?;
atomic = true;
}
_ => {
return Err(UsageError::UnknownFlag { command: CMD, flag });
}
}
}
Arg::Short('h', attached) => {
Args::no_value("-h", attached)?;
return Ok(Command::Help(Topic::Extract));
}
Arg::Short(letter, _) => {
return Err(UsageError::UnknownFlag {
command: CMD,
flag: format!("-{letter}"),
});
}
Arg::Positional(value) => {
if image.is_some() {
return Err(UsageError::UnexpectedArgument {
command: CMD,
value: value.to_string_lossy().into_owned(),
});
}
image = Some(PathBuf::from(value));
}
}
}
let image = image.ok_or(UsageError::MissingArgument {
command: CMD,
what: "image",
})?;
let mode = match (to_tar, to_dir, cat, stat, list) {
(Some(stream), None, None, None, false) => ExtractMode::ToTar(stream),
(None, Some(path), None, None, false) => ExtractMode::ToDir {
path,
skip_privileged,
},
(None, None, Some(path), None, false) => ExtractMode::Cat(path),
(None, None, None, Some(path), false) => ExtractMode::Stat { path, json },
(None, None, None, None, true) => ExtractMode::List { json },
_ => return Err(UsageError::ExtractMode),
};
if skip_privileged && !matches!(mode, ExtractMode::ToDir { .. }) {
return Err(UsageError::SkipPrivilegedWithoutDir);
}
if json && !matches!(mode, ExtractMode::List { .. } | ExtractMode::Stat { .. }) {
return Err(UsageError::JsonWithoutReport);
}
if atomic && !matches!(mode, ExtractMode::ToTar(Stream::File(_))) {
return Err(UsageError::AtomicWithoutFile);
}
Ok(Command::Extract(ExtractArgs {
image,
offset,
mode,
max_file_bytes,
atomic,
}))
}
#[cfg(test)]
mod tests {
use super::*;
fn line(s: &str) -> Result<Command, UsageError> {
let argv = s.split(' ').filter(|t| !t.is_empty()).map(OsString::from);
parse(argv.collect(), None)
}
fn fmt(s: &str) -> FormatArgs {
match line(s).expect("the line parses") {
Command::Format(a) => *a,
other => panic!("expected format, got {other:?}"),
}
}
const UUID: &str = "f0e17055-0000-4000-8000-000000000000";
const UUID_BYTES: [u8; 16] = [
0xf0, 0xe1, 0x70, 0x55, 0, 0, 0x40, 0, 0x80, 0, 0, 0, 0, 0, 0, 0,
];
#[test]
fn format_takes_its_required_inputs() {
let a = fmt(&format!(
"format --size 512M --uuid {UUID} --time 1700000000 out.img"
));
assert_eq!(a.out, PathBuf::from("out.img"));
assert_eq!(a.size, Size::Bytes(512 << 20));
assert_eq!(a.uuid, UUID_BYTES);
assert_eq!(a.time, Timestamp::from_secs(1_700_000_000));
assert_eq!(a.hash_seed, UUID_BYTES);
assert_eq!(a.feature, FeatureSet::DEFAULT);
assert_eq!(a.contents, None);
assert_eq!(a.owner, None);
assert!(!a.json);
}
#[test]
fn a_size_is_named_or_found_and_slack_belongs_to_the_second() {
let auto = fmt(&format!(
"format --size auto --uuid {UUID} --time 1 --from-dir staging out.img"
));
assert_eq!(auto.size, Size::Fit(Slack::None));
for (value, want) in [
("20%", Slack::Share(2000)),
("1.5%", Slack::Share(150)),
("64M", Slack::Bytes(64 << 20)),
("0%", Slack::Share(0)),
] {
let a = fmt(&format!(
"format --size auto --slack {value} --uuid {UUID} --time 1 out.img"
));
assert_eq!(a.size, Size::Fit(want), "--slack {value}");
}
let named = fmt(&format!(
"format --size auto --size 64M --uuid {UUID} --time 1 out.img"
));
assert_eq!(named.size, Size::Bytes(64 << 20));
let found = fmt(&format!(
"format --size 64M --size auto --uuid {UUID} --time 1 out.img"
));
assert_eq!(found.size, Size::Fit(Slack::None));
for line_text in [
format!("format --size 64M --slack 20% --uuid {UUID} --time 1 out.img"),
format!("format --size auto --slack 20% --size 64M --uuid {UUID} --time 1 out.img"),
] {
assert_eq!(
line(&line_text).unwrap_err(),
UsageError::SlackWithoutFit,
"{line_text}"
);
}
let over = format!("format --size auto --slack 95% --uuid {UUID} --time 1 out.img");
assert!(
matches!(
line(&over),
Err(UsageError::Value { ref flag, source: ValueError::OutOfRange(_) })
if flag == "--slack"
),
"a 95% share should be out of range"
);
let bad = format!("format --size auto --slack lots --uuid {UUID} --time 1 out.img");
assert!(matches!(line(&bad), Err(UsageError::Value { .. })));
}
#[test]
fn format_requires_the_inputs_the_bytes_depend_on() {
for (line_text, missing) in [
(
"format --uuid f0e17055000040008000000000000000 --time 1 o.img",
"--size",
),
("format --size 64M --time 1 o.img", "--uuid"),
] {
match line(line_text) {
Err(UsageError::MissingRequired { flag, .. }) => assert_eq!(flag, missing),
other => panic!("expected {missing} to be required, got {other:?}"),
}
}
let argv = "format --size 64M --uuid f0e17055000040008000000000000000 o.img";
assert!(matches!(
line(argv),
Err(UsageError::MissingRequired { .. })
));
let from_env = parse(
argv.split(' ').map(OsString::from).collect(),
Some(OsString::from("1700000000")),
);
match from_env.expect("SOURCE_DATE_EPOCH supplies the time") {
Command::Format(a) => assert_eq!(a.time, Timestamp::from_secs(1_700_000_000)),
other => panic!("expected format, got {other:?}"),
}
let both = parse(
format!("format --size 64M --uuid {UUID} --time 42 o.img")
.split(' ')
.map(OsString::from)
.collect(),
Some(OsString::from("1700000000")),
);
match both.expect("parses") {
Command::Format(a) => assert_eq!(a.time, Timestamp::from_secs(42)),
other => panic!("expected format, got {other:?}"),
}
}
#[test]
fn format_takes_one_source_of_contents() {
let tar = fmt(&format!(
"format --size 64M --uuid {UUID} --time 1 --from-tar rootfs.tar out.img"
));
assert_eq!(
tar.contents,
Some(Contents::Tar(Stream::File(PathBuf::from("rootfs.tar"))))
);
let dash = fmt(&format!(
"format --size 64M --uuid {UUID} --time 1 --from-tar - out.img"
));
assert_eq!(dash.contents, Some(Contents::Tar(Stream::Std)));
let dir = fmt(&format!(
"format --size 64M --uuid {UUID} --time 1 --from-dir staging out.img"
));
assert_eq!(dir.contents, Some(Contents::Dir(PathBuf::from("staging"))));
assert_eq!(
line(&format!(
"format --size 64M --uuid {UUID} --time 1 --from-tar r.tar --from-dir d out.img"
))
.unwrap_err(),
UsageError::TwoSources
);
}
#[test]
fn format_takes_an_ownership_override_for_a_walked_tree() {
let a = fmt(&format!(
"format --size 64M --uuid {UUID} --time 1 --from-dir staging --owner 0:0 out.img"
));
assert_eq!(a.owner, Some((0, 0)));
let a = fmt(&format!(
"format --size 64M --uuid {UUID} --time 1 --from-dir staging --owner 1000:100 out.img"
));
assert_eq!(a.owner, Some((1000, 100)));
for line_text in [
format!("format --size 64M --uuid {UUID} --time 1 --owner 0:0 out.img"),
format!(
"format --size 64M --uuid {UUID} --time 1 --from-tar r.tar --owner 0:0 out.img"
),
] {
assert_eq!(line(&line_text).unwrap_err(), UsageError::OwnerWithoutDir);
}
for bad in ["0", "0:", ":0", "root:root", "-1:0", "4294967296:0"] {
assert!(
matches!(
line(&format!(
"format --size 64M --uuid {UUID} --time 1 --from-dir d --owner {bad} out.img"
)),
Err(UsageError::Value {
source: ValueError::NotAnOwner(_),
..
})
),
"--owner {bad} should be a usage error"
);
}
}
#[test]
fn format_folds_the_feature_options_together() {
let a = fmt(&format!(
"format --size 64M --uuid {UUID} --time 1 --block-size 1024 \
--inode-size 128 -O ^has_journal -O ^orphan_file,^metadata_csum_seed \
-O ^metadata_csum out.img"
));
assert_eq!(a.feature.block_size, 1024);
assert_eq!(a.feature.inode_size, 128);
assert!(!a.feature.has_journal());
assert!(!a.feature.has_metadata_csum());
assert!(!a.feature.has_orphan_file());
assert!(a.feature.has_extents(), "the rest of the profile is intact");
}
#[test]
fn format_seeds_the_base_profile() {
let ext2 = fmt(&format!(
"format --size 64M --uuid {UUID} --time 1 -t ext2 out.img"
));
assert_eq!(ext2.feature, FeatureSet::EXT2);
assert_eq!(Profile::of(ext2.feature), Profile::Ext2);
let ext3 = fmt(&format!(
"format --size 64M --uuid {UUID} --time 1 --type ext3 out.img"
));
assert_eq!(ext3.feature, FeatureSet::EXT3);
let ext4 = fmt(&format!("format --size 64M --uuid {UUID} --time 1 out.img"));
assert_eq!(ext4.feature, FeatureSet::DEFAULT);
assert_eq!(Profile::of(ext4.feature), Profile::Ext4);
}
#[test]
fn the_base_profile_seeds_and_o_layers_on_top_in_any_order() {
let a = fmt(&format!(
"format --size 64M --uuid {UUID} --time 1 -t ext2 -O has_journal out.img"
));
let b = fmt(&format!(
"format --size 64M --uuid {UUID} --time 1 -O has_journal -t ext2 out.img"
));
assert_eq!(
a.feature, b.feature,
"the order of -t and -O does not matter"
);
assert_eq!(a.feature, FeatureSet::EXT3);
assert_eq!(Profile::of(a.feature), Profile::Ext3);
let sized = fmt(&format!(
"format --size 64M --uuid {UUID} --time 1 --block-size 1024 -t ext2 out.img"
));
assert_eq!(sized.feature.block_size, 1024);
assert_eq!(Profile::of(sized.feature), Profile::Ext2);
assert!(matches!(
line(&format!(
"format --size 64M --uuid {UUID} --time 1 -t ext5 out.img"
)),
Err(UsageError::Value { .. })
));
}
#[test]
fn format_takes_the_sizing_and_label_options() {
let a = fmt(&format!(
"format --size 256M --uuid {UUID} --time 1 --inodes 5000 \
--reserved-percent 1.5 --label rootfs out.img"
));
assert_eq!(a.inodes, InodeCount::Count(5000));
assert_eq!(
a.reserved,
ReservedRatio::from_hundredths_of_percent(150).unwrap()
);
assert_eq!(&a.volume_name[..6], b"rootfs");
assert_eq!(a.volume_name[6], 0, "the label is NUL-padded");
let a = fmt(&format!(
"format --size 256M --uuid {UUID} --time 1 --inodes 5000 --bytes-per-inode 65536 out.img"
));
assert_eq!(
a.inodes,
InodeCount::BytesPerInode(std::num::NonZeroU64::new(65536).unwrap())
);
let a = fmt(&format!("format --size 64M --uuid {UUID} --time 1 out.img"));
assert_eq!(a.inodes, InodeCount::Auto);
assert_eq!(a.reserved, ReservedRatio::DEFAULT);
assert_eq!(a.volume_name, [0u8; 16]);
}
#[test]
fn format_takes_the_error_behavior_by_name() {
for (name, want) in [
("continue", ErrorBehavior::Continue),
("remount-ro", ErrorBehavior::RemountReadOnly),
("panic", ErrorBehavior::Panic),
] {
let a = fmt(&format!(
"format --size 64M --uuid {UUID} --time 1 --errors {name} out.img"
));
assert_eq!(a.errors, want, "--errors {name}");
}
let a = fmt(&format!("format --size 64M --uuid {UUID} --time 1 out.img"));
assert_eq!(a.errors, ErrorBehavior::Continue, "the default is continue");
let err = line(&format!(
"format --size 64M --uuid {UUID} --time 1 --errors halt out.img"
))
.unwrap_err();
assert!(matches!(
err,
UsageError::Value {
source: ValueError::NotOneOf { .. },
..
}
));
}
#[test]
fn format_refuses_an_over_long_label_and_a_bad_percent() {
let err = line(&format!(
"format --size 64M --uuid {UUID} --time 1 --label 0123456789abcdefX out.img"
))
.unwrap_err();
assert!(matches!(
err,
UsageError::Value {
source: ValueError::LabelTooLong { len: 17 },
..
}
));
for bad in ["60", "1.234", "-1"] {
let err = line(&format!(
"format --size 64M --uuid {UUID} --time 1 --reserved-percent {bad} out.img"
))
.unwrap_err();
assert!(
matches!(err, UsageError::Value { .. }),
"--reserved-percent {bad} should be a usage error"
);
}
}
#[test]
fn a_feature_set_that_cannot_reach_disk_is_refused_by_name() {
let err = line(&format!(
"format --size 64M --uuid {UUID} --time 1 -O ^has_journal out.img"
))
.unwrap_err();
assert_eq!(
err,
UsageError::Feature(FeatureError::OrphanFileWithoutJournal)
);
}
#[test]
fn a_value_is_never_read_as_an_option() {
let err = line("inspect --offset -1 image.img").unwrap_err();
assert!(matches!(
err,
UsageError::Value {
source: ValueError::NotASize(_),
..
}
));
assert!(matches!(
line("inspect --offset=-1 image.img").unwrap_err(),
UsageError::Value { .. }
));
}
#[test]
fn double_dash_ends_the_options() {
let a = fmt(&format!(
"format --size 64M --uuid {UUID} --time 1 -- --weird.img"
));
assert_eq!(a.out, PathBuf::from("--weird.img"));
}
#[test]
fn double_dash_in_value_position_is_the_options_value() {
let a = fmt(&format!(
"format --size 64M --uuid {UUID} --time 1 --label -- out.img"
));
assert_eq!(a.out, PathBuf::from("out.img"));
assert_eq!(&a.volume_name[..2], b"--");
assert_eq!(a.volume_name[2], 0, "the label is exactly `--`, NUL-padded");
}
#[test]
fn unknown_and_malformed_options_are_usage_errors() {
assert!(matches!(
line("format --nonesuch out.img"),
Err(UsageError::UnknownFlag { .. })
));
assert!(matches!(
parse(Vec::new(), None),
Err(UsageError::NoCommand)
));
assert!(matches!(
line("frobnicate"),
Err(UsageError::UnknownCommand(_))
));
assert!(matches!(
line("inspect --offset"),
Err(UsageError::MissingValue(_))
));
assert!(matches!(
line("inspect --json=yes image.img"),
Err(UsageError::UnexpectedValue(_))
));
assert!(matches!(
line("inspect"),
Err(UsageError::MissingArgument { .. })
));
assert!(matches!(
line("inspect a.img b.img"),
Err(UsageError::UnexpectedArgument { .. })
));
}
#[test]
fn a_malformed_token_renders_without_a_stray_command_colon() {
let err = line("format -1 out.img").expect_err("a non-alpha short flag is rejected");
assert_eq!(err.to_string(), "-1: not an option");
let err = line("format --nonesuch out.img").expect_err("an unknown flag is rejected");
assert_eq!(err.to_string(), "format: --nonesuch: not an option");
}
#[test]
fn inspect_scans_by_default_and_faults_a_filesystem_that_is_unsound() {
match line("inspect image.img").expect("parses") {
Command::Inspect(a) => {
assert!(!a.quick, "a scan is what makes a bad filesystem reportable");
assert_eq!(a.fail_on, Some(Severity::Integrity));
assert_eq!(a.offset, 0);
}
other => panic!("expected inspect, got {other:?}"),
}
match line("inspect --fail-on conformance image.img").expect("parses") {
Command::Inspect(a) => assert_eq!(a.fail_on, Some(Severity::Conformance)),
other => panic!("expected inspect, got {other:?}"),
}
match line("inspect --fail-on structural image.img").expect("parses") {
Command::Inspect(a) => assert_eq!(a.fail_on, Some(Severity::Structural)),
other => panic!("expected inspect, got {other:?}"),
}
match line("inspect --fail-on never image.img").expect("parses") {
Command::Inspect(a) => assert_eq!(a.fail_on, None),
other => panic!("expected inspect, got {other:?}"),
}
}
#[test]
fn inspect_sarif_is_a_findings_dialect() {
match line("inspect --sarif image.img").expect("parses") {
Command::Inspect(a) => {
assert!(a.sarif);
assert!(!a.json);
assert!(!a.quick);
}
other => panic!("expected inspect, got {other:?}"),
}
assert_eq!(
line("inspect --sarif --json image.img").unwrap_err(),
UsageError::SarifWithJson
);
assert_eq!(
line("inspect --sarif --quick image.img").unwrap_err(),
UsageError::SarifWithQuick
);
assert_eq!(
line("inspect --sarif --groups image.img").unwrap_err(),
UsageError::SarifWithGroups
);
}
#[test]
fn extract_produces_exactly_one_thing() {
match line("extract --to-tar - image.img").expect("parses") {
Command::Extract(a) => assert_eq!(a.mode, ExtractMode::ToTar(Stream::Std)),
other => panic!("expected extract, got {other:?}"),
}
match line("extract --cat /etc/hostname image.img").expect("parses") {
Command::Extract(a) => {
assert_eq!(a.mode, ExtractMode::Cat(b"/etc/hostname".to_vec()));
}
other => panic!("expected extract, got {other:?}"),
}
match line("extract --list --json image.img").expect("parses") {
Command::Extract(a) => assert_eq!(a.mode, ExtractMode::List { json: true }),
other => panic!("expected extract, got {other:?}"),
}
assert_eq!(
line("extract image.img").unwrap_err(),
UsageError::ExtractMode
);
assert_eq!(
line("extract --list --cat /x image.img").unwrap_err(),
UsageError::ExtractMode
);
assert_eq!(
line("extract --cat /x --json image.img").unwrap_err(),
UsageError::JsonWithoutReport
);
}
#[test]
fn extract_writes_a_tree_and_the_skip_belongs_to_it() {
match line("extract --to-dir unpacked image.img").expect("parses") {
Command::Extract(a) => assert_eq!(
a.mode,
ExtractMode::ToDir {
path: "unpacked".into(),
skip_privileged: false,
}
),
other => panic!("expected extract, got {other:?}"),
}
match line("extract --to-dir unpacked --skip-privileged image.img").expect("parses") {
Command::Extract(a) => assert_eq!(
a.mode,
ExtractMode::ToDir {
path: "unpacked".into(),
skip_privileged: true,
}
),
other => panic!("expected extract, got {other:?}"),
}
assert_eq!(
line("extract --to-dir d --to-tar t image.img").unwrap_err(),
UsageError::ExtractMode
);
assert_eq!(
line("extract --to-dir d --json image.img").unwrap_err(),
UsageError::JsonWithoutReport
);
assert_eq!(
line("extract --to-dir d --atomic image.img").unwrap_err(),
UsageError::AtomicWithoutFile
);
for spelling in [
"extract --to-tar out.tar --skip-privileged image.img",
"extract --list --skip-privileged image.img",
] {
assert_eq!(
line(spelling).unwrap_err(),
UsageError::SkipPrivilegedWithoutDir,
"{spelling}"
);
}
}
#[test]
fn extract_atomic_needs_a_destination_to_rename_into() {
match line("extract --to-tar out.tar --atomic image.img").expect("parses") {
Command::Extract(a) => {
assert_eq!(a.mode, ExtractMode::ToTar(Stream::File("out.tar".into())));
assert!(a.atomic);
}
other => panic!("expected extract, got {other:?}"),
}
for spelling in [
"extract --to-tar - --atomic image.img",
"extract --list --atomic image.img",
"extract --cat /x --atomic image.img",
] {
assert_eq!(
line(spelling).unwrap_err(),
UsageError::AtomicWithoutFile,
"{spelling}"
);
}
}
#[test]
fn help_and_version_are_reachable_everywhere() {
assert_eq!(line("--help").unwrap(), Command::Help(Topic::General));
assert_eq!(line("-h").unwrap(), Command::Help(Topic::General));
assert_eq!(line("help").unwrap(), Command::Help(Topic::General));
assert_eq!(line("--version").unwrap(), Command::Version);
assert_eq!(line("format --help").unwrap(), Command::Help(Topic::Format));
assert_eq!(line("inspect -h").unwrap(), Command::Help(Topic::Inspect));
assert_eq!(
line("extract --help").unwrap(),
Command::Help(Topic::Extract)
);
assert_eq!(line("format --help").unwrap(), Command::Help(Topic::Format));
}
#[test]
fn a_path_inside_the_image_is_bytes_not_text() {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStringExt;
let argv = vec![
OsString::from("extract"),
OsString::from("--cat"),
OsString::from_vec(b"/od\xffd".to_vec()),
OsString::from("image.img"),
];
match parse(argv, None).expect("parses") {
Command::Extract(a) => assert_eq!(a.mode, ExtractMode::Cat(b"/od\xffd".to_vec())),
other => panic!("expected extract, got {other:?}"),
}
}
}
}