use { super::*, crate::{ app::{ AppContext, Selection, SelectionType, }, errors::TreeBuildError, file_sum::FileSum, git::LineGitStatus, }, lazy_regex::regex_captures, std::{ fs, path::{ Path, PathBuf, }, }, }; #[cfg(unix)] use { std::os::unix::fs::MetadataExt, umask::Mode, }; #[cfg(windows)] use is_executable::IsExecutable; pub type TreeLineId = usize; /// Sanitize a path-derived string (a file/directory name or a symlink /// target) before it's written to the terminal: newlines become the /// `␤` placeholder and any other control character (including the ESC /// byte of a terminal escape sequence) becomes the replacement /// character. /// /// Such strings are controlled by whoever created the file, so without /// this a name or link target containing an escape sequence would be /// interpreted by the terminal of anyone browsing the directory. pub fn sanitize_display_name(s: impl Into) -> String { let s = s.into(); if s.chars().any(char::is_control) { s.chars() .map(|c| match c { '\n' => '␤', c if c.is_control() => '\u{FFFD}', c => c, }) .collect() } else { s } } /// a line in the representation of the file hierarchy #[derive(Debug, Clone)] pub struct TreeLine { pub id: TreeLineId, pub parent_id: Option, pub left_branches: Box<[bool]>, // a depth-sized array telling whether a branch pass pub depth: u16, pub path: PathBuf, pub subpath: String, pub icon: Option, pub name: String, // a displayable name - some chars may have been stripped pub line_type: TreeLineType, pub has_error: bool, pub nb_kept_children: usize, pub unlisted: usize, // number of not listed children (Dir) or brothers (Pruning) pub score: i32, // 0 if there's no pattern pub direct_match: bool, pub sum: Option, // None when not measured pub metadata: fs::Metadata, pub git_status: Option, } pub struct TreeLineBuilder { pub path: PathBuf, pub subpath: String, pub id: TreeLineId, pub parent_id: Option, pub depth: u16, pub unlisted: usize, pub nb_kept_children: usize, pub has_error: bool, pub score: i32, pub direct_match: bool, } impl TreeLineBuilder { pub fn build( self, con: &AppContext, ) -> Result { let Self { path, subpath, id, parent_id, depth, unlisted, nb_kept_children, has_error, score, direct_match, } = self; let metadata = fs::symlink_metadata(&path).map_err(|_| TreeBuildError::FileNotFound { path: path.to_string_lossy().to_string(), })?; let line_type = TreeLineType::new(&path, metadata.file_type()); let name = path .file_name() .map(|os_str| sanitize_display_name(os_str.to_string_lossy())) .unwrap_or_else(String::new); let icon = con.icons.as_ref().map(|icon_plugin| { let extension = TreeLine::extension_from_name(&name); let double_extension = extension.and_then(|_| TreeLine::double_extension_from_name(&name)); icon_plugin.get_icon(&line_type, &name, double_extension, extension) }); Ok(TreeLine { id, parent_id, left_branches: vec![false; depth as usize].into_boxed_slice(), depth, icon, name, subpath, path, line_type, has_error, nb_kept_children, unlisted, score, direct_match, sum: None, metadata, git_status: None, }) } } impl TreeLine { pub fn double_extension_from_name(name: &str) -> Option<&str> { regex_captures!(r"\.([^.]+\.[^.]+)", name).map(|(_, de)| de) } pub fn extension_from_name(name: &str) -> Option<&str> { regex_captures!(r"\.([^.]+)$", name).map(|(_, ext)| ext) } pub fn is_selectable(&self) -> bool { !matches!(&self.line_type, TreeLineType::Pruning) } pub fn is_dir(&self) -> bool { match &self.line_type { TreeLineType::Dir => true, TreeLineType::SymLink { final_is_dir, .. } if *final_is_dir => true, _ => false, } } pub fn is_file(&self) -> bool { matches!(&self.line_type, TreeLineType::File) } pub fn is_of( &self, selection_type: SelectionType, ) -> bool { match selection_type { SelectionType::Any => true, SelectionType::File => self.is_file(), SelectionType::Directory => self.is_dir(), } } pub fn extension(&self) -> Option<&str> { Self::extension_from_name(&self.name) } pub fn selection_type(&self) -> SelectionType { use TreeLineType::*; match &self.line_type { File => SelectionType::File, Dir | BrokenSymLink(_) => SelectionType::Directory, SymLink { final_is_dir, .. } => { if *final_is_dir { SelectionType::Directory } else { SelectionType::File } } Pruning => SelectionType::Any, // should not happen today } } pub fn as_selection(&self) -> Selection<'_> { Selection { path: &self.path, stype: self.selection_type(), is_exe: self.is_exe(), line: 0, } } #[cfg(unix)] pub fn mode(&self) -> Mode { Mode::from(self.metadata.mode()) } /// Return the unix device id /// /// (the equivalent for windows isn't unfailliblely implementable today) #[cfg(any(target_os = "linux", target_os = "macos"))] pub fn device_id(&self) -> lfs_core::DeviceId { self.metadata.dev().into() } #[cfg(target_os = "windows")] pub fn device_id(&self) -> Option { lfs_core::DeviceId::of_path(&self.path).ok() } #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] pub fn mount(&self) -> Option { use crate::filesystems::*; let mut mount_list = MOUNTS.lock().unwrap(); if mount_list.load().is_ok() { let device_id = lfs_core::DeviceId::of_path(&self.path).ok()?; mount_list .get_by_device_id(device_id) .cloned() } else { None } } pub fn is_exe(&self) -> bool { #[cfg(unix)] return self.mode().is_exe(); #[cfg(windows)] return self.path.is_executable(); } /// build and return the absolute targeted path: either self.path or the /// solved canonicalized symlink pub fn target(&self) -> &Path { match &self.line_type { TreeLineType::SymLink { final_target, .. } => final_target, _ => &self.path, } } pub fn unprune(&mut self) { self.line_type = TreeLineType::new(&self.path, self.metadata.file_type()); self.name = self.path.file_name().map_or_else( || "???".to_string(), |n| sanitize_display_name(n.to_string_lossy()), ); } } #[cfg(test)] mod sanitize_display_name_tests { use super::*; #[test] fn strips_escape_sequences() { // A crafted file name embedding a raw OSC 52 (clipboard-write) // escape sequence, the same shape a malicious local file could // use to inject terminal control sequences into another user's // broot session. The ESC and BEL bytes must not survive // sanitization. let malicious = "\x1b]52;c;cGF5bG9hZA==\x07innocuous_file.txt"; let sanitized = sanitize_display_name(malicious); assert!(!sanitized.contains('\u{1b}')); assert!(!sanitized.contains('\u{7}')); assert!(sanitized.contains("innocuous_file.txt")); } #[test] fn preserves_normal_names() { let normal = "some-normal_file.name (1).txt"; assert_eq!(sanitize_display_name(normal), normal); } }