From cb35d9b81d8af56c96c4fe2091aa6fedb858da4e Mon Sep 17 00:00:00 2001 From: arbelonson-source <269032023+arbelonson-source@users.noreply.github.com> Date: Sun, 30 Aug 2026 01:50:40 +0300 Subject: [PATCH] head, tail: keep leading zeros in an invalid count message Closes #14229. `parse_signed_num_max` strips leading zeros so the count is read as decimal rather than octal, then hands the trimmed string to the size parser. The error carries that trimmed string, so the zeros the user typed never reach the message: $ tail -c0fb a tail: invalid number of bytes: 'fb' # GNU: '0fb' $ head -n0x a head: invalid number of lines: 'x' # GNU: '0x' Rebuild the error around the untrimmed operand instead. Only the two variants that carry the operand alone are rebuilt; `SizeTooBig` also carries an explanation and `PhysicalMem` is not about the operand, and neither can arise here anyway, since `parse_size_u64_max` clamps rather than overflowing. The sign stays stripped, matching GNU, which reports the operand of `tail -c-0fb` as '0fb'. This also lines the message up with `number_offset`, which already counts leading zeros as part of the number when placing the caret. --- .../lib/features/parser/parse_signed_num.rs | 52 ++++++++++++++++++- tests/by-util/test_head.rs | 14 +++++ tests/by-util/test_tail.rs | 19 +++++++ 3 files changed, 83 insertions(+), 2 deletions(-) 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"); +}