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";