diff --git a/src/uu/date/Cargo.toml b/src/uu/date/Cargo.toml index 4984fca9aef..553cd9ed7d6 100644 --- a/src/uu/date/Cargo.toml +++ b/src/uu/date/Cargo.toml @@ -30,7 +30,11 @@ jiff = { workspace = true, features = [ jiff-icu = { workspace = true, optional = true } parse_datetime = { workspace = true } thiserror = { workspace = true } -uucore = { workspace = true, features = ["parser", "i18n-datetime"] } +uucore = { workspace = true, features = [ + "i18n-datetime", + "parser", + "quoting-style", +] } [target.'cfg(unix)'.dependencies] libc = { workspace = true } diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index 6e75c4f610e..9edf6695845 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -17,7 +17,6 @@ use parse_datetime::{ExtendedDateTime, ParsedDateTime}; use std::borrow::Cow; use std::collections::HashMap; use std::ffi::{OsStr, OsString}; -use std::fmt::Write as _; use std::fs::File; use std::io::{BufRead, BufReader, BufWriter, Read, Write, stderr}; use std::path::PathBuf; @@ -27,7 +26,8 @@ use uucore::error::FromIo; use uucore::error::{UError, UResult, strip_errno}; #[cfg(feature = "i18n-datetime")] use uucore::i18n::datetime::{localize_format_string, should_use_icu_locale}; -use uucore::i18n::{UEncoding, get_locale_from_env}; +use uucore::i18n::get_ctype_encoding; +use uucore::quoting_style::{QuotingStyle, escape_bytes}; use uucore::translate; use uucore::translate_text; use uucore::{format_usage, show, show_if_err}; @@ -216,77 +216,20 @@ enum DayDelta { Next, } -/// Escape invalid UTF-8 bytes in GNU-compatible octal notation. -/// -/// Converts bytes to a string with printable ASCII characters preserved -/// and non-printable/invalid UTF-8 bytes escaped as `\NNN` octal sequences. -/// -/// This matches GNU date's behavior for invalid input. -/// -/// # Arguments -/// * `bytes` - The byte sequence to escape -/// -/// # Returns -/// A string with invalid bytes escaped in octal notation -/// -/// # Example -/// ```ignore -/// let invalid = b"\xb0"; -/// assert_eq!(escape_invalid_bytes(invalid), "\\260"); -/// ``` -fn escape_invalid_bytes(bytes: &[u8]) -> String { - let mut output = String::new(); - let mut rest = bytes; - let preserve_utf8 = get_locale_from_env("LC_CTYPE").1 == UEncoding::Utf8; - - // Decode valid runs so UTF-8 locales preserve user-facing text while - // legacy locales retain GNU's byte-oriented octal escaping. - let append_valid = |output: &mut String, valid: &str| { - for ch in valid.chars() { - if !ch.is_ascii() && !preserve_utf8 { - let mut bytes = [0; 4]; - for &byte in ch.encode_utf8(&mut bytes).as_bytes() { - let _ = write!(output, "\\{byte:03o}"); - } - } else if ch.is_ascii() && (!(0x20..0x7f).contains(&(ch as u8)) || ch == '\\') { - let _ = write!(output, "\\{:03o}", ch as u8); - } else { - output.push(ch); - } - } - }; - - while !rest.is_empty() { - match std::str::from_utf8(rest) { - Ok(valid) => { - append_valid(&mut output, valid); - break; - } - Err(error) => { - let valid_len = error.valid_up_to(); - if valid_len > 0 { - append_valid( - &mut output, - std::str::from_utf8(&rest[..valid_len]).unwrap_or_default(), - ); - } - let invalid_len = error.error_len().unwrap_or(rest.len() - valid_len); - for &byte in &rest[valid_len..valid_len + invalid_len] { - let _ = write!(output, "\\{byte:03o}"); - } - rest = &rest[valid_len + invalid_len..]; - } - } - } - - output +/// Escape user input for an error message the way GNU date quotes it: C +/// escapes for control characters and quotes, octal for bytes the locale +/// cannot display. The surrounding quotes come from the message itself. +fn escape_for_error(bytes: &[u8]) -> String { + let style = QuotingStyle::C_SINGLE; + let quoted = escape_bytes(bytes, style, get_ctype_encoding()); + String::from_utf8_lossy("ed[1..quoted.len() - 1]).into_owned() } /// Renders a command-line operand for an error message the way GNU does: /// valid UTF-8 is kept as-is in UTF-8 locales; non-UTF-8 locale bytes and /// invalid UTF-8 are octal-escaped. fn operand_for_error(operand: &OsStr) -> String { - escape_invalid_bytes(operand.as_encoded_bytes()) + escape_for_error(operand.as_encoded_bytes()) } /// Strip parenthesized comments from a date string. @@ -1136,7 +1079,7 @@ fn set_and_echo(input: &str, now: &Zoned, settings: &Settings) -> UResult<()> { Ok(()) } Ok(ParsedDateTime::Extended(_)) => Err(Box::new(DateError::InvalidDate { - date: escape_invalid_bytes(input.as_bytes()), + date: escape_for_error(input.as_bytes()), })), Err((input, _)) => Err(Box::new(DateError::InvalidDate { date: input })), } @@ -1648,7 +1591,7 @@ fn parse_dates_from_reader( // Report lines with invalid UTF-8 (with non-printable bytes // octal-escaped like GNU) instead of silently stopping the input Err(e) => Err(( - escape_invalid_bytes(e.as_bytes()), + escape_for_error(e.as_bytes()), parse_datetime::ParseDateTimeError::InvalidInput, )), } @@ -2205,7 +2148,7 @@ fn parse_date>( // reject that result so DST gaps are not silently accepted. if is_nonexistent_local_time(input_str, now) { return Err(( - escape_invalid_bytes(input_str.as_bytes()), + escape_for_error(input_str.as_bytes()), parse_datetime::ParseDateTimeError::InvalidInput, )); } @@ -2244,7 +2187,7 @@ fn parse_date>( Ok(ParsedDateTime::InRange(result)) } Ok(ParsedDateTime::Extended(date)) => Ok(ParsedDateTime::Extended(date)), - Err(e) => Err((escape_invalid_bytes(input_str.as_bytes()), e)), + Err(e) => Err((escape_for_error(input_str.as_bytes()), e)), } } diff --git a/src/uucore/src/lib/features/quoting_style/mod.rs b/src/uucore/src/lib/features/quoting_style/mod.rs index 37437b47d7c..75551692b5b 100644 --- a/src/uucore/src/lib/features/quoting_style/mod.rs +++ b/src/uucore/src/lib/features/quoting_style/mod.rs @@ -317,10 +317,15 @@ fn escape_name_inner( quoter.finalize() } +/// Escape raw bytes with respect to the given [`QuotingStyle`]. +pub fn escape_bytes(bytes: &[u8], style: QuotingStyle, encoding: UEncoding) -> Vec { + escape_name_inner(bytes, style, false, encoding) +} + /// Escape a filename with respect to the given [`QuotingStyle`]. pub fn escape_name(name: &OsStr, style: QuotingStyle, encoding: UEncoding) -> OsString { let name = crate::os_str_as_bytes_lossy(name); - crate::os_string_from_vec(escape_name_inner(&name, style, false, encoding)) + crate::os_string_from_vec(escape_bytes(&name, style, encoding)) .expect("all byte sequences should be valid for platform, or already replaced in name") } diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index 5c477dadde6..e853ceb3f06 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -3912,3 +3912,46 @@ fn test_date_c_locale_escapes_non_ascii_operands() { .fails() .stderr_contains("invalid date '\\342\\210\\2221 day'"); } + +#[test] +fn test_control_chars_in_operands_are_escaped() { + let cases: [(&[&str], &str); 4] = [ + (&["-d", "a\tb\rc\x1bd"], "invalid date 'a\\tb\\rc\\033d'"), + (&["--set", "a\tb\rc\x1bd"], "invalid date 'a\\tb\\rc\\033d'"), + (&["+%Y", "a\tb"], "extra operand 'a\\tb'"), + ( + &["-d", "now", "a\tb"], + "the argument 'a\\tb' lacks a leading '+'", + ), + ]; + + for (args, expected) in cases { + new_ucmd!() + .args(args) + .fails_with_code(1) + .stderr_contains(expected); + } +} + +#[test] +fn test_date_file_control_chars_are_escaped() { + let (at, mut ucmd) = at_and_ucmd!(); + at.write("dates", "a\tb\rc\x1bd\n"); + ucmd.args(&["-f", "dates"]) + .fails_with_code(1) + .stderr_is("date: invalid date 'a\\tb\\rc\\033d'\n"); +} + +#[test] +fn test_invalid_date_escaping_depends_on_locale() { + for (locale, expected) in [ + ("en_US.UTF-8", "date: invalid date 'it\\'s é'\n"), + ("C", "date: invalid date 'it\\'s \\303\\251'\n"), + ] { + new_ucmd!() + .env("LC_ALL", locale) + .args(&["-d", "it's é"]) + .fails_with_code(1) + .stderr_is(expected); + } +}