260915.1 · 24198d904a074f2943b2a3be925e53134c994734
fix: Better sanitizing
This refactors the sanitization code somewhat, to provide a couple benefits:
- Ensure we sanitize output in error messages (#2119), while not
escaping newlines from error messages.
- Avoids some unnecessary allocations and string copies
- Makes `print_error` a little nicer to use. As a macro, it avoids
needing a separate call to `format!`.
The main downside is added complexity.
Fixes: #2119
reading declarations
@@ -11,7 +11,6 @@ use clap::{ use clap_complete::Shell; use normpath::PathExt; -use crate::error::print_error; use crate::exec::CommandSet; use crate::filesystem; #[cfg(unix)]@@ -711,10 +710,12 @@ impl Opts { if filesystem::is_existing_directory(path) { Some(self.normalize_path(path)) } else {- print_error(format!(- "Search path '{}' is not a directory.",- path.to_string_lossy()- ));+ // We use debug for the path to make it more readable if it has special characters+ // Should we use a more reliable escape?+ print_error!(+ "Search path {:?} is not a directory.",+ path.to_string_lossy().as_ref()+ ); None } })
@@ -1,3 +1,51 @@-pub fn print_error(msg: impl std::fmt::Display) {- eprintln!("[fd error]: {msg}");+use std::fmt::Display;+use std::io;++use crate::sanitize::write_sanitized;++/// Print an error message using a format string, sanitizing any arguments if necessary+macro_rules! print_error {+ ($fmt:literal, $($arg:expr),*) => {+ eprintln!(concat!("[fd error]: ", $fmt), $($crate::error::SanitizeErr::sanitize($arg)),*)+ }+}++/// Trait for specifying how to sanitize a type for error display, if needed.+pub(crate) trait SanitizeErr {+ type Sanitized: Display;++ fn sanitize(self) -> Self::Sanitized;+}++impl SanitizeErr for &str {+ type Sanitized = Self;++ fn sanitize(self) -> Self {+ self+ }+}++impl SanitizeErr for io::Error {+ type Sanitized = SanitizedError<io::Error>;++ fn sanitize(self) -> Self::Sanitized {+ SanitizedError(self)+ }+}++impl SanitizeErr for ignore::Error {+ type Sanitized = SanitizedError<ignore::Error>;++ fn sanitize(self) -> Self::Sanitized {+ SanitizedError(self)+ }+}++pub struct SanitizedError<E>(E);++impl<E: std::error::Error> Display for SanitizedError<E> {+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {+ let raw = self.0.to_string();+ write_sanitized(f, &raw)+ } }
@@ -3,7 +3,6 @@ use std::io::Write; use argmax::Command; -use crate::error::print_error; use crate::exit_codes::ExitCode; struct Outputs {@@ -101,14 +100,14 @@ pub fn execute_commands<I: Iterator<Item = io::Result<Command>>>( pub fn handle_cmd_error(cmd: Option<&Command>, err: io::Error) -> ExitCode { match (cmd, err) { (Some(cmd), err) if err.kind() == io::ErrorKind::NotFound => {- print_error(format!(- "Command not found: {}",- cmd.get_program().to_string_lossy()- ));+ print_error!(+ "Command not found: {:?}",+ cmd.get_program().to_string_lossy().as_ref()+ ); ExitCode::GeneralError } (_, err) => {- print_error(format!("Problem while executing command: {err}"));+ print_error!("Problem while executing command: {}", err); ExitCode::GeneralError } }
@@ -1,5 +1,4 @@ use crate::config::Config;-use crate::error::print_error; use crate::exit_codes::{ExitCode, merge_exitcodes}; use crate::walk::WorkerResult; @@ -24,7 +23,7 @@ pub fn job( WorkerResult::Entry(dir_entry) => dir_entry, WorkerResult::Error(err) => { if config.show_filesystem_errors {- print_error(err.to_string());+ print_error!("{}", err); } continue; }@@ -54,7 +53,7 @@ pub fn batch( WorkerResult::Entry(dir_entry) => Some(dir_entry.into_stripped_path(config)), WorkerResult::Error(err) => { if config.show_filesystem_errors {- print_error(err.to_string());+ print_error!("{}", err); } None }
@@ -1,7 +1,10 @@+// needs to be first because it defines a macro+#[macro_use]+mod error;+ mod cli; mod config; mod dir_entry;-mod error; mod exec; mod exit_codes; mod filesystem;@@ -66,7 +69,9 @@ fn main() { exit_code.exit(); } Err(err) => {- crate::error::print_error(format!("{err:#}"));+ // NB: we use eprintln directly instead of print_error!() because+ // we sanitize anyhow errors at the generation site+ eprintln!("[fd error]: {:#}", err); ExitCode::GeneralError.exit(); } }@@ -132,14 +137,14 @@ fn set_working_dir(opts: &Opts) -> Result<()> { if let Some(ref base_directory) = opts.base_directory { if !filesystem::is_existing_directory(base_directory) { return Err(anyhow!(- "The '--base-directory' path '{}' is not a directory.",- base_directory.to_string_lossy()+ "The '--base-directory' path {:?} is not a directory.",+ base_directory.to_string_lossy().as_ref() )); } env::set_current_dir(base_directory).with_context(|| { format!(- "Could not set '{}' as the current working directory",- base_directory.to_string_lossy()+ "Could not set {:?} as the current working directory",+ base_directory.to_string_lossy().as_ref() ) })?; }
@@ -7,7 +7,7 @@ use crate::config::Config; use crate::dir_entry::DirEntry; use crate::fmt::FormatTemplate; use crate::hyperlink::PathUrl;-use crate::sanitize::maybe_sanitize;+use crate::sanitize::sanitize_for_term; fn replace_path_separator(path: &str, new_path_separator: &str) -> String { path.replace(std::path::MAIN_SEPARATOR, new_path_separator)@@ -81,7 +81,7 @@ fn print_entry_format<W: Write>( write!( stdout, "{}",- maybe_sanitize(&s, config.interactive_terminal)+ sanitize_for_term(&s, config.interactive_terminal) ) } @@ -117,16 +117,30 @@ fn print_entry_colorized<W: Write>( .style_for_indicator(Indicator::Directory) .map(Style::to_nu_ansi_term_style) .unwrap_or_default();- let safe_parent = maybe_sanitize(&parent_str, config.interactive_terminal);- write!(stdout, "{}", style.paint(safe_parent.as_ref()))?;+ let safe_parent = sanitize_for_term(&parent_str, config.interactive_terminal);+ // We explicitly use prefix/suffix instead of paint so that we can use Sanitized+ write!(+ stdout,+ "{}{}{}",+ style.prefix(),+ safe_parent,+ style.suffix()+ )?; } let style = entry .style(ls_colors) .map(Style::to_nu_ansi_term_style) .unwrap_or_default();- let safe_basename = maybe_sanitize(&path_str[offset..], config.interactive_terminal);- write!(stdout, "{}", style.paint(safe_basename.as_ref()))?;+ let safe_basename = sanitize_for_term(&path_str[offset..], config.interactive_terminal);+ // We explicitly use prefix/suffix instead of paint so that we can use Sanitized+ write!(+ stdout,+ "{}{}{}",+ style.prefix(),+ safe_basename,+ style.suffix()+ )?; print_trailing_slash( stdout,@@ -150,7 +164,7 @@ fn print_entry_uncolorized_base<W: Write>( if let Some(ref separator) = config.path_separator { *path_string.to_mut() = replace_path_separator(&path_string, separator); }- let safe = maybe_sanitize(&path_string, config.interactive_terminal);+ let safe = sanitize_for_term(&path_string, config.interactive_terminal); write!(stdout, "{safe}")?; print_trailing_slash(stdout, entry, config, None) }
@@ -1,7 +1,6 @@ //! TTY-output sanitization to prevent terminal escape injection via filenames. -use std::borrow::Cow;-use std::fmt::Write;+use std::fmt::{Display, Formatter, Write}; /// True for any char that is neither printable nor permitted whitespace (only HT). /// Covers C0/C1/DEL, bidi overrides, zero-width and format chars, and tag chars.@@ -23,34 +22,51 @@ fn needs_escape(c: char) -> bool { ) } -/// Returns a `Cow<str>` borrowing `s` when no escaping is needed, otherwise an owned-/// escaped copy. Use this when an owned `&str`/`String` is required (e.g. ANSI paint).-pub fn sanitize_for_terminal(s: &str) -> Cow<'_, str> {- if !s.chars().any(needs_escape) {- return Cow::Borrowed(s);- }- let mut out = String::with_capacity(s.len());- for c in s.chars() {+/// Write the sanitized contents of `raw` to `f`.+pub fn write_sanitized(f: &mut std::fmt::Formatter<'_>, raw: &str) -> std::fmt::Result {+ // Would it be faster to do a pass to see if we don't need an escape and write+ // the whole string first? Maybe with a faster check just for non-control ASCII?+ for c in raw.chars() { if needs_escape(c) { let v = c as u32; if v <= 0xFF {- let _ = write!(out, "\\x{v:02X}");+ write!(f, "\\x{v:02X}")?; } else {- let _ = write!(out, "\\u{{{v:04X}}}");+ write!(f, "\\u{{{v:04X}}}")?; } } else {- out.push(c);+ f.write_char(c)?;+ }+ }+ Ok(())+}++// TODO: add something to sanitize paths directly instead of as strings.++/// A wrapper type that sanitizes the output to Display+pub(crate) struct SanitizedStr<'a> {+ raw: &'a str,+ /// If true, the content is safe to output without sanitizing,+ /// either because it is trusted, or because it isn't going to a terminal.+ is_safe: bool,+}++impl<'a> Display for SanitizedStr<'a> {+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {+ if self.is_safe {+ // We don't need to sanitize anything, just forward+ self.raw.fmt(f)+ } else {+ write_sanitized(f, self.raw) } }- Cow::Owned(out) } /// Sanitize for terminal output only; raw bytes pass through on pipes/files.-pub fn maybe_sanitize<'a>(s: &'a str, is_terminal: bool) -> Cow<'a, str> {- if is_terminal {- sanitize_for_terminal(s)- } else {- Cow::Borrowed(s)+pub fn sanitize_for_term(raw: &str, is_terminal: bool) -> SanitizedStr<'_> {+ SanitizedStr {+ raw,+ is_safe: !is_terminal, } } @@ -58,6 +74,14 @@ pub fn maybe_sanitize<'a>(s: &'a str, is_terminal: bool) -> Cow<'a, str> { mod tests { use super::*; + fn sanitize_string(s: &str) -> String {+ SanitizedStr {+ raw: s,+ is_safe: false,+ }+ .to_string()+ }+ #[test] fn preserves_safe_content() { for s in [@@ -68,75 +92,63 @@ mod tests { "a\tb", "a\u{FFFD}b", ] {- assert!(- matches!(sanitize_for_terminal(s), Cow::Borrowed(_)),- "{s:?}"- );- assert_eq!(sanitize_for_terminal(s), s);+ assert_eq!(sanitize_string(s), s); } } #[test] fn strips_osc52_clipboard_payload() { let attack = "innocent\x1b]52;c;cHduZWQ=\x1b\\.txt";- let safe = sanitize_for_terminal(attack);+ let safe = sanitize_string(attack); assert!(!safe.contains('\x1b')); assert_eq!(safe, "innocent\\x1B]52;c;cHduZWQ=\\x1B\\.txt"); } #[test] fn strips_cr_output_forgery() {- assert_eq!(sanitize_for_terminal("A\rFAKE OUTPUT"), "A\\x0DFAKE OUTPUT");+ assert_eq!(sanitize_string("A\rFAKE OUTPUT"), "A\\x0DFAKE OUTPUT"); } #[test] fn strips_osc8_hyperlink_injection() { let attack = "phish\x1b]8;;https:evil.example\x1b\\phony.txt";- assert!(!sanitize_for_terminal(attack).contains('\x1b'));+ assert!(!sanitize_string(attack).contains('\x1b')); } #[test] fn strips_del() {- assert_eq!(sanitize_for_terminal("a\x7fb"), "a\\x7Fb");+ assert_eq!(sanitize_string("a\x7fb"), "a\\x7Fb"); } #[test] fn strips_bel_and_null() {- assert_eq!(sanitize_for_terminal("a\x07b"), "a\\x07b");- assert_eq!(sanitize_for_terminal("a\0b"), "a\\x00b");- }-- #[test]- fn strips_newline() {- assert_eq!(sanitize_for_terminal("a\nb"), "a\\x0Ab");+ assert_eq!(sanitize_string("a\x07b"), "a\\x07b");+ assert_eq!(sanitize_string("a\0b"), "a\\x00b"); } #[test] fn escape_preserves_information() { let s = "name\x1bX\x07Y.txt";- assert_eq!(sanitize_for_terminal(s), "name\\x1BX\\x07Y.txt");+ assert_eq!(sanitize_string(s), "name\\x1BX\\x07Y.txt"); } #[test] fn strips_c1_csi_and_osc_initiators() { // U+009B is CSI, U+009D is OSC; dangerous on 8-bit-control terminals.- assert_eq!(sanitize_for_terminal("\u{9b}31m"), "\\x9B31m");- assert_eq!(- sanitize_for_terminal("\u{9d}0;pwned\u{9c}"),- "\\x9D0;pwned\\x9C"- );+ assert_eq!(sanitize_string("\u{9b}31m"), "\\x9B31m");+ assert_eq!(sanitize_string("\u{9d}0;pwned\u{9c}"), "\\x9D0;pwned\\x9C"); } #[test] fn strips_bidi_overrides_and_zero_width() { // Trojan-Source style RLO/LRO that flip rendered order of filename text. assert_eq!(- sanitize_for_terminal("safe\u{202E}fil\u{202D}gnp.exe"),+ sanitize_string("safe\u{202E}fil\u{202D}gnp.exe"), "safe\\u{202E}fil\\u{202D}gnp.exe" ); // Zero-width space and BOM are also format chars used to disguise filenames.- assert_eq!(sanitize_for_terminal("a\u{200B}b"), "a\\u{200B}b");- assert_eq!(sanitize_for_terminal("\u{FEFF}name"), "\\u{FEFF}name");+ assert_eq!(sanitize_string("a\u{200B}b"), "a\\u{200B}b");+ assert_eq!(sanitize_string("\u{FEFF}name"), "\\u{FEFF}name"); } #[test]@@ -148,10 +160,7 @@ mod tests { "icon\u{E000}.cfg", "cjk\u{6F22}\u{E0101}.txt", ] {- assert!(- matches!(sanitize_for_terminal(s), Cow::Borrowed(_)),- "{s:?}"- );+ assert_eq!(sanitize_string(s), s); } } @@ -159,10 +168,9 @@ mod tests { fn maybe_sanitize_passthrough_when_not_terminal() { let attack = "x\x1by"; // Pipe context: bytes pass through unchanged (zero-copy).- let out = maybe_sanitize(attack, false);- assert!(matches!(out, Cow::Borrowed(_)));+ let out = sanitize_for_term(attack, false).to_string(); assert_eq!(out, attack); // TTY context: escapes apply.- assert_eq!(maybe_sanitize(attack, true), "x\\x1By");+ assert_eq!(sanitize_for_term(attack, true).to_string(), "x\\x1By"); } }
@@ -17,7 +17,6 @@ use regex::bytes::Regex; use crate::config::Config; use crate::dir_entry::DirEntry;-use crate::error::print_error; use crate::exec; use crate::exit_codes::{ExitCode, merge_exitcodes}; use crate::filesystem;@@ -226,7 +225,7 @@ impl<'a, W: Write> ReceiverBuffer<'a, W> { } WorkerResult::Error(err) => { if self.config.show_filesystem_errors {- print_error(err.to_string());+ print_error!("{}", err); } } }@@ -253,7 +252,7 @@ impl<'a, W: Write> ReceiverBuffer<'a, W> { if let Err(e) = output::print_entry(&mut self.stdout, entry, self.config) && e.kind() != ::std::io::ErrorKind::BrokenPipe {- print_error(format!("Could not write to output: {e}"));+ print_error!("Could not write to output: {}", e); return Err(ExitCode::GeneralError); } @@ -377,7 +376,7 @@ impl WorkerState { match result { Some(ignore::Error::Partial(_)) => (), Some(err) => {- print_error(format!("Malformed pattern in global ignore file. {err}."));+ print_error!("Malformed pattern in global ignore file. {}.", err); } None => (), }@@ -389,7 +388,7 @@ impl WorkerState { match result { Some(ignore::Error::Partial(_)) => (), Some(err) => {- print_error(format!("Malformed pattern in custom ignore file. {err}."));+ print_error!("Malformed pattern in custom ignore file. {}.", err); } None => (), }
@@ -196,6 +196,22 @@ impl TestEnv { Ok(broken_symlink_link) } + pub fn create_symlink<P: AsRef<Path>>(+ &mut self,+ target: P,+ link_path: P,+ ) -> io::Result<PathBuf> {+ let root = self.test_root();+ let target_path = root.join(target);+ let link_path = root.join(link_path);+ #[cfg(unix)]+ unix::fs::symlink(&target_path, &link_path)?;+ #[cfg(windows)]+ windows::fs::symlink_file(&target_path, &link_path)?;++ Ok(link_path)+ }+ /// Get the root directory for the tests. pub fn test_root(&self) -> PathBuf { self.temp_dir.path().to_path_buf()
@@ -345,7 +345,7 @@ fn test_multi_file_with_missing() { te.assert_error( &["a.foo", "real", "fake"],- "[fd error]: Search path 'fake' is not a directory.",+ "[fd error]: Search path \"fake\" is not a directory.", ); te.assert_output(@@ -362,14 +362,14 @@ fn test_multi_file_with_missing() { te.assert_error( &["", "real", "fake1", "fake2"],- "[fd error]: Search path 'fake1' is not a directory.- [fd error]: Search path 'fake2' is not a directory.",+ "[fd error]: Search path \"fake1\" is not a directory.+ [fd error]: Search path \"fake2\" is not a directory.", ); te.assert_failure_with_error( &["", "fake1", "fake2"],- "[fd error]: Search path 'fake1' is not a directory.- [fd error]: Search path 'fake2' is not a directory.+ "[fd error]: Search path \"fake1\" is not a directory.+ [fd error]: Search path \"fake2\" is not a directory. [fd error]: No valid search paths given.", ); }@@ -2887,3 +2887,33 @@ fn test_ignore_contain_precedence_over_root_check() { let expected = ""; te.assert_output(&["--ignore-contain=CACHEDIR.TAG", "."], expected); }++// The error message is probably OS-specific.+// This is also somewhat fragile as it depends on the error message+// from creating an executable, and the debug formatting of strings+// could possibly change in the future.+#[cfg(unix)]+#[test]+fn test_sanitize_exec_error_msg() {+ let mut te = TestEnv::new(&[], &[]);+ te.create_broken_symlink("Hello\x1b\r World!\x7fwith\u{9b}\u{1F600}\u{200B}a\u{FEFF}b")+ .expect("failed to create symlink");++ te.assert_error(&["Hello", "--exec", "{}"], + "[fd error]: Command not found: \"./Hello\\u{1b}\\r World!\\u{7f}with\\u{9b}\u{1F600}\\u{200b}a\\u{feff}b\""+ );+}++// Windows doesn't let us make files containing \x1b so only test on unix+#[cfg(unix)]+#[test]+fn test_sanitize_recursive_link_msg() {+ let mut te = TestEnv::new(&["loop"], &[]);+ te.create_symlink("loop", "loop/foo\x1bb")+ .expect("failed to create symlink");++ te.assert_error(+ &["--follow", "--show-errors", "foo"],+ "[fd error]: File system loop found: ./loop/foo\\x1Bb points to an ancestor ./loop",+ );+}