numfmt: fix empty delimiter and whitespace handling

This commit is contained in:
Christopher Dryden
2026-02-15 22:05:40 +01:00
committed by Sylvestre Ledru
parent 3745fe7989
commit 1e5eb7c942
4 changed files with 87 additions and 8 deletions
+27 -8
View File
@@ -144,16 +144,17 @@ fn detailed_error_message(s: &str, unit: Unit) -> Option<String> {
None
}
fn parse_suffix(s: &str, unit: Unit) -> Result<(f64, Option<Suffix>)> {
if s.is_empty() {
fn parse_suffix(s: &str, unit: Unit, max_whitespace: usize) -> Result<(f64, Option<Suffix>)> {
let trimmed = s.trim_end();
if trimmed.is_empty() {
return Err(translate!("numfmt-error-invalid-number-empty"));
}
let with_i = s.ends_with('i');
let with_i = trimmed.ends_with('i');
if with_i && ![Unit::Auto, Unit::Iec(true)].contains(&unit) {
return Err(translate!("numfmt-error-invalid-suffix", "input" => s.quote()));
}
let mut iter = s.chars();
let mut iter = trimmed.chars();
if with_i {
iter.next_back();
}
@@ -181,7 +182,18 @@ fn parse_suffix(s: &str, unit: Unit) -> Result<(f64, Option<Suffix>)> {
Some((_, true)) => 2,
};
let number = s[..s.len() - suffix_len]
let number_part = &trimmed[..trimmed.len() - suffix_len];
let number_trimmed = number_part.trim_end();
// Validate whitespace between number and suffix
if suffix.is_some() {
let whitespace = number_part.len() - number_trimmed.len();
if whitespace > max_whitespace {
return Err(translate!("numfmt-error-invalid-suffix", "input" => s.quote()));
}
}
let number = number_trimmed
.parse::<f64>()
.map_err(|_| translate!("numfmt-error-invalid-number", "input" => s.quote()))?;
@@ -238,8 +250,8 @@ fn remove_suffix(i: f64, s: Option<Suffix>, u: Unit) -> Result<f64> {
}
}
fn transform_from(s: &str, opts: &TransformOptions) -> Result<f64> {
let (i, suffix) = parse_suffix(s, opts.from)
fn transform_from(s: &str, opts: &TransformOptions, max_whitespace: usize) -> Result<f64> {
let (i, suffix) = parse_suffix(s, opts.from, max_whitespace)
.map_err(|original| detailed_error_message(s, opts.from).unwrap_or(original))?;
let i = i * (opts.from_unit as f64);
@@ -395,7 +407,11 @@ fn format_string(
};
let number = transform_to(
transform_from(source_without_suffix, &options.transform)?,
transform_from(
source_without_suffix,
&options.transform,
options.max_whitespace,
)?,
&options.transform,
options.round,
precision,
@@ -438,6 +454,9 @@ fn split_bytes<'a>(input: &'a [u8], delim: &'a [u8]) -> impl Iterator<Item = &'a
let mut remainder = Some(input);
std::iter::from_fn(move || {
let input = remainder.take()?;
if delim.is_empty() {
return Some(input);
}
match input.windows(delim.len()).position(|w| w == delim) {
Some(pos) => {
remainder = Some(&input[pos + delim.len()..]);
+11
View File
@@ -261,6 +261,15 @@ fn parse_options(args: &ArgMatches) -> Result<NumfmtOptions> {
.cloned()
.unwrap_or_default();
// Max whitespace between number and suffix: length of separator if provided, default one
let max_whitespace = if args.contains_id(UNIT_SEPARATOR)
&& args.value_source(UNIT_SEPARATOR) == Some(ValueSource::CommandLine)
{
unit_separator.len()
} else {
1
};
let invalid = InvalidModes::from_str(args.get_one::<String>(INVALID).unwrap()).unwrap();
let zero_terminated = args.get_flag(ZERO_TERMINATED);
@@ -276,6 +285,7 @@ fn parse_options(args: &ArgMatches) -> Result<NumfmtOptions> {
round,
suffix,
unit_separator,
max_whitespace,
format,
invalid,
zero_terminated,
@@ -502,6 +512,7 @@ mod tests {
round: RoundMethod::Nearest,
suffix: None,
unit_separator: String::new(),
max_whitespace: 1,
format: FormatOptions::default(),
invalid: InvalidModes::Abort,
zero_terminated: false,
+1
View File
@@ -55,6 +55,7 @@ pub struct NumfmtOptions {
pub round: RoundMethod,
pub suffix: Option<String>,
pub unit_separator: String,
pub max_whitespace: usize,
pub format: FormatOptions,
pub invalid: InvalidModes,
pub zero_terminated: bool,
+48
View File
@@ -1201,3 +1201,51 @@ fn test_debug_warnings() {
.stdout_is("4.0K\n")
.stderr_is("numfmt: --header ignored with command-line input\n");
}
#[test]
fn test_empty_delimiter_success() {
for (args, expected) in [
// Single space between number and suffix is allowed by default
(&["-d", "", "--from=si", "4.0 K"][..], "4000\n"),
// Trailing spaces without suffix are allowed
(&["-d", "", "--from=si", "4 "], "4\n"),
(&["-d", "", "--from=auto", "2 "], "2\n"),
(&["-d", "", "--from=auto", "2 "], "2\n"),
// Trailing space after suffix is allowed
(&["-d", "", "--from=auto", "2K "], "2000\n"),
// Explicit --unit-separator=" " allows single space
(
&["-d", "", "--from=si", "--unit-separator= ", "1 K"],
"1000\n",
),
(
&["-d", "", "--from=iec", "--unit-separator= ", "2 M"],
"2097152\n",
),
] {
new_ucmd!().args(args).succeeds().stdout_only(expected);
}
}
#[test]
fn test_empty_delimiter_multi_char_unit_separator() {
// Two-space unit separator allows two spaces between number and suffix
new_ucmd!()
.args(&["-d", "", "--from=si", "--unit-separator= "])
.pipe_in("1 K\n2 M\n3 G\n")
.succeeds()
.stdout_only("1000\n2000000\n3000000000\n");
}
#[test]
fn test_empty_delimiter_whitespace_rejection() {
new_ucmd!()
.args(&["-d", "", "--from=auto", "2 K"])
.fails_with_code(2)
.stderr_contains("invalid suffix in input");
new_ucmd!()
.args(&["-d", "", "--from=si", "--unit-separator=", "1 K"])
.fails_with_code(2)
.stderr_contains("invalid suffix in input");
}