Skip to content

Commit 63a7d85

Browse files
author
curious-rabbit
committed
improve patch
1 parent 92c54d3 commit 63a7d85

7 files changed

Lines changed: 73 additions & 159 deletions

File tree

src/dir_entry.rs

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::borrow::Cow;
12
use std::cell::OnceCell;
23
use std::ffi::OsString;
34
use std::fs::{FileType, Metadata};
@@ -54,18 +55,26 @@ impl DirEntry {
5455
}
5556

5657
/// Returns the path as it should be presented to the user.
57-
pub fn stripped_path(&self, config: &Config) -> &Path {
58+
/// When stripping `./` would leave the path starting with `-`, keep the `./` so
59+
/// downstream tools don't interpret the filename as an option.
60+
pub fn stripped_path(&self, config: &Config) -> Cow<'_, Path> {
61+
let path = self.path();
5862
if config.strip_cwd_prefix {
59-
strip_current_dir(self.path())
63+
let stripped = strip_current_dir(path);
64+
if starts_with_dash(stripped) {
65+
Cow::Owned(Path::new(".").join(stripped))
66+
} else {
67+
Cow::Borrowed(stripped)
68+
}
6069
} else {
61-
self.path()
70+
Cow::Borrowed(path)
6271
}
6372
}
6473

6574
/// Returns the path as it should be presented to the user.
6675
pub fn into_stripped_path(self, config: &Config) -> PathBuf {
6776
if config.strip_cwd_prefix {
68-
self.stripped_path(config).to_path_buf()
77+
self.stripped_path(config).into_owned()
6978
} else {
7079
self.into_path()
7180
}
@@ -101,6 +110,10 @@ impl DirEntry {
101110
}
102111
}
103112

