Skip to content
Merged
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
6 changes: 5 additions & 1 deletion src/uu/date/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
85 changes: 14 additions & 71 deletions src/uu/date/src/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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};
Expand Down Expand Up @@ -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 {
Comment thread
anasbekheit marked this conversation as resolved.
let style = QuotingStyle::C_SINGLE;
let quoted = escape_bytes(bytes, style, get_ctype_encoding());
String::from_utf8_lossy(&quoted[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())
Comment thread
anasbekheit marked this conversation as resolved.
}

/// Strip parenthesized comments from a date string.
Expand Down Expand Up @@ -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 })),
}
Expand Down Expand Up @@ -1648,7 +1591,7 @@ fn parse_dates_from_reader<R: Read + 'static>(
// 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,
)),
}
Expand Down Expand Up @@ -2205,7 +2148,7 @@ fn parse_date<S: AsRef<str>>(
// 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,
));
}
Expand Down Expand Up @@ -2244,7 +2187,7 @@ fn parse_date<S: AsRef<str>>(
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)),
}
}

Expand Down
7 changes: 6 additions & 1 deletion src/uucore/src/lib/features/quoting_style/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8> {
Comment thread
anasbekheit marked this conversation as resolved.
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")
}

Expand Down
43 changes: 43 additions & 0 deletions tests/by-util/test_date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading