From 2452bc5ab131098a48cc38a90d5473336d44b6f5 Mon Sep 17 00:00:00 2001 From: fixfon <12512757+fixfon@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:21:28 +0300 Subject: [PATCH] quoting: use single quotes for apostrophe names with C1 controls A shell-escape name with an apostrophe stays in double quotes unless it holds invalid bytes. Valid control characters outside ASCII, such as the C1 controls, are also written as $'...' segments, which are plain text inside double quotes, so the quoted word named a different file. Use single quotes whenever a character is escaped that way, by asking the function that escapes it. Fixes #15810 --- .../src/lib/features/quoting_style/mod.rs | 19 +++++++++++++++++++ .../features/quoting_style/shell_quoter.rs | 11 ++++++++--- tests/by-util/test_printf.rs | 9 +++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/uucore/src/lib/features/quoting_style/mod.rs b/src/uucore/src/lib/features/quoting_style/mod.rs index 75551692b5b..5ab6d7a3249 100644 --- a/src/uucore/src/lib/features/quoting_style/mod.rs +++ b/src/uucore/src/lib/features/quoting_style/mod.rs @@ -447,6 +447,25 @@ mod tests { ); } + #[test] + fn test_c1_control_chars_with_single_quote() { + for (name, expected) in [ + ("it's\u{9b}here", "'it'\\''s'$'\\302\\233''here'"), + ("\u{9b}it's", "''$'\\302\\233''it'\\''s'"), + ("it's\u{9b}", "'it'\\''s'$'\\302\\233'"), + ("it's\u{e9}", "\"it's\u{e9}\""), + ] { + check_names_encoding( + UEncoding::Utf8, + name, + &[ + (expected, "shell-escape"), + (expected, "shell-escape-always"), + ], + ); + } + } + #[test] fn test_simple_names() { check_names_both( diff --git a/src/uucore/src/lib/features/quoting_style/shell_quoter.rs b/src/uucore/src/lib/features/quoting_style/shell_quoter.rs index 441f1e54042..799badcb299 100644 --- a/src/uucore/src/lib/features/quoting_style/shell_quoter.rs +++ b/src/uucore/src/lib/features/quoting_style/shell_quoter.rs @@ -134,11 +134,16 @@ impl<'a> EscapedShellQuoter<'a> { let (mut quotes, must_quote) = initial_quoting(reference, dirname, always_quote, true); // Dollar-quoted byte escapes cannot be nested in double quotes. if quotes == ShellQuotes::Double { - let invalid = match encoding { + let needs_dollar = match encoding { UEncoding::Ascii => !reference.is_ascii(), - UEncoding::Utf8 => std::str::from_utf8(reference).is_err(), + UEncoding::Utf8 => std::str::from_utf8(reference).map_or(true, |name| { + name.chars().any(|c| { + let escaped = EscapedChar::new_shell(c, true, ShellQuotes::Double); + matches!(escaped.state, EscapeState::Octal(_)) + }) + }), }; - if invalid { + if needs_dollar { quotes = ShellQuotes::Single; } } diff --git a/tests/by-util/test_printf.rs b/tests/by-util/test_printf.rs index f598b6ab287..db908e04df2 100644 --- a/tests/by-util/test_printf.rs +++ b/tests/by-util/test_printf.rs @@ -1104,6 +1104,15 @@ fn test_q_string_control_chars_with_quotes() { .stdout_only("''$'\\001'\\'''$'\\001'"); } +#[test] +fn test_q_string_c1_control_char_with_quote() { + new_ucmd!() + .env("LC_ALL", "en_US.UTF-8") + .args(&["%q", "it's\u{9b}here"]) + .succeeds() + .stdout_only("'it'\\''s'$'\\302\\233''here'"); +} + // Output with no trailing newline stays in the buffer until the process exits, // so the failure is only visible when it is flushed. #[test]