Hacktoberfest 2026:メンテナが10月に向けて印を付けた、オープンで初心者向けの issue。 Hacktoberfest の issue を見る

rm: --preserve-root error prints path unescaped (11 other utils too)

クローズ 初心者向け
#15,811 コメント 0 件 リアクション 0 件 担当者 0 名 GitHub で見る

メンテナーはふだん 1 日以内に返信

まだ誰も着手していません。

評価

難易度
2/5
見積もり時間
1〜3時間
初心者へのやさしさ
65/100
issue の種類
バグ
明瞭さ
明確に書かれている
活発さ
活発
技術スタック
rust
領域
cli

調査の方向性

src/uu/rm/src/rm.rs:801 の show_preserve_root_error() から始め、rm の preserve-root 診断と en-US および fr-FR のメッセージ文字列を調べてください。添付されたパッチと再現手順には、意図する変更が記載されています。quote() を使い、テンプレート内のリテラルな引用符を削除してください。rm のテストを実行し、拒否メッセージ内でオペランドの制御文字がエスケープされる一方、拒否動作と終了ステータスは変わらないことを確認してください。

索引モデルが issue の本文から書いたものです。

説明

good first issue I - Incorrect oss-scanner U - All (uucore) U - rm
============================================================================
ANT-2026-WJ2W9E31  [NOT SECURITY: ordinary bug]
rm: --preserve-root error prints path unescaped (11 other utils too)
Location: src/uu/rm/src/rm.rs:801  in show_preserve_root_error()
Commit:   7bc1eb6d1d6fa2e1dc2c9d2277cf576c05f61e47
============================================================================

Not a security issue. This is a real, reproducible bug that is NOT a security issue: by your project's own policy nobody gains anything from it that they did not already have. It is not part of the vulnerability report, is in none of its counts, needs no embargo and no advisory, and can be filed and fixed in the open like any bug.

When rm -r refuses an operand under --preserve-root because it resolves to /, the "it is dangerous to operate recursively on '…' (same as '/')" message prints the operand name without escaping it. If that name contains ESC, CR or newline bytes, they go to stderr as-is, so a terminal acts on them and a captured log gets the message split into extra lines that look like separate diagnostics. GNU rm 9.7, and uutils' own other rm messages, print the same name escaped on one line. Only the stderr text is wrong: nothing extra is removed, the refusal still happens, and the exit status is still 1.

The cause is show_preserve_root_error() in src/uu/rm/src/rm.rs:801, which formats the name with path.display() instead of quote(). The locale string supplies literal quotes, so ordinary names look correct. A user does not have to type the name to hit this: a symlink to / whose name contains control characters, matched by a trailing-slash glob such as rm -rf */, is enough. The attached reproducer shows the raw bytes on a pty next to GNU's escaped output, and the attached patch switches the call to quote() and removes the literal quotes from the en-US and fr-FR strings. The code was added in 5e5968cdbc66 (#9706) and first released in 0.7.0.

The same missing quote()/maybe_quote() appears at independent call sites in ten other utilities (cp, tail, mkdir, ln, split, od, pr, sum, pathchk, tsort). It also appears in date, where an unparsable -f line or -d value is echoed raw. For date this is a regression from the l10n port in bde2a15b39c9. These other sites are listed with suggested changes in the report but are not covered by the patch.

Details

This is an ordinary bug, not a security issue: the unescaped names only change the text written to stderr (the same
files are touched, the exit status is the same, and --preserve-root still refuses), and in the demonstrated
rm -rf */ case, anyone able to plant the symlink can already make that same command empty any directory the link
points to, so hiding a warning gains them nothing.

The convention in this tree is that a file name goes into a diagnostic through uucore::display::Quotable (quote() /
maybe_quote()), which renders control bytes as $'…\x1B…'. A number of messages in eleven utilities skip that step: they format the name with
Path::display(), to_string_lossy(), a bare String/&str, or the Display of a third-party error. Those formatters
only replace invalid UTF-8, so ESC, CR and newline bytes of the name are written to stderr as they are, on a terminal and
on a pipe alike. The other messages of these utilities that name a file already go through Quotable, and GNU
coreutils 9.7 prints the name escaped, on one line, in its corresponding message (in shell form, 'x'$'\033''…', in most
of them; in C form, 'a\033[2K\r…', in mkdir).

