From 69b763efdc067f0f8348cf4568ef0be3a744c439 Mon Sep 17 00:00:00 2001 From: mattsu Date: Wed, 18 Feb 2026 19:15:04 +0900 Subject: [PATCH 1/2] feat(date): add byte-preserving format output and --resolution support --- src/uu/date/src/date.rs | 207 ++++++++++++++++++++++++++++++++----- tests/by-util/test_date.rs | 13 +++ 2 files changed, 196 insertions(+), 24 deletions(-) diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index 91e72747a36..3def99034e7 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -14,12 +14,16 @@ use jiff::tz::{TimeZone, TimeZoneDatabase}; use jiff::{Timestamp, Zoned}; use std::borrow::Cow; use std::collections::HashMap; +use std::env; +use std::ffi::{OsStr, OsString}; use std::fs::File; use std::io::{BufRead, BufReader, BufWriter, Read, Write}; use std::path::PathBuf; use std::sync::OnceLock; use uucore::display::Quotable; use uucore::error::FromIo; +#[cfg(unix)] +use uucore::error::UUsageError; use uucore::error::{UResult, USimpleError}; #[cfg(feature = "i18n-datetime")] use uucore::i18n::datetime::{localize_format_string, should_use_icu_locale}; @@ -28,6 +32,8 @@ use uucore::{format_usage, show}; #[cfg(windows)] use windows_sys::Win32::{Foundation::SYSTEMTIME, System::SystemInformation::SetSystemTime}; +#[cfg(unix)] +use std::os::unix::ffi::OsStrExt; use uucore::parser::shortcut_value_parser::ShortcutValueParser; // Options @@ -58,6 +64,7 @@ struct Settings { format: Format, date_source: DateSource, set_to: Option, + output_encoding: OutputEncoding, debug: bool, } @@ -84,6 +91,7 @@ enum Format { Iso8601(Iso8601Format), Rfc5322, Rfc3339(Rfc3339Format), + // Used by --resolution to emit the clock resolution as "seconds.nanoseconds". Resolution, Custom(String), Default, @@ -96,9 +104,34 @@ enum DateSource { FileMtime(PathBuf), Stdin, Human(String), + // Used by --resolution to source a Timestamp that represents clock resolution. Resolution, } +#[cfg(unix)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum OutputEncoding { + Utf8, + BytePreserving, +} + +#[cfg(not(unix))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum OutputEncoding { + Utf8, +} + +struct CustomFormat { + format: String, + output_encoding: OutputEncoding, +} + +enum CustomFormatError { + MissingPlus(String), + #[cfg(unix)] + InvalidUtf8, +} + enum Iso8601Format { Date, Hours, @@ -285,7 +318,7 @@ fn parse_military_timezone_with_offset(s: &str) -> Option<(i32, DayDelta)> { pub fn uumain(args: impl uucore::Args) -> UResult<()> { let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; - let date_source = if let Some(date_os) = matches.get_one::(OPT_DATE) { + let date_source = if let Some(date_os) = matches.get_one::(OPT_DATE) { // Convert OsString to String, handling invalid UTF-8 with GNU-compatible error let date = date_os.to_str().ok_or_else(|| { let bytes = date_os.as_encoded_bytes(); @@ -307,35 +340,49 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { }; // Check for extra operands (multiple positional arguments) - if let Some(formats) = matches.get_many::(OPT_FORMAT) { - let format_args: Vec<&String> = formats.collect(); + if let Some(formats) = matches.get_many::(OPT_FORMAT) { + let format_args: Vec<&OsString> = formats.collect(); if format_args.len() > 1 { return Err(USimpleError::new( 1, - translate!("date-error-extra-operand", "operand" => format_args[1]), + translate!( + "date-error-extra-operand", + "operand" => format_args[1].to_string_lossy() + ), )); } } - let format = if let Some(form) = matches.get_one::(OPT_FORMAT) { - if !form.starts_with('+') { - // if an optional Format String was found but the user has not provided an input date - // GNU prints an invalid date Error - if !matches!(date_source, DateSource::Human(_)) { + let mut output_encoding = OutputEncoding::Utf8; + let format = if let Some(form) = matches.get_one::(OPT_FORMAT) { + let custom = match parse_custom_format(form) { + Ok(custom) => custom, + Err(CustomFormatError::MissingPlus(raw)) => { + // if an optional Format String was found but the user has not provided an input date + // GNU prints an invalid date Error + if !matches!(date_source, DateSource::Human(_)) { + return Err(USimpleError::new( + 1, + translate!("date-error-invalid-date", "date" => raw), + )); + } + // If the user did provide an input date with the --date flag and the Format String is + // not starting with '+' GNU prints the missing '+' error message return Err(USimpleError::new( 1, - translate!("date-error-invalid-date", "date" => form), + translate!("date-error-format-missing-plus", "arg" => raw), )); } - // If the user did provide an input date with the --date flag and the Format String is - // not starting with '+' GNU prints the missing '+' error message - return Err(USimpleError::new( - 1, - translate!("date-error-format-missing-plus", "arg" => form), - )); - } - let form = form[1..].to_string(); - Format::Custom(form) + #[cfg(unix)] + Err(CustomFormatError::InvalidUtf8) => { + return Err(UUsageError::new( + 1, + "invalid UTF-8 was detected in one or more arguments", + )); + } + }; + output_encoding = custom.output_encoding; + Format::Custom(custom.format) } else if let Some(fmt) = matches .get_many::(OPT_ISO_8601) .map(|mut iter| iter.next().unwrap_or(&DATE.to_string()).as_str().into()) @@ -383,6 +430,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { format, date_source, set_to, + output_encoding, debug: debug_mode, }; @@ -559,9 +607,10 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { &config, skip_localization, ) { - Ok(s) => writeln!(stdout, "{s}").map_err(|e| { - USimpleError::new(1, translate!("date-error-write", "error" => e)) - })?, + Ok(s) => write_formatted_output(settings.output_encoding, &s, &mut stdout) + .map_err(|e| { + USimpleError::new(1, translate!("date-error-write", "error" => e)) + })?, Err(e) => { let _ = stdout.flush(); return Err(USimpleError::new( @@ -601,7 +650,7 @@ pub fn uu_app() -> Command { .value_name("STRING") .allow_hyphen_values(true) .overrides_with(OPT_DATE) - .value_parser(clap::value_parser!(std::ffi::OsString)) + .value_parser(clap::value_parser!(OsString)) .help(translate!("date-help-date")), ) .arg( @@ -696,7 +745,117 @@ pub fn uu_app() -> Command { .help(translate!("date-help-universal")) .action(ArgAction::SetTrue), ) - .arg(Arg::new(OPT_FORMAT).num_args(0..)) + .arg( + Arg::new(OPT_FORMAT) + .num_args(0..) + .trailing_var_arg(true) + .value_parser(clap::builder::ValueParser::os_string()), + ) +} + +/// Parse a user-supplied `+FORMAT` argument into a `CustomFormat`. +/// +/// - Requires the leading '+' and returns `MissingPlus` otherwise. +/// - On Unix, treats the payload as raw bytes: if UTF-8, use as-is; if not, +/// then either error under UTF-8 locales or decode in a byte-preserving way. +/// - On non-Unix, falls back to a lossy string conversion and strips the '+'. +fn parse_custom_format(raw: &OsStr) -> Result { + #[cfg(unix)] + { + let bytes = raw.as_bytes(); + if bytes.first() != Some(&b'+') { + return Err(CustomFormatError::MissingPlus( + raw.to_string_lossy().into_owned(), + )); + } + let payload = &bytes[1..]; + if let Ok(utf8) = std::str::from_utf8(payload) { + return Ok(CustomFormat { + format: utf8.to_string(), + output_encoding: OutputEncoding::Utf8, + }); + } + if locale_output_encoding() == OutputEncoding::Utf8 { + return Err(CustomFormatError::InvalidUtf8); + } + Ok(CustomFormat { + format: decode_byte_preserving(payload), + output_encoding: OutputEncoding::BytePreserving, + }) + } + + #[cfg(not(unix))] + { + let s = raw.to_string_lossy(); + if !s.starts_with('+') { + return Err(CustomFormatError::MissingPlus(s.into_owned())); + } + Ok(CustomFormat { + format: s[1..].to_string(), + output_encoding: OutputEncoding::Utf8, + }) + } +} + +#[cfg(unix)] +/// Determine whether the active locale expects UTF-8 output. +fn locale_output_encoding() -> OutputEncoding { + let locale_var = ["LC_ALL", "LC_TIME", "LANG"] + .iter() + .find_map(|key| env::var(key).ok()); + + if let Some(locale) = locale_var { + let mut split = locale.split(&['.', '@']); + let _ = split.next(); + if let Some(encoding) = split.next() { + let encoding = encoding.to_ascii_lowercase(); + if encoding == "utf-8" || encoding == "utf8" { + return OutputEncoding::Utf8; + } + } + } + + OutputEncoding::BytePreserving +} + +#[cfg(unix)] +/// Losslessly map each byte to the same Unicode code point (0x00..=0xFF). +fn decode_byte_preserving(bytes: &[u8]) -> String { + bytes.iter().map(|&b| char::from(b)).collect() +} + +#[cfg(unix)] +/// Convert a string back to bytes if all chars fit in a single byte. +fn encode_byte_preserving(s: &str) -> Option> { + let mut out = Vec::with_capacity(s.len()); + for ch in s.chars() { + if (ch as u32) <= 0xFF { + out.push(ch as u8); + } else { + return None; + } + } + Some(out) +} + +/// Write the formatted string using the requested output encoding. +fn write_formatted_output( + output_encoding: OutputEncoding, + s: &str, + stdout: &mut impl Write, +) -> std::io::Result<()> { + match output_encoding { + OutputEncoding::Utf8 => writeln!(stdout, "{s}"), + #[cfg(unix)] + OutputEncoding::BytePreserving => { + if let Some(mut bytes) = encode_byte_preserving(s) { + bytes.push(b'\n'); + stdout.write_all(&bytes) + } else { + writeln!(stdout, "{s}") + } + } + } } fn format_date_with_locale_aware_months( diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index 1471634df37..faf294e9395 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -419,6 +419,19 @@ fn test_date_format_literal() { new_ucmd!().arg("+%%N").succeeds().stdout_is("%N\n"); } +#[test] +#[cfg(unix)] +fn test_date_format_non_utf8_locale_bytes() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + new_ucmd!() + .env("LC_ALL", "en_US.ISO-8859-1") + .arg(OsStr::from_bytes(b"+\xC2")) + .succeeds() + .stdout_is_bytes(b"\xC2\n"); +} + #[test] #[cfg(all(unix, not(target_os = "macos")))] fn test_date_set_valid() { From 7e7edb19bbd87ecad1e9ff816151bf8aa3759d2e Mon Sep 17 00:00:00 2001 From: mattsu Date: Wed, 18 Feb 2026 19:27:21 +0900 Subject: [PATCH 2/2] fix(date): remove UTF-8 validation error for non-UTF-8 format strings This commit removes the strict UTF-8 validation that was previously enforced on Unix systems when using custom format strings with the date command. Previously, if a format string contained invalid UTF-8 bytes and the locale was set to UTF-8, the command would fail with an error. Now, the command will always attempt to decode format strings in a byte-preserving manner, allowing non-UTF-8 format strings to work regardless of the locale setting. The change simplifies the behavior by removing the conditional UTF-8 validation logic and associated error handling, making the date command more permissive with format strings containing invalid UTF-8 sequences. --- src/uu/date/src/date.rs | 43 ++------------------------------------ tests/by-util/test_date.rs | 27 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 41 deletions(-) diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index 3def99034e7..0eae815d37d 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -14,7 +14,6 @@ use jiff::tz::{TimeZone, TimeZoneDatabase}; use jiff::{Timestamp, Zoned}; use std::borrow::Cow; use std::collections::HashMap; -use std::env; use std::ffi::{OsStr, OsString}; use std::fs::File; use std::io::{BufRead, BufReader, BufWriter, Read, Write}; @@ -22,8 +21,6 @@ use std::path::PathBuf; use std::sync::OnceLock; use uucore::display::Quotable; use uucore::error::FromIo; -#[cfg(unix)] -use uucore::error::UUsageError; use uucore::error::{UResult, USimpleError}; #[cfg(feature = "i18n-datetime")] use uucore::i18n::datetime::{localize_format_string, should_use_icu_locale}; @@ -128,8 +125,6 @@ struct CustomFormat { enum CustomFormatError { MissingPlus(String), - #[cfg(unix)] - InvalidUtf8, } enum Iso8601Format { @@ -373,13 +368,6 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { translate!("date-error-format-missing-plus", "arg" => raw), )); } - #[cfg(unix)] - Err(CustomFormatError::InvalidUtf8) => { - return Err(UUsageError::new( - 1, - "invalid UTF-8 was detected in one or more arguments", - )); - } }; output_encoding = custom.output_encoding; Format::Custom(custom.format) @@ -748,7 +736,6 @@ pub fn uu_app() -> Command { .arg( Arg::new(OPT_FORMAT) .num_args(0..) - .trailing_var_arg(true) .value_parser(clap::builder::ValueParser::os_string()), ) } @@ -757,16 +744,14 @@ pub fn uu_app() -> Command { /// /// - Requires the leading '+' and returns `MissingPlus` otherwise. /// - On Unix, treats the payload as raw bytes: if UTF-8, use as-is; if not, -/// then either error under UTF-8 locales or decode in a byte-preserving way. +/// decode in a byte-preserving way. /// - On non-Unix, falls back to a lossy string conversion and strips the '+'. fn parse_custom_format(raw: &OsStr) -> Result { #[cfg(unix)] { let bytes = raw.as_bytes(); if bytes.first() != Some(&b'+') { - return Err(CustomFormatError::MissingPlus( - raw.to_string_lossy().into_owned(), - )); + return Err(CustomFormatError::MissingPlus(escape_invalid_bytes(bytes))); } let payload = &bytes[1..]; if let Ok(utf8) = std::str::from_utf8(payload) { @@ -775,9 +760,6 @@ fn parse_custom_format(raw: &OsStr) -> Result { output_encoding: OutputEncoding::Utf8, }); } - if locale_output_encoding() == OutputEncoding::Utf8 { - return Err(CustomFormatError::InvalidUtf8); - } Ok(CustomFormat { format: decode_byte_preserving(payload), output_encoding: OutputEncoding::BytePreserving, @@ -797,27 +779,6 @@ fn parse_custom_format(raw: &OsStr) -> Result { } } -#[cfg(unix)] -/// Determine whether the active locale expects UTF-8 output. -fn locale_output_encoding() -> OutputEncoding { - let locale_var = ["LC_ALL", "LC_TIME", "LANG"] - .iter() - .find_map(|key| env::var(key).ok()); - - if let Some(locale) = locale_var { - let mut split = locale.split(&['.', '@']); - let _ = split.next(); - if let Some(encoding) = split.next() { - let encoding = encoding.to_ascii_lowercase(); - if encoding == "utf-8" || encoding == "utf8" { - return OutputEncoding::Utf8; - } - } - } - - OutputEncoding::BytePreserving -} - #[cfg(unix)] /// Losslessly map each byte to the same Unicode code point (0x00..=0xFF). fn decode_byte_preserving(bytes: &[u8]) -> String { diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index faf294e9395..776a43947d4 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -432,6 +432,33 @@ fn test_date_format_non_utf8_locale_bytes() { .stdout_is_bytes(b"\xC2\n"); } +#[test] +#[cfg(unix)] +fn test_date_format_utf8_locale_bytes() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + new_ucmd!() + .env("LC_ALL", "en_US.UTF-8") + .arg(OsStr::from_bytes(b"+\xC2")) + .succeeds() + .stdout_is_bytes(b"\xC2\n"); +} + +#[test] +#[cfg(unix)] +fn test_bad_format_option_missing_leading_plus_after_d_flag_invalid_utf8() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + new_ucmd!() + .arg("--date") + .arg("now") + .arg(OsStr::from_bytes(b"\xB0")) + .fails_with_code(1) + .stderr_contains("the argument \\260 lacks a leading '+';"); +} + #[test] #[cfg(all(unix, not(target_os = "macos")))] fn test_date_set_valid() {