rm: --preserve-root error prints path unescaped (11 other utils too)
メンテナーはふだん 1 日以内に返信
まだ誰も着手していません。
評価
調査の方向性
src/uu/rm/src/rm.rs:801 の show_preserve_root_error() から始め、rm の preserve-root 診断と en-US および fr-FR のメッセージ文字列を調べてください。添付されたパッチと再現手順には、意図する変更が記載されています。quote() を使い、テンプレート内のリテラルな引用符を削除してください。rm のテストを実行し、拒否メッセージ内でオペランドの制御文字がエスケープされる一方、拒否動作と終了ステータスは変わらないことを確認してください。
索引モデルが issue の本文から書いたものです。
説明
============================================================================
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:
cpis 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 withsource.quote()).- In the
rm,mkdirandtail.rsrows 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. tsortweighs 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 withtsort:) to what such a file can already write.pathchk -padditionally prints the single offending character raw between literal quotes
(check_portable_chars(),pathchk.rs:247); that is one fixed character, not a forgeable line. GNUpathchk -p
reports a name with control characters as containing a non-portable character, not with the length message, so
the:152message 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
named1e3is reported as1000: hard link not allowed for directoryand007as7. dateis 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 ofdateare 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: uutilsrm -rf */(the observed case), GNUrm -rf */and uutilsrm */
(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\ntranslation. Each run usesLC_ALL=Cand runs asnobody/nogroupwith
supplementary groups cleared, so pointingrm -rfat 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
printsBUGwhen the uutilsrm -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
printsOK.
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
環境構築
このプロジェクトの開発コンテナを、あなたの GitHub アカウントでブラウザ上に起動します。
- Dockerfile・Docker Compose ファイルなし
- プルリクエストのテンプレートなし
- コントリビューションガイドを読む
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
uutils/coreutils のほかの issue
-
fmt: stops at an unreadable file operand and says only 'read error' for a directory対応中かも @emirmrkaljevic が 1 日前に担当しました。 オープンU - fmt
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
メンテナーはふだん 1 日以内に返信
-
shuf: exhausted random source destroys the input during an in-place shuffle対応中かも @limadog9 が 1 日前に担当しました。 オープンU - shuf
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
メンテナーはふだん 1 日以内に返信
-
paste: serial mode exhausts file descriptors by opening every input simultaneously対応中かも @binhvq が 1 日前に担当しました。 オープンU - paste
難易度 2/5 1〜3時間 初心者へのやさしさ 82/100
メンテナーはふだん 1 日以内に返信
-
unexpand: final newline prevents conversion of leading Unicode blanks対応中かも @Ybucaille が 1 日前に担当しました。 オープンU - unexpand
難易度 2/5 1〜3時間 初心者へのやさしさ 86/100
メンテナーはふだん 1 日以内に返信
-
chmod -v: missing 'could not be accessed' line for operands that can't be stat'ed対応中かも @emirmrkaljevic が 2 日前に担当しました。 オープンU - chmod
難易度 2/5 1〜3時間 初心者へのやさしさ 68/100
メンテナーはふだん 1 日以内に返信
uutils/coreutils の issue をすべて見る
似ている issue
-
desktop documentation
難易度 1/5 1時間未満 初心者へのやさしさ 90/100
vfarcic/dot-agent-deck#2008 ·
メンテナーはふだん 1 日以内に返信
-
needs triage
難易度 1/5 1時間未満 初心者へのやさしさ 85/100
pbakaus/impeccable#1210 ·
メンテナーはふだん 1 日以内に返信
-
enhancement ux-opportunities
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
メンテナーはふだん 1 日以内に返信
-
area: ci priority: P4 ready-for-agent
難易度 2/5 1〜3時間 初心者へのやさしさ 74/100
shep-pm/shep#1249 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
`PartialEq` for `AssignedBigUint` treats different values as equal対応中かも このイシューにリンクされたプルリクエストがオープン中、またはマージ済みです。 オープンstatus:untriaged
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
midnightntwrk/midnight-zk#600 ·
メンテナーはふだん 2 日以内に返信