From ff1614b53223f04728a0adfa6898bfc9bab4d87f Mon Sep 17 00:00:00 2001 From: sichen Date: Wed, 9 Sep 2026 04:09:04 +0000 Subject: [PATCH] echo: don't expand \u, \U and \" in -e GNU's echo recognizes only \a \b \c \e \f \n \r \t \v \\ \0NNN \NNN and \xHH after -e. uucore's escape parser is shared with printf, which also recognizes \", \uHHHH and \UHHHHHHHH, so echo -e picked those up as well: echo -e '\u0041' printed A instead of \u0041. Add an EscapeSet parameter alongside the existing OctalParsing one, and have echo ask for the set without \", \u and \U. Those three then fall through to the unknown-escape arm and are emitted verbatim, backslash included, which is what GNU does. printf keeps the full set on both its format-string and %b paths. --- src/uu/echo/src/echo.rs | 8 ++- src/uucore/src/lib/features/format/escape.rs | 28 +++++++- src/uucore/src/lib/features/format/mod.rs | 15 +++-- src/uucore/src/lib/features/format/spec.rs | 8 ++- tests/by-util/test_echo.rs | 71 ++++++++++++++++++++ 5 files changed, 118 insertions(+), 12 deletions(-) diff --git a/src/uu/echo/src/echo.rs b/src/uu/echo/src/echo.rs index 8a76155dae2..7f497d86702 100644 --- a/src/uu/echo/src/echo.rs +++ b/src/uu/echo/src/echo.rs @@ -9,7 +9,7 @@ use std::env; use std::ffi::{OsStr, OsString}; use std::io::{StdoutLock, Write, stdout}; use uucore::error::UResult; -use uucore::format::{FormatChar, OctalParsing, parse_escape_only}; +use uucore::format::{EscapeSet, FormatChar, OctalParsing, parse_escape_only}; use uucore::{crate_version, format_usage, os_str_as_bytes}; use uucore::translate; @@ -238,7 +238,11 @@ fn execute( } if options.escape { - for item in parse_escape_only(bytes, OctalParsing::ThreeDigits) { + for item in parse_escape_only( + bytes, + OctalParsing::ThreeDigits, + EscapeSet::WithoutUnicodeAndQuote, + ) { if item.write(&mut *stdout)?.is_break() { return Ok(()); } diff --git a/src/uucore/src/lib/features/format/escape.rs b/src/uucore/src/lib/features/format/escape.rs index 75d44a36e2b..fa46403913c 100644 --- a/src/uucore/src/lib/features/format/escape.rs +++ b/src/uucore/src/lib/features/format/escape.rs @@ -26,6 +26,27 @@ pub enum OctalParsing { ThreeDigits = 3, } +/// Which escape sequences `parse_escape_code` recognizes. +/// +/// `printf` recognizes three sequences that `echo -e` does not: `\"`, and the +/// four- and eight-digit code point escapes `\u` and `\U`. Every other +/// sequence is shared by both, so this only selects whether those three are +/// interpreted or left exactly as written. +#[derive(Clone, Copy)] +pub enum EscapeSet { + /// Interpret `\"`, `\u` and `\U` on top of the shared sequences. + WithUnicodeAndQuote, + /// Only the shared sequences. `\"`, `\u` and `\U` are not escape sequences + /// and are emitted with their backslash, like any other unknown escape. + WithoutUnicodeAndQuote, +} + +impl EscapeSet { + fn has_unicode_and_quote(self) -> bool { + matches!(self, Self::WithUnicodeAndQuote) + } +} + #[derive(Clone, Copy)] enum Base { Oct(OctalParsing), @@ -121,6 +142,7 @@ pub enum EscapeError { pub fn parse_escape_code( rest: &mut &[u8], zero_octal_parsing: OctalParsing, + escape_set: EscapeSet, ) -> Result { if let [c, new_rest @ ..] = rest { // This is for the \NNN syntax for octal sequences. @@ -135,7 +157,7 @@ pub fn parse_escape_code( *rest = new_rest; match c { b'\\' => Ok(EscapedChar::Byte(b'\\')), - b'"' => Ok(EscapedChar::Byte(b'"')), + b'"' if escape_set.has_unicode_and_quote() => Ok(EscapedChar::Byte(b'"')), b'a' => Ok(EscapedChar::Byte(b'\x07')), b'b' => Ok(EscapedChar::Byte(b'\x08')), b'c' => Ok(EscapedChar::End), @@ -155,14 +177,14 @@ pub fn parse_escape_code( b'0' => Ok(EscapedChar::Byte( parse_code(rest, Base::Oct(zero_octal_parsing)).unwrap_or(b'\0'), )), - b'u' => match parse_unicode(rest, 4) { + b'u' if escape_set.has_unicode_and_quote() => match parse_unicode(rest, 4) { Ok(c) => Ok(EscapedChar::Char(c)), Err(EscapeError::MissingHexadecimalNumber) => Err(FormatError::MissingHex(None)), Err(EscapeError::InvalidCharacters(chars)) => { Err(FormatError::InvalidCharacter('u', chars, None)) } }, - b'U' => match parse_unicode(rest, 8) { + b'U' if escape_set.has_unicode_and_quote() => match parse_unicode(rest, 8) { Ok(c) => Ok(EscapedChar::Char(c)), Err(EscapeError::MissingHexadecimalNumber) => Err(FormatError::MissingHex(None)), Err(EscapeError::InvalidCharacters(chars)) => { diff --git a/src/uucore/src/lib/features/format/mod.rs b/src/uucore/src/lib/features/format/mod.rs index 7524e958eea..4e1583aa41b 100644 --- a/src/uucore/src/lib/features/format/mod.rs +++ b/src/uucore/src/lib/features/format/mod.rs @@ -37,7 +37,7 @@ pub mod human; pub mod num_format; mod spec; -pub use self::escape::{EscapedChar, OctalParsing}; +pub use self::escape::{EscapeSet, EscapedChar, OctalParsing}; use crate::extendedbigdecimal::ExtendedBigDecimal; pub use argument::{FormatArgument, FormatArguments}; @@ -274,9 +274,13 @@ pub fn parse_spec_and_escape( let start = fmt.len() - current.len(); current = rest; Some( - parse_escape_code(&mut current, OctalParsing::default()) - .map(FormatItem::Char) - .map_err(|e| e.spanned(start..fmt.len() - current.len())), + parse_escape_code( + &mut current, + OctalParsing::default(), + EscapeSet::WithUnicodeAndQuote, + ) + .map(FormatItem::Char) + .map_err(|e| e.spanned(start..fmt.len() - current.len())), ) } [c, rest @ ..] => { @@ -318,6 +322,7 @@ pub fn parse_spec_only( pub fn parse_escape_only( fmt: &[u8], zero_octal_parsing: OctalParsing, + escape_set: EscapeSet, ) -> impl Iterator + '_ { let mut current = fmt; std::iter::from_fn(move || match current { @@ -325,7 +330,7 @@ pub fn parse_escape_only( [b'\\', rest @ ..] => { current = rest; Some( - parse_escape_code(&mut current, zero_octal_parsing) + parse_escape_code(&mut current, zero_octal_parsing, escape_set) .unwrap_or(EscapedChar::Backslash(b'x')), ) } diff --git a/src/uucore/src/lib/features/format/spec.rs b/src/uucore/src/lib/features/format/spec.rs index d2169ba7e24..47966f59601 100644 --- a/src/uucore/src/lib/features/format/spec.rs +++ b/src/uucore/src/lib/features/format/spec.rs @@ -6,7 +6,7 @@ // spell-checker:ignore (vars) intmax ptrdiff padlen use super::{ - ExtendedBigDecimal, FormatChar, FormatError, OctalParsing, check_precision, + EscapeSet, ExtendedBigDecimal, FormatChar, FormatError, OctalParsing, check_precision, num_format::{ self, Case, FloatVariant, ForceDecimal, Formatter, NumberAlignment, PositiveSign, Prefix, UnsignedIntVariant, @@ -388,7 +388,11 @@ impl Spec { let bytes = os_str_as_bytes(os_str)?; let mut parsed = Vec::::new(); - for c in parse_escape_only(bytes, OctalParsing::ThreeDigits) { + for c in parse_escape_only( + bytes, + OctalParsing::ThreeDigits, + EscapeSet::WithUnicodeAndQuote, + ) { match c.write(&mut parsed)? { ControlFlow::Continue(()) => {} ControlFlow::Break(()) => { diff --git a/tests/by-util/test_echo.rs b/tests/by-util/test_echo.rs index 860b7250f8d..d277c555ed6 100644 --- a/tests/by-util/test_echo.rs +++ b/tests/by-util/test_echo.rs @@ -199,6 +199,77 @@ fn test_escape_vertical_tab() { .stdout_only("\x0B\n"); } +#[test] +fn test_escape_unicode_and_quote_not_recognized() { + // `echo -e` has no `\u`, `\U` or `\"` escape, unlike `printf`: the + // backslash and the character following it are printed as given. + for arg in [ + r"\u0041", + r"\U00000041", + r"\U0001F600", + r#"\""#, + r"a\u0041b", + r"a\U00000041b", + r#"a\"b"#, + r"\u\u", + ] { + new_ucmd!() + .args(&["-e", arg]) + .succeeds() + .stdout_only(format!("{arg}\n")); + } +} + +#[test] +fn test_escape_malformed_unicode_not_recognized() { + // Truncated, non-hexadecimal, surrogate and out-of-range payloads are not + // special either: nothing is consumed and no error is reported. + for arg in [ + r"\u", + r"\U", + r"\u00", + r"\U0000004", + r"\u00zz", + r"\U000000zz", + r"\uD800", + r"\U00110000", + ] { + new_ucmd!() + .args(&["-e", arg]) + .succeeds() + .stdout_only(format!("{arg}\n")); + } +} + +#[test] +fn test_disable_escapes_unicode_and_quote() { + let input_str = r#"\u0041 \U00000041 \""#; + new_ucmd!() + .arg("-E") + .arg(input_str) + .succeeds() + .stdout_only(format!("{input_str}\n")); +} + +#[test] +fn test_escape_recognized_sequences_still_expand() { + // The sequences the GNU manual does list for `echo -e` are unaffected. + new_ucmd!() + .args(&["-e", r"\a\b\e\f\n\r\t\v\\"]) + .succeeds() + .stdout_only("\x07\x08\x1B\x0C\n\r\t\x0B\\\n"); + + new_ucmd!() + .args(&["-e", r"\0101\101\x41"]) + .succeeds() + .stdout_only("AAA\n"); + + new_ucmd!() + .args(&["-e", r"a\cb", "c"]) + .succeeds() + .stdout_only("a"); +} + #[test] fn test_disable_escapes() { let input_str = "\\a \\\\ \\b \\r \\e \\f \\x41 \\n a\\cb \\u0100 \\t \\v";