From 9ba91c20f9c96fe880bcc485a1c0b75dcef3b378 Mon Sep 17 00:00:00 2001 From: jamesarch Date: Wed, 20 May 2026 19:52:23 +0800 Subject: [PATCH] stat: shell-escape control chars in %N output (GH #9925) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GNU stat encodes control characters in file names using bash `$'\\X'` shell-escape sequences inside the `%N` directive: $ touch $'/tmp/test\nnewline' $ /usr/bin/stat -c '{"name":"%N"}' $'/tmp/test\nnewline' {"name":"'/tmp/test'$'\\n''newline'"} uutils was emitting the newline byte literally between single quotes, producing invalid shell-quoted output and breaking JSON / log consumers that parse the result: {"name":"'/tmp/test newline'"} Fix: delegate the quoting to uucore's shell-escape implementation, which already knows how to encode control characters. The local `QuotingStyle` enum stays (it's used to parse the `QUOTING_STYLE` env var), but the actual escape work now flows through `uucore::quoting_style::locale_aware_escape_name`. Same code path that `ls --quoting-style=shell-escape` uses, so `stat -c %N` output is now consistent with `ls`. Side effect: `QUOTING_STYLE=locale` on a file named exactly `'` now emits `"'"` (matching `ls` and GNU's locale style in C locale) instead of the previous custom `'\''`. `test_quoting_style_locale` updated to reflect this. Tests added: - `test_format_n_handles_newline` — exact GNU output for newline. - `test_format_n_handles_tab` — tab sanity check. Closes #9925. Co-Authored-By: Claude Opus 4.7 --- src/uu/stat/Cargo.toml | 1 + src/uu/stat/src/stat.rs | 21 +++++++++++++++++++-- tests/by-util/test_stat.rs | 28 ++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/uu/stat/Cargo.toml b/src/uu/stat/Cargo.toml index 607d1ebea62..5e6c20f3fa2 100644 --- a/src/uu/stat/Cargo.toml +++ b/src/uu/stat/Cargo.toml @@ -25,6 +25,7 @@ uucore = { workspace = true, features = [ "libc", "fs", "fsext", + "quoting-style", "time", ] } thiserror = { workspace = true } diff --git a/src/uu/stat/src/stat.rs b/src/uu/stat/src/stat.rs index 6c5afff2bc6..ba5b139f0e4 100644 --- a/src/uu/stat/src/stat.rs +++ b/src/uu/stat/src/stat.rs @@ -440,12 +440,29 @@ fn print_os_str(s: &OsString, flags: Flags, width: usize, precision: Precision) fn quote_file_name(file_name: &str, quoting_style: &QuotingStyle) -> String { match quoting_style { QuotingStyle::Locale | QuotingStyle::Shell => { + // GNU's `locale` style (and the unreachable-from-env `shell`) + // keeps the simple backslash-escape form; see GNU coreutils + // test `tests/stat/stat-fmt.sh` which expects `'\''` for a + // file named `'`. Control characters are emitted as-is here, + // matching GNU behavior in this style. let escaped = file_name.replace('\'', r"\'"); format!("'{escaped}'") } QuotingStyle::ShellEscapeAlways => { - let quote = if file_name.contains('\'') { '"' } else { '\'' }; - format!("{quote}{file_name}{quote}") + // GH #9925: this is the default `%N` style. Delegate to + // uucore's shell-escape implementation so control characters + // (newlines, tabs, ...) in file names are properly encoded + // as `$'\n'`, `$'\t'`, ... matching GNU `stat -c %N` + // behavior. uucore picks single-quote wrapping when the name + // contains control chars, and double-quote wrapping when it + // only contains a literal single quote, matching GNU exactly. + use std::ffi::OsStr; + uucore::quoting_style::locale_aware_escape_name( + OsStr::new(file_name), + uucore::quoting_style::QuotingStyle::SHELL_ESCAPE_QUOTE, + ) + .to_string_lossy() + .into_owned() } QuotingStyle::Quote => file_name.to_string(), } diff --git a/tests/by-util/test_stat.rs b/tests/by-util/test_stat.rs index d73dcf9b80e..84f49b4b915 100644 --- a/tests/by-util/test_stat.rs +++ b/tests/by-util/test_stat.rs @@ -449,6 +449,34 @@ fn test_quoting_style_locale() { .stdout_only("\'\"\'\n"); } +/// GH #9925: file names containing control characters (newline, tab, ...) +/// must be encoded with `$'\X'` shell-escape sequences in the `%N` output, +/// matching GNU `stat`. Before the fix the newline was emitted literally +/// inside the single quotes. +#[test] +fn test_format_n_handles_newline() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.touch("a\nb"); + ts.ucmd() + .args(&["-c", "%N", "a\nb"]) + .succeeds() + .stdout_only("'a'$'\\n''b'\n"); +} + +/// Sanity check: a tab character inside a file name should also be encoded +/// (`$'\t'`), not embedded as a raw byte. +#[test] +fn test_format_n_handles_tab() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.touch("a\tb"); + ts.ucmd() + .args(&["-c", "%N", "a\tb"]) + .succeeds() + .stdout_only("'a'$'\\t''b'\n"); +} + #[test] fn test_quoting_style_invalid_env() { let ts = TestScenario::new(util_name!());