From 1e5eb7c942fcf837dd5fe766486ea0804a5d0642 Mon Sep 17 00:00:00 2001 From: Christopher Dryden Date: Mon, 19 Jan 2026 13:10:24 +0000 Subject: [PATCH] numfmt: fix empty delimiter and whitespace handling --- src/uu/numfmt/src/format.rs | 35 ++++++++++++++++++++------ src/uu/numfmt/src/numfmt.rs | 11 +++++++++ src/uu/numfmt/src/options.rs | 1 + tests/by-util/test_numfmt.rs | 48 ++++++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 8 deletions(-) diff --git a/src/uu/numfmt/src/format.rs b/src/uu/numfmt/src/format.rs index b6946cdf8..f84655076 100644 --- a/src/uu/numfmt/src/format.rs +++ b/src/uu/numfmt/src/format.rs @@ -144,16 +144,17 @@ fn detailed_error_message(s: &str, unit: Unit) -> Option { None } -fn parse_suffix(s: &str, unit: Unit) -> Result<(f64, Option)> { - if s.is_empty() { +fn parse_suffix(s: &str, unit: Unit, max_whitespace: usize) -> Result<(f64, Option)> { + 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)> { 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::() .map_err(|_| translate!("numfmt-error-invalid-number", "input" => s.quote()))?; @@ -238,8 +250,8 @@ fn remove_suffix(i: f64, s: Option, u: Unit) -> Result { } } -fn transform_from(s: &str, opts: &TransformOptions) -> Result { - let (i, suffix) = parse_suffix(s, opts.from) +fn transform_from(s: &str, opts: &TransformOptions, max_whitespace: usize) -> Result { + 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 { remainder = Some(&input[pos + delim.len()..]); diff --git a/src/uu/numfmt/src/numfmt.rs b/src/uu/numfmt/src/numfmt.rs index 69f1257f6..2d53d41ce 100644 --- a/src/uu/numfmt/src/numfmt.rs +++ b/src/uu/numfmt/src/numfmt.rs @@ -261,6 +261,15 @@ fn parse_options(args: &ArgMatches) -> Result { .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::(INVALID).unwrap()).unwrap(); let zero_terminated = args.get_flag(ZERO_TERMINATED); @@ -276,6 +285,7 @@ fn parse_options(args: &ArgMatches) -> Result { 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, diff --git a/src/uu/numfmt/src/options.rs b/src/uu/numfmt/src/options.rs index 8d7b639bb..f6db4ff9f 100644 --- a/src/uu/numfmt/src/options.rs +++ b/src/uu/numfmt/src/options.rs @@ -55,6 +55,7 @@ pub struct NumfmtOptions { pub round: RoundMethod, pub suffix: Option, pub unit_separator: String, + pub max_whitespace: usize, pub format: FormatOptions, pub invalid: InvalidModes, pub zero_terminated: bool, diff --git a/tests/by-util/test_numfmt.rs b/tests/by-util/test_numfmt.rs index 44d3b6a05..6ba72d790 100644 --- a/tests/by-util/test_numfmt.rs +++ b/tests/by-util/test_numfmt.rs @@ -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"); +}