A twelfth utility, date, has the same omission with a string that is not a file name: the date string it could not
parse (a line of the -f file, or the value of -d) and its stray operands are inserted into
invalid date '…' and three sibling messages as a bare String. GNU date 9.7 prints that string C-escaped
(‘\033[2K\r…’).

These are independent call sites. No shared helper is at fault (the common sink, print_diagnostic() in
src/uucore/src/lib/mods/error.rs, cannot tell a name from message text), so each site needs its own small change, and
fixing one does not change the others. They are reported together because it is one omission with one consequence and one
remedy. Only the rm row is covered by the reproducer and the attached patch. The others were checked by hand on this
commit with a name (for date, an input string) containing ESC, CR or LF, against GNU 9.7 as the control; the two
tail -f messages are the only rows that have not been confirmed a second time.

Affected call sites
Utility Location Name is formatted with Message Reached when
rm src/uu/rm/src/rm.rs:801, show_preserve_root_error() path.display(), between literal quotes from the .ftl string it is dangerous to operate recursively on 'NAME' (same as '/') rm -r on an operand with a trailing slash that resolves to /, e.g. a symlink to / matched by rm -rf */
cp src/uu/cp/src/cp.rs:89-91, CpError::WalkDirErr (#[error("{0}")]), shown at src/uu/cp/src/copydir.rs:577 the Display of walkdir::Error (walkdir 2.5.0), which uses path.display() IO error for operation on NAME: …, File system loop found: NAME points to an ancestor … any error of the directory walk in cp -r/-a: with -L, --dereference or -rl, a dangling or looping symlink or a symlink to an ancestor (also when run by root); for a non-root caller, a mode-000 directory. The name comes from the tree being copied, not from the command line
tail src/uu/tail/src/tail.rs:148, :158, :201, :207, tail_file() input.display_name, a String made with to_string_lossy() (paths.rs:60); literal quotes from the .ftl strings error reading 'NAME': Is a directory, NAME: cannot follow end of this type of file, cannot open 'NAME' for reading operand is a directory (the cannot follow line only with -f), a dangling symlink, or (non-root) an unreadable file
tail -f src/uu/tail/src/follow/watch.rs:358, :435, Observer::handle_event() display_name (same String) NAME: file truncated, NAME: No such file or directory a followed file shrinks (every follow mode); a followed file is removed or renamed under --follow=name without --retry
mkdir src/uu/mkdir/src/mkdir.rs:373, create_single_dir() path.display(), between literal quotes from locales/en-US.ftl:17 cannot create directory 'NAME': … mkdir(2) fails with ENOENT, ENOTDIR, ENAMETOOLONG, EACCES, or EEXIST on a non-directory under -p
ln src/uu/ln/src/ln.rs:72, LnError::FailedToCreateHardLinkDir _0.to_string_lossy() NAME: hard link not allowed for directory a directory operand without -s, in both ln DIR NEWNAME and ln DIR... TARGETDIR
split src/uu/split/src/split.rs:583, ByteChunkWriter::report_error() self.current_filename.display() (PREFIX operand plus generated suffix) NAME: No space left on device (any write error) split -b when a write to an output chunk fails; -l, -C, -n print no name here
od src/uu/od/src/multifile_reader.rs:241, MultifileReader::read() self.file_name as a plain &str NAME: Is a directory an operand that opens but cannot be read; on Linux every directory operand
pr src/uu/pr/src/pr.rs:176, PrError::ReadPath {path} on a PathBuf, i.e. Path::display() pr: NAME: No such file or directory std::fs::read() fails: dangling symlink, directory, symlink loop, unreadable file; also with -m
sum src/uu/sum/src/sum.rs:150, read-error arm of uumain() file.display() NAME: Is a directory an operand that opens but cannot be read; on Linux every directory operand
pathchk src/uu/pathchk/src/pathchk.rs:233, check_searchable(); :152, check_basic() plain &str / String NAME: Not a directory; limit 256 exceeded by length N of file name NAME lstat() fails with anything but ENOENT (ENOTDIR below a non-directory such as /dev/null/…, ENAMETOOLONG, ELOOP, EACCES), default mode and -P; -p on a name longer than 256 bytes
tsort src/uu/tsort/src/error.rs:21, Error::Loop .0, a plain String NAME: input contains a loop: the input file contains a cycle; printed once per cycle
date src/uu/date/src/date.rs:95, DateError::InvalidDate; also :93 ExtraOperand, :97 FormatMissingPlus, :103 InvalidFormat, and the --debug line at :1419 a plain String, between literal quotes from locales/en-US.ftl:99 (:100, :106); only input that is not valid UTF-8 is octal-escaped, by escape_invalid_bytes() (date.rs:233) invalid date 'STRING', extra operand 'STRING', the argument STRING lacks a leading '+', invalid format 'STRING' (…), date: input string: STRING a line of the -f file (or of stdin with -f -) or the value of -d that does not parse; an extra operand or one without a leading +; a format the formatter rejects. STRING is not a file name: it is file content or an argument value

Notes on individual rows:

  • cp is the one row where the caller does not pass the name at all: it is read from the directories during the walk.
    The same name given as an operand is quoted (cannot stat $'…', built with source.quote()).
  • In the rm, mkdir and tail.rs rows the quotes around the name are fixed text of the message template, so a ' in
    the name closes them and nothing distinguishes it from the real closing quote.
  • tsort weighs less than the others: the lines that follow this message print the cycle's node names from the file
    content raw, and GNU does the same, so there the unquoted file name only adds newlines and blanks (a complete line
    that does not start with tsort: ) to what such a file can already write.
  • pathchk -p additionally prints the single offending character raw between literal quotes
    (check_portable_chars(), pathchk.rs:247); that is one fixed character, not a forgeable line. GNU pathchk -p
    reports a name with control characters as containing a non-portable character, not with the length message, so
    the :152 message has no GNU counterpart for such a name.
  • In ln, translate! also hands an argument that parses as a number to Fluent as a number, so today a directory
    named 1e3 is reported as 1000: hard link not allowed for directory and 007 as 7.
  • date is the one row that is a regression, and the one row where the string is not a file name. It has its own
    subsection below. The file-name errors of date are quoted (date -r $'nofile\033x' prints $'nofile\x1Bx').

The next subsection, the reproducer and the attached patch deal with the first row, the rm --preserve-root
refusal. The other rows behave the same way with their own trigger; the change each one needs is listed under
Suggested fix.

The rm --preserve-root message

The message rm prints when --preserve-root refuses an operand that resolves to / contains the
operand name unescaped. Control characters in that name go to stderr as they are.

In an empty directory, with a symlink to / whose name contains ESC [ 2 K, a carriage return and
a newline (bash quoting):

ln -s / $'x\033[2K\rrm: removed 12 directories\n'
coreutils rm -rf */

stderr receives the first line below; GNU rm 9.7 prints the second for the same directory:

uutils  rm: it is dangerous to operate recursively on 'x<ESC>[2K<CR>rm: removed 12 directories<LF>/' (same as '/')
GNU     rm: it is dangerous to operate recursively on 'x'$'\033''[2K'$'\r''rm: removed 12 directories'$'\n''/' (same as '/')

<ESC>, <CR> and <LF> stand for the single bytes 0x1b, 0x0d and 0x0a. The bytes are the same
whether stderr is a terminal or a pipe, and whether rm runs as root or as an unprivileged user.
Both implementations then print rm: use --no-preserve-root to override this failsafe and exit
with status 1; nothing is removed.

The cause is Path::display() where the rest of the file uses Quotable::quote(),
src/uu/rm/src/rm.rs:790:

fn show_preserve_root_error(path: &Path) {
    …
    } else {
        // Path resolves to root but isn't literally "/" (e.g., symlink to /)
        show_error!(
            "{}",
            translate!("rm-error-dangerous-recursive-operation-same-as-root",
            "path" => path.display())                                   // rm.rs:801
        );
    }

display() only replaces invalid UTF-8; it does no escaping. The locale string wraps the result in
literal quote characters, which is why the output looks quoted for ordinary names
(src/uu/rm/locales/en-US.ftl:45):

rm-error-dangerous-recursive-operation-same-as-root = it is dangerous to operate recursively on '{$path}' (same as '/')

The other rm messages that name a file (errors, -v output, prompts) pass it through quote(),
which renders control bytes as $'…\x1B…'. For example rm.rs:41:

    #[error("{}", translate!("rm-error-cannot-remove-is-directory", "file" => _0.quote()))]
    CannotRemoveIsDirectory(OsString),

With the same symlink, coreutils rm */ (no -r) prints
rm: cannot remove $'x\x1B[2K\rrm: removed 12 directories\n/': Is a directory.

The name reaches show_preserve_root_error() without the invoker having typed it. remove() checks
every operand that ends in a slash before it looks at the file type (rm.rs:565):

        if uucore::fs::path_ends_with_terminator(file)
            && options.recursive
            && options.preserve_root
            && is_root_path(file)
        {
            show_preserve_root_error(file);

is_root_path() (rm.rs:768) compares (st_dev, st_ino) of the dereferenced path with those of
/, so any symlink to / followed by a slash qualifies, and a shell glob with a trailing slash
(*/) produces exactly that operand from a directory entry. -r with the default --preserve-root
is enough; -f is not needed. Without the trailing slash the operand is a symlink, rm unlinks it
and prints nothing. handle_dir() calls the same function at rm.rs:825 for an operand that is a
real directory with the identity of / (a bind mount of /, the case
test_preserve_root_bind_mount_of_root covers).

show_preserve_root_error(), its path.display() call and the message template were added in 5e5968cdbc66 ("rm: fix
--preserve-root detection for nested symlinks with trailing slash (#9706)", 2026-02-05), first released in 0.7.0.

In the example, ESC [ 2 K is the "erase line" sequence and CR moves the cursor to column 0, so a
terminal drops the part of the warning printed so far and shows rm: removed 12 directories in its
place. In a log file or cron mail the embedded newline makes that text start a line of its own, in
the format of a real rm diagnostic.

The date messages

date repeats a string it cannot parse inside invalid date '…' without escaping control characters, as long as the
string is valid UTF-8. With a file whose second line is ESC [2K CR import: 2 of 2 timestamps accepted ESC [8m:

$ printf '2024-01-02 03:04:05\n\033[2K\rimport: 2 of 2 timestamps accepted\033[8m\n' > timestamps.txt
$ coreutils date -u -f timestamps.txt
Tue Jan  2 03:04:05 UTC 2024                                                    (stdout)
date: invalid date '<ESC>[2K<CR>import: 2 of 2 timestamps accepted<ESC>[8m'     (stderr; <ESC> = 0x1b, <CR> = 0x0d)

The ESC and CR bytes arrive on stderr as they are in the file. GNU date 9.7 prints
date: invalid date ‘\033[2K\rimport: 2 of 2 timestamps accepted\033[8m’ for the same file. uutils date itself prints
'\033[2K\015import: 2 of 2 timestamps accepted\033[8m\377' when a single 0xff byte is appended to the line, because
only input that is not valid UTF-8 is escaped.

The message is the Display of one DateError variant. The apostrophes come from the message text
(date-error-invalid-date = invalid date '{$date}') and date is inserted as it is:

// src/uu/date/src/date.rs:95
#[error("{}", translate_text!("date-error-invalid-date", "date" => .date))]
InvalidDate { date: String },

parse_dates_from_reader splits the -f file (or stdin for -f -) at \n, cuts each line at the first NUL, and sends
only lines that are not valid UTF-8 through escape_invalid_bytes (date.rs:1397), which rewrites every byte outside
0x20..0x7f as \NNN. A line that is valid UTF-8 goes to parse_date, which hands the input back unchanged when
parsing fails (date.rs:1485, Err(e) => Err((input_str.into(), e))), and the loop in uumain prints it
(date.rs:641, show!(DateError::InvalidDate { date: input.clone() })).

The value of -d goes through the same parse_date and the same loop, and it may contain a newline:
date -d "$(printf 'x\nany line')" writes date: invalid date 'x and any line' as two lines, where GNU writes
‘x\nany line’ on one. For operands the rule is spelled out in operand_for_error (date.rs:251): "valid UTF-8 is kept
as-is, anything else is octal-escaped". ExtraOperand (:93), FormatMissingPlus (:97) and InvalidFormat (:103)
therefore print an ESC byte of their operand raw as well (date +%Y $'a\033b', date -d now $'a\033b',
date +$'\033%99999999Y'), and so does the --debug line date: input string: … (:1419).

This row is a regression. From 259f18fca (2021-09) the message was built with input.quote(), which escapes control
characters. The translation port bde2a15b39c9 ("l10n: port date for translation + add french", 2025-06-23, first
released in 0.2.0) replaced that with the Fluent string, which inserts the input verbatim; git log -S'input.quote()'
on date.rs returns exactly those two commits. Revisions that old cannot be built offline in this image, so the
introducing commit was identified from the history, not by running it.

Impact

Only the text of the listed error messages goes wrong. When a file name contains ESC, CR, LF or another control byte,
the message prints those bytes as they are: a terminal acts on them (erasing the line, moving the cursor, changing
colours or the window title), so the message shows up garbled or partly overwritten, and a captured log (cron mail,
journal, CI output) gets the message broken over several lines, some of which look like separate diagnostics. The same
messages in GNU, and the neighbouring messages of the same uutils utilities, print the name escaped on one line. Which
files are read, written or removed does not change, the exit status is the same, and rm --preserve-root still refuses
the operand and exits 1.

Users rarely meet this, because it needs a file name with control characters in it (left by a buggy script, a badly
decoded archive or a copy from another system) that also reaches one of the error paths in the table: a recursive rm
of a trailing-slash glob that includes a symlink to /, a walk error during cp -r/-a somewhere inside the copied
tree (the one case where the user never typed the name), a directory, dangling or unreadable operand given to tail,
od, sum, pr or ln, a failed mkdir or split -b write, a pathchk lookup failure, or a cycle in a tsort
input. For tail and pr the contents of such a file go to stdout unescaped anyway, so on a terminal the unescaped
name changes little; only a log that captures stderr alone differs.

For date the string is not a file name but an unparsable line of the -f input or an unparsable -d value, for
example timestamps taken from logs or CSV files. A -f line can carry ESC and CR but no newline (lines are split at
\n), so it can garble the message on a terminal but not split it across log lines; a -d value that contains a
newline does split it. stdout and the exit status (1) are unaffected. GNU date escapes this string, but GNU coreutils
is not consistent about file content in diagnostics elsewhere: 9.7 writes raw control bytes in the disorder line of
sort -c, for example. So this is a gap in output hygiene that scripts moved from GNU date newly meet.

Reproducing

The reproducer covers the rm row of the table.

repro is one Python script (standard library only, no companion files). Run it as root in the built
image. It uses the prebuilt /src/target/debug/coreutils and GNU rm 9.7 from /usr/bin as the
control, and it builds nothing.

It has three phases:

  • set-up: in a fresh temporary directory (mode 0755), it creates one symlink to / named
    x + ESC [ 2 K (erase line) + CR + rm: removed 12 directories + LF. Creating a symlink
    needs no privilege. This name is the only unusual input.
  • runs: in that directory it runs sh -c '<rm> -rf */', so the shell's */ glob produces the
    operand <name>/, which is what a cleanup job that removes every subdirectory would pass. It runs
    this three times: uutils rm -rf */ (the observed case), GNU rm -rf */ and uutils rm */
    (controls; the second one hits uutils' own quoted "Is a directory" message). Each run has stderr on
    a pseudo-terminal in raw mode, so what is captured is exactly what the terminal receives, without
    \n → \r\n translation. Each run uses LC_ALL=C and runs as nobody/nogroup with
    supplementary groups cleared, so pointing rm -rf at a link to / puts nothing at risk. Root gets
    the same message. The script then removes the symlink and the directory.
  • check: it prints the bytes the terminal received for each run (OBSERVED …, CONTROL …). It
    prints BUG when the uutils rm -rf */ output contains
    rm: it is dangerous to operate recursively on '<raw planted name>/' (same as '/') with the name's
    ESC, CR and LF bytes unchanged, and neither control output contains the raw name. Otherwise it
    prints OK.

Expected output (about 0.1 s):

OBSERVED uutils rm -rf */ : b"rm: it is dangerous to operate recursively on 'x\x1b[2K\rrm: removed 12 directories\n/' (same as '/')\nrm: use --no-preserve-root to override this failsafe\n"
CONTROL  GNU    rm -rf */ : b"rm: it is dangerous to operate recursively on 'x'$'\\033''[2K'$'\\r''rm: removed 12 directories'$'\\n''/' (same as '/')\nrm: use --no-preserve-root to override this failsafe\n"
CONTROL  uutils rm */     : b"rm: cannot remove $'x\\x1B[2K\\rrm: removed 12 directories\\n/': Is a directory\n"
BUG

In the first line, \x1b, \r and \n are single raw bytes on the terminal. In the two control
lines, the doubled backslashes are literal backslash characters: GNU and uutils' other message print
the name escaped.

Suggested fix

The attached patch quotes the operand like the other messages do, and removes the literal quote
characters from the two locale strings, since quote() supplies its own. Both call sites (remove()
at rm.rs:565 and handle_dir() at rm.rs:825) go through show_preserve_root_error(), so the one
change covers both; the literal-/ branch has no operand to print:

--- a/src/uu/rm/src/rm.rs
+++ b/src/uu/rm/src/rm.rs
@@ -798,7 +798,7 @@ fn show_preserve_root_error(path: &Path) {
             translate!("rm-error-dangerous-recursive-operation-same-as-root",
-            "path" => path.display())
+            "path" => path.quote())
         );
--- a/src/uu/rm/locales/en-US.ftl
+++ b/src/uu/rm/locales/en-US.ftl
-rm-error-dangerous-recursive-operation-same-as-root = it is dangerous to operate recursively on '{$path}' (same as '/')
+rm-error-dangerous-recursive-operation-same-as-root = it is dangerous to operate recursively on {$path} (same as '/')
--- a/src/uu/rm/locales/fr-FR.ftl
+++ b/src/uu/rm/locales/fr-FR.ftl
-rm-error-dangerous-recursive-operation-same-as-root = il est dangereux d'opérer récursivement sur '{$path}' (identique à '/')
+rm-error-dangerous-recursive-operation-same-as-root = il est dangereux d'opérer récursivement sur {$path} (identique à '/')

With the patch applied to a clean checkout and rebuilt, the planted name is printed as
$'x\x1B[2K\rrm: removed 12 directories\n/' and the reproducer prints OK; for an ordinary name
the message is byte-identical to the current one and to GNU's
(rm: it is dangerous to operate recursively on 'rootlink/' (same as '/')), in both locales. The
patch also adds a regression test next to the existing preserve-root tests,
test_preserve_root_symlink_to_root_quotes_name in tests/by-util/test_rm.rs, which plants a
symlink to / named "root\x1b[2K\rlink\n" and expects $'root\x1B[2K\rlink\n/' in stderr.

The other call sites

The attached patch changes rm only. Each remaining row needs the same kind of change. Quotable is already imported
in every file named below. maybe_quote() is used where GNU prints ordinary names bare and the existing tests expect
that; quote() where the message already shows the name in quotes. Each change below was built on a copy of this
commit and checked: the message then prints the name as $'…' on one line, ordinary names print as before, and the
utility's existing integration tests give the same results as before (for cp, after the one adjustment noted below).

--- a/src/uu/ln/src/ln.rs
+++ b/src/uu/ln/src/ln.rs
+use uucore::translate_text;
@@ -72 @@
-    #[error("{}", translate!("ln-failed-to-create-hard-link-dir", "source" => _0.to_string_lossy()))]
+    #[error("{}", translate_text!("ln-failed-to-create-hard-link-dir", "source" => _0.maybe_quote()))]
--- a/src/uu/mkdir/src/mkdir.rs
+++ b/src/uu/mkdir/src/mkdir.rs
@@ -373 @@
-            translate!("mkdir-error-cannot-create-directory", "path" => path.display(), "error" => strip_errno(&e)),
+            translate!("mkdir-error-cannot-create-directory", "path" => path.quote(), "error" => strip_errno(&e)),
--- a/src/uu/mkdir/locales/en-US.ftl          (same change in fr-FR.ftl)
-mkdir-error-cannot-create-directory = cannot create directory '{ $path }': { $error }
+mkdir-error-cannot-create-directory = cannot create directory { $path }: { $error }
--- a/src/uu/split/src/split.rs
+++ b/src/uu/split/src/split.rs
@@ -583 @@
-            uucore::show_error!("{}: {}", self.current_filename.display(), strip_errno(e));
+            uucore::show_error!("{}: {}", self.current_filename.maybe_quote(), strip_errno(e));
--- a/src/uu/od/src/multifile_reader.rs
+++ b/src/uu/od/src/multifile_reader.rs
@@ -241 @@
-                                    self.file_name.unwrap_or("I/O"),
+                                    self.file_name.unwrap_or("I/O").maybe_quote().external(true),
--- a/src/uu/pr/src/pr.rs
+++ b/src/uu/pr/src/pr.rs
@@ -176 @@
-    #[error("pr: {path}: {}", strip_errno(error))]
+    #[error("pr: {}: {}", path.maybe_quote(), strip_errno(error))]
--- a/src/uu/sum/src/sum.rs
+++ b/src/uu/sum/src/sum.rs
@@ -150 @@
-                    format!("{}: {}", file.display(), strip_errno(&e))
+                    format!("{}: {}", file.maybe_quote(), strip_errno(&e))
--- a/src/uu/pathchk/src/pathchk.rs
+++ b/src/uu/pathchk/src/pathchk.rs
@@ -152 @@
-            translate!("pathchk-error-posix-path-length-exceeded", "limit" => POSIX_PATH_MAX, "length" => total_len, "path" => joined_path)
+            translate!("pathchk-error-posix-path-length-exceeded", "limit" => POSIX_PATH_MAX, "length" => total_len, "path" => joined_path.quote())
@@ -233 @@
-            show_error!("{}: {}", path, strip_errno(&e));
+            show_error!("{}: {}", path.maybe_quote(), strip_errno(&e));
--- a/src/uu/tsort/src/error.rs
+++ b/src/uu/tsort/src/error.rs
@@ -21 @@
-    #[error("{input}: {message}", input = .0, message = translate!("tsort-error-loop"))]
+    #[error("{input}: {message}", input = .0.maybe_quote(), message = translate!("tsort-error-loop"))]

In ln, translate_text! replaces translate! so that a name such as 1e3 or 007 is passed to Fluent as text and
not reformatted as a number; with a bare maybe_quote() result that misrendering would remain. In od,
.maybe_quote().external(true) is what the open-error arm of the same reader already uses (multifile_reader.rs:126).

tail has the most sites. The two follow-mode messages and cannot follow take maybe_quote() (GNU prints ordinary
names bare there, and tests/by-util/test_tail.rs expects tail: data: file truncated). The error reading and
cannot open messages take quote(), with the literal quotes removed from the templates:

--- a/src/uu/tail/src/follow/watch.rs
+++ b/src/uu/tail/src/follow/watch.rs
@@ -358 @@
-                                    translate!("tail-status-file-truncated", "file" => display_name)
+                                    translate!("tail-status-file-truncated", "file" => display_name.maybe_quote())
@@ -435 @@
-                            display_name,
+                            display_name.maybe_quote(),
--- a/src/uu/tail/src/tail.rs
+++ b/src/uu/tail/src/tail.rs
@@ -148 @@
-            translate!("tail-error-reading-file", "file" => input.display_name, "error" => err_msg)
+            translate!("tail-error-reading-file", "file" => input.display_name.quote(), "error" => err_msg)
@@ -158 @@
-                translate!("tail-error-cannot-follow-file-type", "file" => input.display_name, "msg" => msg)
+                translate!("tail-error-cannot-follow-file-type", "file" => input.display_name.maybe_quote(), "msg" => msg)
@@ -201 @@ and @@ -207 @@
-                    translate!("tail-error-cannot-open-for-reading", "file" => input.display_name)
+                    translate!("tail-error-cannot-open-for-reading", "file" => input.display_name.quote())
--- a/src/uu/tail/locales/en-US.ftl           (same change in fr-FR.ftl)
-tail-error-cannot-open-no-such-file = cannot open '{ $file }' for reading: { $error }
-tail-error-reading-file = error reading '{ $file }': { $error }
+tail-error-cannot-open-no-such-file = cannot open { $file } for reading: { $error }
+tail-error-reading-file = error reading { $file }: { $error }
-tail-error-cannot-open-for-reading = cannot open '{ $file }' for reading
+tail-error-cannot-open-for-reading = cannot open { $file } for reading

Because the templates lose their quotes, their other users need .quote() as well: the standard input label at
tail.rs:309, and the two tail-error-cannot-open-no-such-file calls at tail.rs:132 and :266, which are compiled
only for non-unix and Apple targets. The escaped form is the one quote() produces elsewhere in tail; it is not
byte-identical to GNU's spelling of the same name.

cp cannot be fixed by swapping one call, because the text comes from walkdir. Format the error in cp from the
parts walkdir::Error exposes and quote the paths:

--- a/src/uu/cp/src/cp.rs
+++ b/src/uu/cp/src/cp.rs
-    /// Simple [`walkdir::Error`] wrapper
-    #[error("{0}")]
+    /// [`walkdir::Error`] wrapper, with the paths quoted
+    #[error("{}", walkdir_error_message(.0))]
     WalkDirErr(#[from] walkdir::Error),
…
+/// Same wording as the `Display` of [`walkdir::Error`], but with the paths
+/// quoted: they are names found in the source tree and must not reach stderr
+/// unescaped.
+fn walkdir_error_message(error: &walkdir::Error) -> String {
+    match (error.path(), error.loop_ancestor(), error.io_error()) {
+        (Some(child), Some(ancestor), _) => format!(
+            "File system loop found: {} points to an ancestor {}",
+            child.quote(),
+            ancestor.quote()
+        ),
+        (Some(path), None, Some(io_error)) => {
+            format!("IO error for operation on {}: {io_error}", path.quote())
+        }
+        _ => error.to_string(),
+    }
+}

The last arm only covers walkdir errors that carry no path, so no text from the tree. This version keeps walkdir's
wording and only adds the quoting, so the expectations of test_cp_circular_symbolic_links_in_directory
(tests/by-util/test_cp.rs) need to change only by the added quotes. The alternative is to switch to GNU's wording at
the same time (cannot access { $path }: { $error } with strip_errno, and cannot copy cyclic symbolic link { $path }
for the loop case, as two new keys in en-US.ftl/fr-FR.ftl); that was also built and checked, and it requires that
test to expect the new text.

date is not covered by the attached patch either. Its strings are not file names and GNU prints them C-escaped, not
shell-quoted, so there are two ways to close it; both were built on this commit and turn the example line above into
printable text.

The first keeps the notation date already uses for non-UTF-8 input (escape_invalid_bytes, \NNN) and escapes where
the messages are formatted, so every producer is covered at once (the -f/stdin loop, -d, the bare operand,
set_and_echo):

--- a/src/uu/date/src/date.rs
+++ b/src/uu/date/src/date.rs
@@ -93 @@
-    #[error("{}", translate!("date-error-extra-operand", "operand" => .operand))]
+    #[error("{}", translate!("date-error-extra-operand", "operand" => escape_control_chars(.operand)))]
@@ -95 @@
-    #[error("{}", translate_text!("date-error-invalid-date", "date" => .date))]
+    #[error("{}", translate_text!("date-error-invalid-date", "date" => escape_control_chars(.date)))]
@@ -97 @@
-    #[error("{}", translate_text!("date-error-format-missing-plus", "arg" => .arg))]
+    #[error("{}", translate_text!("date-error-format-missing-plus", "arg" => escape_control_chars(.arg)))]
@@ -103 @@
-    #[error("{}", translate!("date-error-invalid-format", "format" => .format, "error" => .error))]
+    #[error("{}", translate!("date-error-invalid-format", "format" => escape_control_chars(.format), "error" => .error))]
…
+/// Escape the control characters of user input echoed in a diagnostic, in the
+/// same octal notation as [`escape_invalid_bytes`].
+fn escape_control_chars(text: &str) -> String {
+    let mut escaped = String::with_capacity(text.len());
+    for c in text.chars() {
+        if c.is_control() {
+            escaped.push_str(&escape_invalid_bytes(c.encode_utf8(&mut [0; 4]).as_bytes()));
+        } else {
+            escaped.push(c);
+        }
+    }
+    escaped
+}
@@ -1419 @@
-        let _ = writeln!(stderr(), "date: input string: {input_str}");
+        let _ = writeln!(stderr(), "date: input string: {}", escape_control_chars(input_str));

Only control characters (C0, DEL, C1) are rewritten. Strings that escape_invalid_bytes has already escaped therefore
pass through unchanged (invalid date 'Hello\377x' stays as the existing tests expect), and printable non-ASCII text
such as é is kept. The example line is reported as '\033[2K\015import: 2 of 2 timestamps accepted\033[8m' and the
-d newline as 'x\012any line'. With this change test_date:: passes (174 tests, run as an unprivileged user). It
does not produce exactly GNU's output: it prints \015, \012 and \011 where GNU prints \r, \n and \t, and it
leaves a literal backslash as it is where GNU doubles it, so the text \033 and a real ESC byte look the same in the
message. A possible follow-up is one helper for both the valid and the invalid UTF-8 paths that writes GNU's form
(\\, \n, \r, \t, octal for the rest).

The second is the remedy of the other rows, and what date did before the translation port: .date.quote() at
date.rs:95 with the literal quotes removed from date-error-invalid-date in locales/en-US.ftl:99 and
fr-FR.ftl:94. The example line then prints as invalid date $'\x1B[2K\rimport: 2 of 2 timestamps accepted\x1B[8m'
and ordinary input still as invalid date 'bad'. This variant was checked on InvalidDate with those two inputs only;
the other three variants, the --debug line and the test_date:: suite were not run with it.

Running the reproducer

bugs-not-security/ANT-2026-WJ2W9E31/repro, next to this file. It was written by an automated agent and our check ran it as root in a privileged container: run it INSIDE A THROWAWAY VM, with the image oss-scan-7bc1eb6d1d6f built by bash build/build-image.sh, from the archive's top directory:

docker run --rm --network=none --privileged -u 0 -w /src -v "$PWD/bugs-not-security/ANT-2026-WJ2W9E31/repro:/tmp/repro:ro" oss-scan-7bc1eb6d1d6f /tmp/repro

It prints its evidence; the last line is BUG if the issue triggered, OK otherwise.

主要言語
Rust
スター
24.3k
フォーク
2.1k
平均マージ
1日 21時間
マージ済み PR(30日)
357

環境構築

Codespaces で開く

このプロジェクトの開発コンテナを、あなたの GitHub アカウントでブラウザ上に起動します。

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

uutils/coreutils のほかの issue

uutils/coreutils の issue をすべて見る

似ている issue

Rust の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。