Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions src/uucore/src/lib/features/parser/parse_signed_num.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -95,10 +96,10 @@ pub fn parse_signed_num_max(src: &str) -> Result<SignedNum, ParseSizeError> {
// 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 })
Expand Down Expand Up @@ -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<SignPrefix>, &str) {
let trimmed = src.trim();
Expand Down Expand Up @@ -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();
Expand Down
14 changes: 14 additions & 0 deletions tests/by-util/test_head.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
19 changes: 19 additions & 0 deletions tests/by-util/test_tail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}