diff --git a/src/uucore/src/lib/features/parser/parse_signed_num.rs b/src/uucore/src/lib/features/parser/parse_signed_num.rs index 836c4f0a05..f0465c9d63 100644 --- a/src/uucore/src/lib/features/parser/parse_signed_num.rs +++ b/src/uucore/src/lib/features/parser/parse_signed_num.rs @@ -9,6 +9,7 @@ //! sign indicates different behavior (e.g., "first N" vs "last N" vs "starting from N"). use super::parse_size::{ParseSizeError, parse_size_u64, parse_size_u64_max, size_offset}; +use crate::display::Quotable; /// The sign prefix found on a numeric argument. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -95,10 +96,10 @@ pub fn parse_signed_num_max(src: &str) -> Result { // Otherwise "0K" would parse as 1KiB (bare suffix means 1). // A genuinely bare suffix with no digits at all (e.g. "kiB") // still parses as 1 of that unit. - parse_size_u64_max(trimmed)?; + parse_size_u64_max(trimmed).map_err(|e| as_typed(e, size_string))?; 0 } else { - parse_size_u64_max(trimmed)? + parse_size_u64_max(trimmed).map_err(|e| as_typed(e, size_string))? }; Ok(SignedNum { value, sign }) @@ -141,6 +142,28 @@ pub fn number_offset(src: &str) -> usize { size_offset(src, |c| matches!(c, '+' | '-')) } +/// Put back the leading zeros the parser stripped, so the error names the +/// argument the way it was typed. +/// +/// Zeros are only removed so the number is read as decimal rather than octal, +/// which is an implementation detail the message should not leak: GNU reports +/// `tail: invalid number of bytes: '007z'`, not `'7z'`. The sign is left off, +/// also matching GNU, which reports `-c-0fb` as `'0fb'`. +fn as_typed(error: ParseSizeError, size_string: &str) -> ParseSizeError { + let quoted = format!("{}", size_string.quote()); + match error { + // These two carry the quoted operand and nothing else, so it can be + // swapped for the one that was actually typed. + ParseSizeError::InvalidSuffix(_) => ParseSizeError::InvalidSuffix(quoted), + ParseSizeError::ParseFailure(_) => ParseSizeError::ParseFailure(quoted), + // `SizeTooBig` carries an explanation after the operand and + // `PhysicalMem` is not about the operand at all, so neither can be + // rebuilt from the string alone. `parse_size_u64_max` clamps instead + // of overflowing, so neither reaches this in practice. + other => other, + } +} + /// Strip the sign prefix from a string and return both the sign and remaining string. fn strip_sign_prefix(src: &str) -> (Option, &str) { let trimmed = src.trim(); @@ -182,6 +205,31 @@ mod tests { assert_eq!(&operand[at..][1..3], "fb"); } + /// GNU names the argument as it was typed. The leading zeros are stripped + /// only so the number is read as decimal rather than octal, and that + /// detail must not reach the message: GNU reports `'007z'`, not `'7z'`. + #[test] + fn an_invalid_count_is_reported_with_its_leading_zeros() { + for operand in ["0fb", "00x", "000ff", "0abc"] { + let error = parse_signed_num_max(operand).unwrap_err(); + assert!( + error.to_string().contains(&format!("'{operand}'")), + "{operand} was reported as {error}" + ); + } + } + + /// The sign is not restored along with the zeros: GNU reports the operand + /// of `tail -c-0fb` as `'0fb'`. + #[test] + fn the_sign_is_left_off_the_reported_count() { + let error = parse_signed_num_max("-0fb").unwrap_err(); + assert!( + error.to_string().contains("'0fb'"), + "-0fb was reported as {error}" + ); + } + #[test] fn test_no_sign() { let result = parse_signed_num_max("10").unwrap(); diff --git a/tests/by-util/test_head.rs b/tests/by-util/test_head.rs index 7ce7dac6b1..3f9446b714 100644 --- a/tests/by-util/test_head.rs +++ b/tests/by-util/test_head.rs @@ -1181,3 +1181,17 @@ head: invalid number of bytes: '1fb' .stderr_is("head: invalid number of bytes: '1fb'\n"); } } + +#[test] +fn test_invalid_count_keeps_its_leading_zeros() { + // Leading zeros are stripped only so the count is read as decimal rather + // than octal. That is internal, so GNU still names the argument as typed. + new_ucmd!() + .args(&["-c", "0fb", "/dev/null"]) + .fails_with_code(1) + .stderr_is("head: invalid number of bytes: '0fb'\n"); + new_ucmd!() + .args(&["-n", "00x", "/dev/null"]) + .fails_with_code(1) + .stderr_is("head: invalid number of lines: '00x'\n"); +} diff --git a/tests/by-util/test_tail.rs b/tests/by-util/test_tail.rs index a935b69db6..21bda5db06 100644 --- a/tests/by-util/test_tail.rs +++ b/tests/by-util/test_tail.rs @@ -5277,3 +5277,22 @@ mod diagnostics { .stderr_is("tail: invalid number of lines: '5QQ'\n"); } } + +#[test] +fn test_invalid_count_keeps_its_leading_zeros() { + // Leading zeros are stripped only so the count is read as decimal rather + // than octal. That is internal, so GNU still names the argument as typed. + new_ucmd!() + .args(&["-c", "0fb", "/dev/null"]) + .fails_with_code(1) + .stderr_is("tail: invalid number of bytes: '0fb'\n"); + new_ucmd!() + .args(&["-n", "000ff", "/dev/null"]) + .fails_with_code(1) + .stderr_is("tail: invalid number of lines: '000ff'\n"); + // The sign is not put back with them: GNU reports `-c-0fb` as '0fb'. + new_ucmd!() + .args(&["-c-0fb", "/dev/null"]) + .fails_with_code(1) + .stderr_is("tail: invalid number of bytes: '0fb'\n"); +}