diff --git a/src/core.rs b/src/core.rs index b867cadfc..717199e51 100644 --- a/src/core.rs +++ b/src/core.rs @@ -124,7 +124,7 @@ impl Core { match Meta::from_path(&path, self.flags.dereference.0, self.flags.permission) { Ok(meta) => meta, Err(err) => { - print_error!("{}: {}.", path.display(), err); + print_error!("{}: {}.", crate::display_util::SafePath(&path), err); exit_code.set_if_greater(ExitCode::MajorIssue); continue; } @@ -147,7 +147,7 @@ impl Core { exit_code.set_if_greater(path_exit_code); } Err(err) => { - print_error!("lsd: {}: {}\n", path.display(), err); + print_error!("lsd: {}: {}\n", crate::display_util::SafePath(&path), err); exit_code.set_if_greater(ExitCode::MinorIssue); continue; } diff --git a/src/display.rs b/src/display.rs index 842ffb248..44db2ec02 100644 --- a/src/display.rs +++ b/src/display.rs @@ -318,7 +318,7 @@ fn should_display_folder_path(depth: usize, metas: &[Meta]) -> bool { } fn display_folder_path(meta: &Meta) -> String { - format!("\n{}:\n", meta.path.to_string_lossy()) + format!("\n{}:\n", crate::display_util::SafePath(&meta.path)) } #[allow(clippy::too_many_arguments)] diff --git a/src/display_util.rs b/src/display_util.rs new file mode 100644 index 000000000..65452a894 --- /dev/null +++ b/src/display_util.rs @@ -0,0 +1,117 @@ +use std::borrow::Cow; +use std::fmt; +use std::path::Path; + +#[inline] +fn is_dangerous(c: char) -> bool { + let cp = c as u32; + cp < 0x20 + || cp == 0x7f + // C1 controls (raw and UTF-8 form both decode here) + || (0x80..=0x9f).contains(&cp) + || matches!(c, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}') +} + +pub fn sanitize_for_terminal(s: &str) -> Cow<'_, str> { + if !s.chars().any(is_dangerous) { + return Cow::Borrowed(s); + } + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + if is_dangerous(c) { + out.extend(c.escape_default()); + } else { + out.push(c); + } + } + Cow::Owned(out) +} + +pub struct SafePath<'a>(pub &'a Path); + +impl fmt::Display for SafePath<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&sanitize_for_terminal(&self.0.to_string_lossy())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn sanitize_passes_through_clean_strings() { + assert!(matches!(sanitize_for_terminal("hello.txt"), Cow::Borrowed(_))); + assert_eq!(sanitize_for_terminal("hello.txt"), "hello.txt"); + } + + #[test] + fn sanitize_preserves_non_ascii() { + let s = "café_文件.txt"; + assert!(matches!(sanitize_for_terminal(s), Cow::Borrowed(_))); + assert_eq!(sanitize_for_terminal(s), s); + } + + #[test] + fn sanitize_escapes_esc() { + let s = "a\x1b[31mred\x1b[0m"; + let out = sanitize_for_terminal(s); + assert!(matches!(out, Cow::Owned(_))); + assert!(!out.contains('\x1b')); + assert!(out.contains("\\u{1b}")); + } + + #[test] + fn sanitize_escapes_newline_and_tab() { + let out = sanitize_for_terminal("a\nb\tc"); + assert_eq!(out, "a\\nb\\tc"); + } + + #[test] + fn sanitize_escapes_bell() { + let out = sanitize_for_terminal("a\x07b"); + assert!(!out.contains('\x07')); + } + + #[test] + fn sanitize_escapes_del() { + let out = sanitize_for_terminal("a\x7fb"); + assert!(!out.contains('\x7f')); + } + + #[test] + fn sanitize_escapes_bidi_override() { + let out = sanitize_for_terminal("innocent\u{202e}gpj.exe"); + assert!(matches!(out, Cow::Owned(_))); + assert!(!out.contains('\u{202e}')); + assert!(out.contains("\\u{202e}")); + } + + #[test] + fn sanitize_escapes_bidi_isolate() { + let out = sanitize_for_terminal("a\u{2066}b"); + assert!(!out.contains('\u{2066}')); + } + + #[test] + fn safe_path_display_sanitizes() { + let p = PathBuf::from("dir/evil\x1b[2Jcleared/file"); + let rendered = format!("{}", SafePath(&p)); + assert!(!rendered.contains('\x1b')); + assert!(rendered.contains("\\u{1b}")); + } + + #[test] + fn safe_path_clean_is_unchanged() { + let p = PathBuf::from("/home/user/doc.txt"); + assert_eq!(format!("{}", SafePath(&p)), "/home/user/doc.txt"); + } + + #[test] + fn sanitize_escapes_c1_controls() { + let out = sanitize_for_terminal("a\u{9d}52;c;evil\u{9c}b"); + assert!(!out.contains('\u{9d}')); + assert!(!out.contains('\u{9c}')); + } +} diff --git a/src/git.rs b/src/git.rs index aac48ca69..d883a2d62 100644 --- a/src/git.rs +++ b/src/git.rs @@ -60,8 +60,10 @@ impl GitCache { match repo.statuses(None) { Ok(status_list) => { for status_entry in status_list.iter() { - // git2-rs provides / separated path even on Windows. We have to rebuild it - let str_path = status_entry.path().unwrap(); + // git2 returns None for non-utf8 paths, skip them + let Some(str_path) = status_entry.path() else { + continue; + }; let path: PathBuf = str_path.split('/').collect::>().iter().collect(); let path = workdir.join(path); diff --git a/src/main.rs b/src/main.rs index e6b7e914e..d25a3a9cb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,7 @@ mod color; mod config_file; mod core; mod display; +mod display_util; mod flags; mod git; mod git_theme; diff --git a/src/meta/access_control.rs b/src/meta/access_control.rs index ddb4d5caa..d54dc8c7d 100644 --- a/src/meta/access_control.rs +++ b/src/meta/access_control.rs @@ -61,7 +61,10 @@ impl AccessControl { if context.is_empty() { context += "?"; } - colors.colorize(context, &Elem::Context) + colors.colorize( + crate::display_util::sanitize_for_terminal(&context).into_owned(), + &Elem::Context, + ) } } diff --git a/src/meta/mod.rs b/src/meta/mod.rs index 81b9dbcc5..aa44e85eb 100644 --- a/src/meta/mod.rs +++ b/src/meta/mod.rs @@ -87,7 +87,7 @@ impl Meta { let entries = match self.path.read_dir() { Ok(entries) => entries, Err(err) => { - print_error!("{}: {}.", self.path.display(), err); + print_error!("{}: {}.", crate::display_util::SafePath(&self.path), err); return Ok((None, ExitCode::MinorIssue)); } }; @@ -151,7 +151,7 @@ impl Meta { { Ok(res) => res, Err(err) => { - print_error!("{}: {}.", path.display(), err); + print_error!("{}: {}.", crate::display_util::SafePath(&path), err); exit_code.set_if_greater(ExitCode::MinorIssue); continue; } @@ -173,7 +173,7 @@ impl Meta { exit_code.set_if_greater(rec_exit_code); } Err(err) => { - print_error!("{}: {}.", path.display(), err); + print_error!("{}: {}.", crate::display_util::SafePath(&path), err); exit_code.set_if_greater(ExitCode::MinorIssue); continue; } @@ -225,7 +225,7 @@ impl Meta { let metadata = match metadata { Ok(meta) => meta, Err(err) => { - print_error!("{}: {}.", path.display(), err); + print_error!("{}: {}.", crate::display_util::SafePath(path), err); return 0; } }; @@ -238,7 +238,7 @@ impl Meta { let entries = match path.read_dir() { Ok(entries) => entries, Err(err) => { - print_error!("{}: {}.", path.display(), err); + print_error!("{}: {}.", crate::display_util::SafePath(path), err); return size; } }; @@ -246,7 +246,7 @@ impl Meta { let path = match entry { Ok(entry) => entry.path(), Err(err) => { - print_error!("{}: {}.", path.display(), err); + print_error!("{}: {}.", crate::display_util::SafePath(path), err); continue; } }; @@ -280,7 +280,7 @@ impl Meta { // path.symlink_metadata would have errored out if dereference { broken_link = true; - eprintln!("lsd: {}: {}", path.to_str().unwrap_or(""), e); + eprintln!("lsd: {}: {}", crate::display_util::SafePath(path), e); } } } @@ -314,7 +314,7 @@ impl Meta { Err(e) => { eprintln!( "lsd: {}: {}(Hint: Consider using `--permission disable`.)", - path.to_str().unwrap_or(""), + crate::display_util::SafePath(path), e ); (None, None) diff --git a/src/meta/name.rs b/src/meta/name.rs index 788c8907e..36a65538e 100644 --- a/src/meta/name.rs +++ b/src/meta/name.rs @@ -90,25 +90,7 @@ impl Name { name = format!("\'{}\'", &name); } } - let string = name; - if string - .chars() - .all(|c| c >= 0x20 as char && c != 0x7f as char) - { - string - } else { - let mut chars = String::new(); - for c in string.chars() { - // The `escape_default` method on `char` is *almost* what we want here, but - // it still escapes non-ASCII UTF-8 characters, which are still printable. - if c >= 0x20 as char && c != 0x7f as char { - chars.push(c); - } else { - chars += &c.escape_default().collect::(); - } - } - chars - } + crate::display_util::sanitize_for_terminal(&name).into_owned() } fn hyperlink(&self, name: String, hyperlink: HyperlinkOption) -> String { diff --git a/src/meta/symlink.rs b/src/meta/symlink.rs index 786b49202..ffab0e582 100644 --- a/src/meta/symlink.rs +++ b/src/meta/symlink.rs @@ -15,22 +15,12 @@ impl From<&Path> for SymLink { if target.is_absolute() || path.parent().is_none() { return Self { valid: target.exists(), - target: Some( - target - .to_str() - .expect("failed to convert symlink to str") - .to_string(), - ), + target: Some(target.to_string_lossy().into_owned()), }; } return Self { - target: Some( - target - .to_str() - .expect("failed to convert symlink to str") - .to_string(), - ), + target: Some(target.to_string_lossy().into_owned()), valid: path.parent().unwrap().join(target).exists(), }; } @@ -57,7 +47,10 @@ impl SymLink { let strings: &[ColoredString] = &[ ColoredString::new(Colors::default_style(), format!(" {} ", flag.symlink_arrow)), // ⇒ \u{21d2} - colors.colorize(target_string, elem), + colors.colorize( + crate::display_util::sanitize_for_terminal(&target_string).into_owned(), + elem, + ), ]; let res = strings