113+
fn starts_with_dash(path: &Path) -> bool {
114+
path.as_os_str().as_encoded_bytes().first() == Some(&b'-')
115+
}
116+
104117
impl PartialEq for DirEntry {
105118
#[inline]
106119
fn eq(&self, other: &Self) -> bool {
@@ -153,3 +166,25 @@ impl Colorable for DirEntry {
153166
self.metadata().cloned()
154167
}
155168
}
169+
170+
#[cfg(test)]
171+
mod tests {
172+
use super::starts_with_dash;
173+
use std::path::Path;
174+
175+
#[test]
176+
fn dash_prefixed_paths_detected() {
177+
assert!(starts_with_dash(Path::new("-rf")));
178+
assert!(starts_with_dash(Path::new("--delete")));
179+
assert!(starts_with_dash(Path::new("-")));
180+
}
181+
182+
#[test]
183+
fn safe_paths_not_flagged() {
184+
assert!(!starts_with_dash(Path::new("foo")));
185+
assert!(!starts_with_dash(Path::new("./foo")));
186+
assert!(!starts_with_dash(Path::new("sub/-rf")));
187+
assert!(!starts_with_dash(Path::new("")));
188+
assert!(!starts_with_dash(Path::new(" -rf")));
189+
}
190+
}

src/exec/job.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ pub fn job(
3232

3333
// Generate a command, execute it and store its exit code.
3434
let code = cmd.execute(
35-
dir_entry.stripped_path(config),
35+
&dir_entry.stripped_path(config),
3636
config.path_separator.as_deref(),
3737
config.null_separator,
3838
buffer_output,

src/exec/mod.rs

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ impl CommandBuilder {
175175
self.finish()?;
176176
}
177177

178-
let arg = self.path_arg.generate_for_exec(path, separator);
178+
let arg = self.path_arg.generate(path, separator);
179179
if !self
180180
.cmd
181181
.args_would_fit(iter::once(&arg).chain(&self.post_args))
@@ -242,8 +242,6 @@ impl CommandTemplate {
242242
bail!("No executable provided for --exec or --exec-batch");
243243
}
244244

245-
// The first argument is the command to execute and must be a fixed string;
246-
// allowing a placeholder here would turn every matched file into an execve target.
247245
if args[0].has_tokens() {
248246
bail!("First argument of --exec/--exec-batch must be a fixed executable, not a placeholder");
249247
}
@@ -265,9 +263,9 @@ impl CommandTemplate {
265263
/// Using the internal `args` field, and a supplied `input` variable, a `Command` will be
266264
/// build.
267265
fn generate(&self, input: &Path, path_separator: Option<&str>) -> io::Result<Command> {
268-
let mut cmd = Command::new(self.args[0].generate_for_exec(input, path_separator));
266+
let mut cmd = Command::new(self.args[0].generate(input, path_separator));
269267
for arg in &self.args[1..] {
270-
cmd.try_arg(arg.generate_for_exec(input, path_separator))?;
268+
cmd.try_arg(arg.generate(input, path_separator))?;
271269
}
272270
Ok(cmd)
273271
}

src/fmt/mod.rs

Lines changed: 1 addition & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -24,19 +24,6 @@ pub enum Token {
2424
Text(String),
2525
}
2626

27-
impl Token {
28-
fn is_path_derived(&self) -> bool {
29-
matches!(
30-
self,
31-
Token::Placeholder
32-
| Token::Basename
33-
| Token::Parent
34-
| Token::NoExt
35-
| Token::BasenameNoExt
36-
)
37-
}
38-
}
39-
4027
impl Display for Token {
4128
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
4229
match *self {
@@ -123,26 +110,8 @@ impl FormatTemplate {
123110
/// the path separator in all placeholder tokens. Fixed text and tokens are not affected by
124111
/// path separator substitution.
125112
pub fn generate(&self, path: impl AsRef<Path>, path_separator: Option<&str>) -> OsString {
126-
self.generate_impl(path.as_ref(), path_separator, false)
127-
}
128-
129-
/// Like `generate`, but prepends `./` when a path-derived token produces a leading `-`,
130-
/// so the target of `--exec` does not see it as an option.
131-
pub fn generate_for_exec(
132-
&self,
133-
path: impl AsRef<Path>,
134-
path_separator: Option<&str>,
135-
) -> OsString {
136-
self.generate_impl(path.as_ref(), path_separator, true)
137-
}
138-
139-
fn generate_impl(
140-
&self,
141-
path: &Path,
142-
path_separator: Option<&str>,
143-
protect_leading_dash: bool,
144-
) -> OsString {
145113
use Token::*;
114+
let path = path.as_ref();
146115

147116
match *self {
148117
Self::Tokens(ref tokens) => {
@@ -165,16 +134,6 @@ impl FormatTemplate {
165134
Text(string) => s.push(string),
166135
}
167136
}
168-
169-
if protect_leading_dash
170-
&& tokens.first().is_some_and(|t| t.is_path_derived())
171-
&& s.as_encoded_bytes().first() == Some(&b'-')
172-
{
173-
let mut prefixed = OsString::from("./");
174-
prefixed.push(s);
175-
return prefixed;
176-
}
177-
178137
s
179138
}
180139
Self::Text(ref text) => OsString::from(text),
@@ -319,86 +278,4 @@ mod fmt_tests {
319278
basenameNoExt=file }"
320279
);
321280
}
322-
323-
fn tmpl(s: &str) -> FormatTemplate {
324-
FormatTemplate::parse(s)
325-
}
326-
327-
#[test]
328-
fn exec_basename_with_leading_dash_is_guarded() {
329-
let out = tmpl("{/}")
330-
.generate_for_exec(Path::new("./some/dir/-rf"), None)
331-
.into_string()
332-
.unwrap();
333-
assert_eq!(out, "./-rf");
334-
}
335-
336-
#[test]
337-
fn exec_basename_no_ext_with_leading_dash_is_guarded() {
338-
let out = tmpl("{/.}")
339-
.generate_for_exec(Path::new("./some/dir/-evil.txt"), None)
340-
.into_string()
341-
.unwrap();
342-
assert_eq!(out, "./-evil");
343-
}
344-
345-
#[test]
346-
fn exec_parent_with_leading_dash_is_guarded() {
347-
let out = tmpl("{//}")
348-
.generate_for_exec(Path::new("-startdir/inner/file.txt"), None)
349-
.into_string()
350-
.unwrap();
351-
assert_eq!(out, "./-startdir/inner");
352-
}
353-
354-
#[test]
355-
fn exec_plain_placeholder_already_safe() {
356-
let out = tmpl("{}")
357-
.generate_for_exec(Path::new("./some/dir/-rf"), None)
358-
.into_string()
359-
.unwrap();
360-
assert_eq!(out, "./some/dir/-rf");
361-
}
362-
363-
#[test]
364-
fn exec_user_literal_dash_prefix_not_rewritten() {
365-
let out = tmpl("-{/}")
366-
.generate_for_exec(Path::new("./some/dir/normal.txt"), None)
367-
.into_string()
368-
.unwrap();
369-
assert_eq!(out, "-normal.txt");
370-
}
371-
372-
#[test]
373-
fn exec_user_literal_dash_before_dash_basename() {
374-
let out = tmpl("-{/}")
375-
.generate_for_exec(Path::new("./some/dir/-name"), None)
376-
.into_string()
377-
.unwrap();
378-
assert_eq!(out, "--name");
379-
}
380-
381-
#[test]
382-
fn exec_suffix_after_placeholder_preserves_guard() {
383-
let out = tmpl("{/}.bak")
384-
.generate_for_exec(Path::new("./some/dir/-foo"), None)
385-
.into_string()
386-
.unwrap();
387-
assert_eq!(out, "./-foo.bak");
388-
}
389-
390-
#[test]
391-
fn format_mode_does_not_protect_leading_dash() {
392-
let out = tmpl("{/}")
393-
.generate(Path::new("./some/dir/-rf"), None)
394-
.into_string()
395-
.unwrap();
396-
assert_eq!(out, "-rf");
397-
}
398-
399-
#[test]
400-
fn exec_empty_result_not_prefixed() {
401-
let out = tmpl("{/}").generate_for_exec(Path::new(""), None);
402-
assert!(out.is_empty());
403-
}
404281
}

src/main.rs

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,7 @@ fn main() {
6666
exit_code.exit();
6767
}
6868
Err(err) => {
69-
// Gap fix: anyhow errors bubbled from run() may embed user paths/args.
70-
let formatted = format!("{err:#}");
71-
if std::io::stderr().is_terminal() {
72-
eprintln!("[fd error]: {}", sanitize::sanitize_for_terminal(&formatted));
73-
} else {
74-
eprintln!("[fd error]: {formatted}");
75-
}
69+
crate::error::print_error(format!("{err:#}"));
7670
ExitCode::GeneralError.exit();
7771
}
7872
}

src/sanitize.rs

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,29 @@
11
//! TTY-output sanitization to prevent terminal escape injection via filenames.
22
33
use std::borrow::Cow;
4+
use std::fmt::Write;
45

56
#[inline]
67
fn is_dangerous_control(c: char) -> bool {
7-
// C0 (except HT), DEL, and C1 controls (gap fix: U+0080..=U+009F can act
8-
// as single-byte CSI/OSC initiators on 8-bit-control terminals).
8+
// C0 (except HT), DEL, and C1 controls (U+0080..=U+009F can act as
9+
// single-byte CSI/OSC initiators on 8-bit-control terminals).
910
matches!(c, '\x00'..='\x08' | '\x0A'..='\x1F' | '\x7F' | '\u{80}'..='\u{9F}')
1011
}
1112

12-
/// Replace control characters with `?` (matches `ls -q`). Borrows on the fast path.
13+
/// Replace control characters with `\xNN` so the original filename remains recoverable.
1314
pub fn sanitize_for_terminal(s: &str) -> Cow<'_, str> {
1415
if !s.chars().any(is_dangerous_control) {
1516
return Cow::Borrowed(s);
1617
}
17-
Cow::Owned(
18-
s.chars()
19-
.map(|c| if is_dangerous_control(c) { '?' } else { c })
20-
.collect(),
21-
)
18+
let mut out = String::with_capacity(s.len());
19+
for c in s.chars() {
20+
if is_dangerous_control(c) {
21+
let _ = write!(out, "\\x{:02X}", c as u32);
22+
} else {
23+
out.push(c);
24+
}
25+
}
26+
Cow::Owned(out)
2227
}
2328

2429
#[cfg(test)]
@@ -27,7 +32,6 @@ mod tests {
2732

2833
#[test]
2934
fn preserves_safe_content() {
30-
// Plain text, unicode, tab, and existing U+FFFD all pass through without allocation.
3135
for s in ["hello.txt", "résumé.pdf", "文档.txt", "🦀.rs", "a\tb", "a\u{FFFD}b"] {
3236
assert!(matches!(sanitize_for_terminal(s), Cow::Borrowed(_)), "{s:?}");
3337
assert_eq!(sanitize_for_terminal(s), s);
@@ -39,12 +43,12 @@ mod tests {
3943
let attack = "innocent\x1b]52;c;cHduZWQ=\x1b\\.txt";
4044
let safe = sanitize_for_terminal(attack);
4145
assert!(!safe.contains('\x1b'));
42-
assert_eq!(safe, "innocent?]52;c;cHduZWQ=?\\.txt");
46+
assert_eq!(safe, "innocent\\x1B]52;c;cHduZWQ=\\x1B\\.txt");
4347
}
4448

4549
#[test]
4650
fn strips_cr_output_forgery() {
47-
assert_eq!(sanitize_for_terminal("A\rFAKE OUTPUT"), "A?FAKE OUTPUT");
51+
assert_eq!(sanitize_for_terminal("A\rFAKE OUTPUT"), "A\\x0DFAKE OUTPUT");
4852
}
4953

5054
#[test]
@@ -55,24 +59,30 @@ mod tests {
5559

5660
#[test]
5761
fn strips_del() {
58-
assert_eq!(sanitize_for_terminal("a\x7fb"), "a?b");
62+
assert_eq!(sanitize_for_terminal("a\x7fb"), "a\\x7Fb");
5963
}
6064

6165
#[test]
6266
fn strips_bel_and_null() {
63-
assert_eq!(sanitize_for_terminal("a\x07b"), "a?b");
64-
assert_eq!(sanitize_for_terminal("a\0b"), "a?b");
67+
assert_eq!(sanitize_for_terminal("a\x07b"), "a\\x07b");
68+
assert_eq!(sanitize_for_terminal("a\0b"), "a\\x00b");
6569
}
6670

6771
#[test]
6872
fn strips_newline() {
69-
assert_eq!(sanitize_for_terminal("a\nb"), "a?b");
73+
assert_eq!(sanitize_for_terminal("a\nb"), "a\\x0Ab");
74+
}
75+
76+
#[test]
77+
fn escape_preserves_information() {
78+
let s = "name\x1bX\x07Y.txt";
79+
assert_eq!(sanitize_for_terminal(s), "name\\x1BX\\x07Y.txt");
7080
}
7181

7282
#[test]
7383
fn strips_c1_csi_and_osc_initiators() {
7484
// U+009B is CSI, U+009D is OSC; dangerous on 8-bit-control terminals.
75-
assert_eq!(sanitize_for_terminal("\u{9b}31m"), "?31m");
76-
assert_eq!(sanitize_for_terminal("\u{9d}0;pwned\u{9c}"), "?0;pwned?");
85+
assert_eq!(sanitize_for_terminal("\u{9b}31m"), "\\x9B31m");
86+
assert_eq!(sanitize_for_terminal("\u{9d}0;pwned\u{9c}"), "\\x9D0;pwned\\x9C");
7787
}
7888
}

tests/tests.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1906,7 +1906,7 @@ fn test_exec_batch() {
19061906

19071907
te.assert_failure_with_error(
19081908
&["foo", "--exec-batch", "echo {}"],
1909-
"error: First argument of exec-batch is expected to be a fixed executable\n\
1909+
"error: First argument of --exec/--exec-batch must be a fixed executable, not a placeholder\n\
19101910
\n\
19111911
Usage: fd [OPTIONS] [pattern] [path]...\n\
19121912
\n\

0 commit comments

Comments
 (0)