use std::ffi::OsStr;
use std::num::NonZeroU64;
use ferrosys::ext::feature::FeatureSet;
use ferrosys::ext::{
Compat, ErrorBehavior, GrowReservation, HashSignedness, HashVersion, Incompat, InodeCount,
JournalSize, Profile, ReservedRatio, RoCompat, Severity,
};
#[derive(Clone, PartialEq, Eq, Debug, thiserror::Error)]
pub enum ValueError {
#[error("{0}: expected a whole number")]
NotANumber(String),
#[error("{0}: out of range")]
OutOfRange(String),
#[error("{0}: expected a byte count, optionally suffixed K, M, G, or T")]
NotASize(String),
#[error("{0}: expected 16 bytes of hex (32 digits; dashes are ignored)")]
NotHex16(String),
#[error("{value}: expected one of {expected}")]
NotOneOf {
value: String,
expected: &'static str,
},
#[error("{0}: expected a percentage from 0 to 50, with at most two decimal places")]
NotAPercent(String),
#[error("label is {len} bytes; the maximum is 16")]
LabelTooLong {
len: usize,
},
#[error("{0}: not an ext feature name")]
UnknownFeature(String),
#[error("{0}: a feature list has an empty element")]
EmptyFeature(String),
}
fn shown(v: &OsStr) -> String {
v.to_string_lossy().into_owned()
}
fn text(v: &OsStr) -> Option<&str> {
v.to_str()
}
pub fn size(v: &OsStr) -> Result<u64, ValueError> {
let s = text(v).ok_or_else(|| ValueError::NotASize(shown(v)))?;
let (digits, scale) = match s.as_bytes().last() {
Some(b'K' | b'k') => (&s[..s.len() - 1], 1u64 << 10),
Some(b'M' | b'm') => (&s[..s.len() - 1], 1u64 << 20),
Some(b'G' | b'g') => (&s[..s.len() - 1], 1u64 << 30),
Some(b'T' | b't') => (&s[..s.len() - 1], 1u64 << 40),
_ => (s, 1),
};
let n: u64 = digits.parse().map_err(|_| ValueError::NotASize(shown(v)))?;
n.checked_mul(scale)
.ok_or_else(|| ValueError::OutOfRange(shown(v)))
}
pub fn count_u32(v: &OsStr) -> Result<u32, ValueError> {
let s = text(v).ok_or_else(|| ValueError::NotANumber(shown(v)))?;
s.parse().map_err(|_| {
if s.bytes().all(|b| b.is_ascii_digit()) && !s.is_empty() {
ValueError::OutOfRange(shown(v))
} else {
ValueError::NotANumber(shown(v))
}
})
}
pub fn seconds(v: &OsStr) -> Result<i64, ValueError> {
let s = text(v).ok_or_else(|| ValueError::NotANumber(shown(v)))?;
s.parse().map_err(|_| ValueError::NotANumber(shown(v)))
}
pub fn hex16(v: &OsStr) -> Result<[u8; 16], ValueError> {
let s = text(v).ok_or_else(|| ValueError::NotHex16(shown(v)))?;
let mut nibbles = Vec::with_capacity(32);
for c in s.chars() {
if c == '-' {
continue;
}
let d = c
.to_digit(16)
.ok_or_else(|| ValueError::NotHex16(shown(v)))?;
nibbles.push(d as u8);
}
if nibbles.len() != 32 {
return Err(ValueError::NotHex16(shown(v)));
}
let mut out = [0u8; 16];
for (byte, pair) in out.iter_mut().zip(nibbles.chunks_exact(2)) {
*byte = (pair[0] << 4) | pair[1];
}
Ok(out)
}
pub fn features(base: FeatureSet, v: &OsStr) -> Result<FeatureSet, ValueError> {
let s = text(v).ok_or_else(|| ValueError::UnknownFeature(shown(v)))?;
let mut set = base;
for element in s.split(',') {
if element.is_empty() {
return Err(ValueError::EmptyFeature(shown(v)));
}
if element == "none" {
set.compat = Compat::NONE;
set.incompat = Incompat::NONE;
set.ro_compat = RoCompat::NONE;
continue;
}
let (name, on) = match element.strip_prefix('^') {
Some(rest) => (rest, false),
None => (element, true),
};
set = set
.with_feature(name, on)
.ok_or_else(|| ValueError::UnknownFeature(name.to_string()))?;
}
Ok(set)
}
pub fn grow(v: &OsStr) -> Result<GrowReservation, ValueError> {
match text(v) {
Some("none") => Ok(GrowReservation::None),
Some("max") => Ok(GrowReservation::Max),
_ => size(v).map(GrowReservation::UpTo),
}
}
pub fn journal(v: &OsStr) -> Result<JournalSize, ValueError> {
match text(v) {
Some("auto") => Ok(JournalSize::Auto),
_ => count_u32(v).map(JournalSize::Blocks),
}
}
pub fn fail_on(v: &OsStr) -> Result<Option<Severity>, ValueError> {
match text(v) {
Some("cosmetic") => Ok(Some(Severity::Cosmetic)),
Some("conformance") => Ok(Some(Severity::Conformance)),
Some("integrity") => Ok(Some(Severity::Integrity)),
Some("structural") => Ok(Some(Severity::Structural)),
Some("never") => Ok(None),
_ => Err(ValueError::NotOneOf {
value: shown(v),
expected: "cosmetic, conformance, integrity, structural, never",
}),
}
}
pub fn hash_version(v: &OsStr) -> Result<HashVersion, ValueError> {
match text(v) {
Some("half_md4") => Ok(HashVersion::HalfMd4),
Some("tea") => Ok(HashVersion::Tea),
Some("legacy") => Ok(HashVersion::Legacy),
_ => Err(ValueError::NotOneOf {
value: shown(v),
expected: "half_md4, tea, legacy",
}),
}
}
pub fn hash_signedness(v: &OsStr) -> Result<HashSignedness, ValueError> {
match text(v) {
Some("signed") => Ok(HashSignedness::Signed),
Some("unsigned") => Ok(HashSignedness::Unsigned),
_ => Err(ValueError::NotOneOf {
value: shown(v),
expected: "signed, unsigned",
}),
}
}
pub fn error_behavior(v: &OsStr) -> Result<ErrorBehavior, ValueError> {
match text(v) {
Some("continue") => Ok(ErrorBehavior::Continue),
Some("remount-ro") => Ok(ErrorBehavior::RemountReadOnly),
Some("panic") => Ok(ErrorBehavior::Panic),
_ => Err(ValueError::NotOneOf {
value: shown(v),
expected: "continue, remount-ro, panic",
}),
}
}
pub fn profile(v: &OsStr) -> Result<Profile, ValueError> {
match text(v) {
Some("ext2") => Ok(Profile::Ext2),
Some("ext3") => Ok(Profile::Ext3),
Some("ext4") => Ok(Profile::Ext4),
_ => Err(ValueError::NotOneOf {
value: shown(v),
expected: "ext2, ext3, ext4",
}),
}
}
pub fn label(bytes: &[u8]) -> Result<[u8; 16], ValueError> {
if bytes.len() > 16 {
return Err(ValueError::LabelTooLong { len: bytes.len() });
}
let mut name = [0u8; 16];
name[..bytes.len()].copy_from_slice(bytes);
Ok(name)
}
pub fn bytes_per_inode(v: &OsStr) -> Result<InodeCount, ValueError> {
let bytes = NonZeroU64::new(size(v)?).ok_or_else(|| ValueError::OutOfRange(shown(v)))?;
Ok(InodeCount::BytesPerInode(bytes))
}
pub fn reserved_percent(v: &OsStr) -> Result<ReservedRatio, ValueError> {
let s = text(v).ok_or_else(|| ValueError::NotAPercent(shown(v)))?;
let (int_part, frac_part) = s.split_once('.').unwrap_or((s, ""));
let digits = |t: &str| t.bytes().all(|b| b.is_ascii_digit());
if frac_part.len() > 2 || !digits(int_part) || !digits(frac_part) {
return Err(ValueError::NotAPercent(shown(v)));
}
if (int_part.is_empty() && frac_part.is_empty()) || s.ends_with('.') {
return Err(ValueError::NotAPercent(shown(v)));
}
let whole = if int_part.is_empty() {
0
} else {
int_part
.parse::<u64>()
.map_err(|_| ValueError::OutOfRange(shown(v)))?
};
let hundredths = whole
.checked_mul(100)
.and_then(|h| h.checked_add(frac_to_hundredths(frac_part)))
.and_then(|h| u16::try_from(h).ok())
.and_then(ReservedRatio::from_hundredths_of_percent)
.ok_or_else(|| ValueError::OutOfRange(shown(v)))?;
Ok(hundredths)
}
fn frac_to_hundredths(frac: &str) -> u64 {
let mut two = *b"00";
for (slot, b) in two.iter_mut().zip(frac.bytes()) {
*slot = b;
}
u64::from(two[0] - b'0') * 10 + u64::from(two[1] - b'0')
}
#[cfg(test)]
mod tests {
use super::*;
fn os(s: &str) -> &OsStr {
OsStr::new(s)
}
#[test]
fn size_reads_a_byte_count_and_its_suffix() {
assert_eq!(size(os("4096")).unwrap(), 4096);
assert_eq!(size(os("512M")).unwrap(), 512 << 20);
assert_eq!(size(os("512m")).unwrap(), 512 << 20);
assert_eq!(size(os("1K")).unwrap(), 1024);
assert_eq!(size(os("2G")).unwrap(), 2 << 30);
assert_eq!(size(os("1T")).unwrap(), 1u64 << 40);
assert_eq!(size(os("0")).unwrap(), 0);
}
#[test]
fn size_refuses_what_is_not_a_byte_count() {
assert!(matches!(size(os("-1")), Err(ValueError::NotASize(_))));
assert!(matches!(size(os("")), Err(ValueError::NotASize(_))));
assert!(matches!(size(os("M")), Err(ValueError::NotASize(_))));
assert!(matches!(size(os("1.5G")), Err(ValueError::NotASize(_))));
assert!(matches!(size(os("4 K")), Err(ValueError::NotASize(_))));
assert!(matches!(size(os("0x10")), Err(ValueError::NotASize(_))));
assert!(matches!(
size(os("99999999999T")),
Err(ValueError::OutOfRange(_))
));
assert_eq!(
size(os("-1")).unwrap_err().to_string(),
"-1: expected a byte count, optionally suffixed K, M, G, or T"
);
}
#[test]
fn counts_and_seconds() {
assert_eq!(count_u32(os("256")).unwrap(), 256);
assert!(matches!(
count_u32(os("4294967296")),
Err(ValueError::OutOfRange(_))
));
assert!(matches!(
count_u32(os("-1")),
Err(ValueError::NotANumber(_))
));
assert_eq!(seconds(os("1700000000")).unwrap(), 1_700_000_000);
assert_eq!(seconds(os("-2000000000")).unwrap(), -2_000_000_000);
assert!(matches!(seconds(os("now")), Err(ValueError::NotANumber(_))));
}
#[test]
fn hex16_reads_the_dashed_and_bare_forms() {
let dashed = hex16(os("f0e17055-0000-4000-8000-000000000000")).unwrap();
let bare = hex16(os("f0e1705500004000800000000000000")).ok();
assert_eq!(
dashed,
[
0xf0, 0xe1, 0x70, 0x55, 0, 0, 0x40, 0, 0x80, 0, 0, 0, 0, 0, 0, 0
]
);
assert!(bare.is_none());
assert_eq!(
hex16(os("f0e170550000400080000000000000000")).ok(),
None,
"33 digits is not 16 bytes either"
);
assert_eq!(
hex16(os("F0E17055000040008000000000000000")).unwrap(),
dashed,
"the case of a hex digit does not change its value"
);
assert!(matches!(
hex16(os("f0e17055-0000-4000-8000-00000000000g")),
Err(ValueError::NotHex16(_))
));
}
#[test]
fn features_apply_left_to_right() {
let base = FeatureSet::DEFAULT;
let set = features(base, os("^extent,extent")).unwrap();
assert_eq!(set, base);
let set = features(base, os("^has_journal,^orphan_file")).unwrap();
assert!(!set.has_journal());
assert!(!set.has_orphan_file());
let set = features(base, os("none")).unwrap();
assert!(set.compat.is_empty());
assert!(set.incompat.is_empty());
assert!(set.ro_compat.is_empty());
assert_eq!(set.block_size, base.block_size);
assert_eq!(set.inode_size, base.inode_size);
let set = features(base, os("none,extent")).unwrap();
assert_eq!(set.incompat, Incompat::EXTENTS);
}
#[test]
fn features_refuse_a_name_no_word_defines() {
assert!(matches!(
features(FeatureSet::DEFAULT, os("extents")),
Err(ValueError::UnknownFeature(_))
));
assert!(matches!(
features(FeatureSet::DEFAULT, os("extent,,64bit")),
Err(ValueError::EmptyFeature(_))
));
assert!(matches!(
features(FeatureSet::DEFAULT, os("EXTENTS")),
Err(ValueError::UnknownFeature(_))
));
}
#[test]
fn the_named_choices() {
assert_eq!(grow(os("none")).unwrap(), GrowReservation::None);
assert_eq!(grow(os("max")).unwrap(), GrowReservation::Max);
assert_eq!(grow(os("4G")).unwrap(), GrowReservation::UpTo(4 << 30));
assert!(matches!(grow(os("huge")), Err(ValueError::NotASize(_))));
assert_eq!(journal(os("auto")).unwrap(), JournalSize::Auto);
assert_eq!(journal(os("4096")).unwrap(), JournalSize::Blocks(4096));
assert_eq!(fail_on(os("never")).unwrap(), None);
assert_eq!(fail_on(os("integrity")).unwrap(), Some(Severity::Integrity));
assert_eq!(hash_version(os("tea")).unwrap(), HashVersion::Tea);
assert_eq!(
hash_signedness(os("signed")).unwrap(),
HashSignedness::Signed
);
assert_eq!(
hash_signedness(os("maybe")).unwrap_err().to_string(),
"maybe: expected one of signed, unsigned"
);
}
#[test]
fn profile_names_the_three_baselines() {
assert_eq!(profile(os("ext2")).unwrap(), Profile::Ext2);
assert_eq!(profile(os("ext3")).unwrap(), Profile::Ext3);
assert_eq!(profile(os("ext4")).unwrap(), Profile::Ext4);
assert!(matches!(
profile(os("ext5")),
Err(ValueError::NotOneOf { .. })
));
assert_eq!(
profile(os("xfs")).unwrap_err().to_string(),
"xfs: expected one of ext2, ext3, ext4"
);
}
#[test]
fn label_fits_the_field_or_is_refused() {
assert_eq!(&label(b"root").unwrap()[..4], b"root");
assert_eq!(label(b"0123456789abcdef").unwrap(), *b"0123456789abcdef");
assert_eq!(label(b"fs").unwrap(), *b"fs\0\0\0\0\0\0\0\0\0\0\0\0\0\0");
assert!(matches!(
label(b"0123456789abcdefX"),
Err(ValueError::LabelTooLong { len: 17 })
));
assert_eq!(
label(b"this label is much too long")
.unwrap_err()
.to_string(),
"label is 27 bytes; the maximum is 16"
);
}
#[test]
fn reserved_percent_is_exact_hundredths() {
assert_eq!(
reserved_percent(os("5")).unwrap(),
ReservedRatio::from_hundredths_of_percent(500).unwrap()
);
assert_eq!(
reserved_percent(os("0")).unwrap(),
ReservedRatio::from_hundredths_of_percent(0).unwrap()
);
assert_eq!(
reserved_percent(os("50")).unwrap(),
ReservedRatio::from_hundredths_of_percent(5000).unwrap()
);
assert_eq!(
reserved_percent(os("1.5")).unwrap(),
ReservedRatio::from_hundredths_of_percent(150).unwrap()
);
assert_eq!(
reserved_percent(os("12.34")).unwrap(),
ReservedRatio::from_hundredths_of_percent(1234).unwrap()
);
assert_eq!(
reserved_percent(os(".5")).unwrap(),
ReservedRatio::from_hundredths_of_percent(50).unwrap()
);
assert_eq!(reserved_percent(os("1.5")).unwrap().blocks(16384), 245);
assert_eq!(reserved_percent(os("5")).unwrap().blocks(16384), 819);
}
#[test]
fn reserved_percent_refuses_what_it_cannot_hold() {
assert!(matches!(
reserved_percent(os("50.01")),
Err(ValueError::OutOfRange(_))
));
assert!(matches!(
reserved_percent(os("60")),
Err(ValueError::OutOfRange(_))
));
assert!(matches!(
reserved_percent(os("1.234")),
Err(ValueError::NotAPercent(_))
));
for bad in ["-1", "1.5%", "abc", ".", "", "1.2.3", "5.", "50."] {
assert!(
matches!(reserved_percent(os(bad)), Err(ValueError::NotAPercent(_))),
"{bad:?} should not parse as a percent"
);
}
}
#[test]
fn bytes_per_inode_is_a_positive_byte_count() {
assert_eq!(
bytes_per_inode(os("16384")).unwrap(),
InodeCount::BytesPerInode(NonZeroU64::new(16384).unwrap())
);
assert_eq!(
bytes_per_inode(os("1M")).unwrap(),
InodeCount::BytesPerInode(NonZeroU64::new(1 << 20).unwrap())
);
assert!(matches!(
bytes_per_inode(os("0")),
Err(ValueError::OutOfRange(_))
));
assert!(matches!(
bytes_per_inode(os("nonsense")),
Err(ValueError::NotASize(_))
));
}
}