diff --git a/src/uu/ls/locales/en-US.ftl b/src/uu/ls/locales/en-US.ftl index d5fc32b4f27..004243c5ed6 100644 --- a/src/uu/ls/locales/en-US.ftl +++ b/src/uu/ls/locales/en-US.ftl @@ -123,6 +123,8 @@ ls-invalid-quoting-style = {$program}: Ignoring invalid value of environment var ls-invalid-columns-width = ignoring invalid width in environment variable COLUMNS: {$width} ls-invalid-ignore-pattern = Invalid pattern for ignore: {$pattern} ls-invalid-hide-pattern = Invalid pattern for hide: {$pattern} +ls-warning-unrecognized-ls-colors-prefix = unrecognized prefix: {$prefix} +ls-warning-unparsable-ls-colors = unparsable value for LS_COLORS environment variable ls-total = total {$size} # Security context warnings diff --git a/src/uu/ls/locales/fr-FR.ftl b/src/uu/ls/locales/fr-FR.ftl index 552e4095fbb..0ae8b06c995 100644 --- a/src/uu/ls/locales/fr-FR.ftl +++ b/src/uu/ls/locales/fr-FR.ftl @@ -123,4 +123,6 @@ ls-invalid-quoting-style = {$program} : Ignorer la valeur invalide de la variabl ls-invalid-columns-width = ignorer la largeur invalide dans la variable d'environnement COLUMNS : {$width} ls-invalid-ignore-pattern = Motif invalide pour ignore : {$pattern} ls-invalid-hide-pattern = Motif invalide pour hide : {$pattern} +ls-warning-unrecognized-ls-colors-prefix = préfixe non reconnu : {$prefix} +ls-warning-unparsable-ls-colors = valeur illisible pour la variable d'environnement LS_COLORS ls-total = total {$size} diff --git a/src/uu/ls/src/colors.rs b/src/uu/ls/src/colors.rs index a7f58d0fd58..8eb4b70970b 100644 --- a/src/uu/ls/src/colors.rs +++ b/src/uu/ls/src/colors.rs @@ -3,9 +3,25 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. use super::PathData; -use lscolors::{Colorable, Indicator, LsColors, Style}; +use lscolors::{Indicator, LsColors, Style}; +use std::collections::HashMap; +use std::env; use std::ffi::OsString; -use std::fs::Metadata; +use std::fs::{self, Metadata}; +#[cfg(unix)] +use std::os::unix::fs::{FileTypeExt, MetadataExt}; + +/// ANSI CSI (Control Sequence Introducer) +const ANSI_CSI: &str = "\x1b["; +const ANSI_SGR_END: &str = "m"; +const ANSI_RESET: &str = "\x1b[0m"; +const ANSI_CLEAR_EOL: &str = "\x1b[K"; +const EMPTY_STYLE: &str = "\x1b[m"; + +enum RawIndicatorStyle { + Empty, + Code(Indicator), +} /// We need this struct to be able to store the previous style. /// This because we need to check the previous value in case we don't need @@ -16,33 +32,130 @@ pub(crate) struct StyleManager<'a> { /// `true` if the initial reset is applied pub(crate) initial_reset_is_done: bool, pub(crate) colors: &'a LsColors, + /// raw indicator codes as specified in LS_COLORS (if available) + indicator_codes: HashMap, + /// whether ln=target is active + ln_color_from_target: bool, } impl<'a> StyleManager<'a> { pub(crate) fn new(colors: &'a LsColors) -> Self { + let (indicator_codes, ln_color_from_target) = parse_indicator_codes(); Self { initial_reset_is_done: false, current_style: None, colors, + indicator_codes, + ln_color_from_target, } } pub(crate) fn apply_style( &mut self, new_style: Option<&Style>, + path: Option<&PathData>, name: OsString, wrap: bool, ) -> OsString { let mut style_code = String::new(); let mut force_suffix_reset: bool = false; + let mut applied_raw_code = false; - // if reset is done we need to apply normal style before applying new style if self.is_reset() { if let Some(norm_sty) = self.get_normal_style().copied() { style_code.push_str(&self.get_style_code(&norm_sty)); } } + if let Some(path) = path { + // Fast-path: apply LS_COLORS raw SGR codes verbatim, + // bypassing LsColors fallbacks so the entry from LS_COLORS + // is honored exactly as specified. + match self.raw_indicator_style_for_path(path) { + Some(RawIndicatorStyle::Empty) => { + // An explicit empty entry (e.g. "or=") disables coloring and + // bypasses fallbacks, matching GNU ls behavior. + return self.apply_empty_style(name, wrap); + } + Some(RawIndicatorStyle::Code(indicator)) => { + self.append_raw_style_code_for_indicator(indicator, &mut style_code); + applied_raw_code = true; + self.current_style = None; + force_suffix_reset = true; + } + None => {} + } + } + + if !applied_raw_code { + self.append_style_code_for_style(new_style, &mut style_code, &mut force_suffix_reset); + } + + // we need this clear to eol code in some terminals, for instance if the + // text is in the last row of the terminal meaning the terminal need to + // scroll up in order to print new text in this situation if the clear + // to eol code is not present the background of the text would stretch + // till the end of line + let clear_to_eol = if wrap { ANSI_CLEAR_EOL } else { "" }; + + let mut ret: OsString = style_code.into(); + ret.push(name); + ret.push(self.reset(force_suffix_reset)); + ret.push(clear_to_eol); + ret + } + + fn raw_indicator_style_for_path(&self, path: &PathData) -> Option { + let indicator = self.indicator_for_raw_code(path)?; + let should_skip = indicator == Indicator::SymbolicLink + && self.ln_color_from_target + && path.path().exists(); + + if should_skip { + return None; + } + + let raw = self.indicator_codes.get(&indicator)?; + if raw.is_empty() { + Some(RawIndicatorStyle::Empty) + } else { + Some(RawIndicatorStyle::Code(indicator)) + } + } + + // Append a raw SGR sequence for a validated LS_COLORS indicator. + fn append_raw_style_code_for_indicator( + &mut self, + indicator: Indicator, + style_code: &mut String, + ) { + if !self.indicator_codes.contains_key(&indicator) { + return; + } + style_code.push_str(self.reset(!self.initial_reset_is_done)); + style_code.push_str(ANSI_CSI); + if let Some(raw) = self.indicator_codes.get(&indicator) { + debug_assert!(!raw.is_empty()); + style_code.push_str(raw); + style_code.push_str(ANSI_SGR_END); + } + } + + fn build_raw_style_code(&mut self, raw: &str) -> String { + let mut style_code = String::new(); + style_code.push_str(self.reset(!self.initial_reset_is_done)); + style_code.push_str(ANSI_CSI); + style_code.push_str(raw); + style_code.push_str(ANSI_SGR_END); + style_code + } + + fn append_style_code_for_style( + &mut self, + new_style: Option<&Style>, + style_code: &mut String, + force_suffix_reset: &mut bool, + ) { if let Some(new_style) = new_style { // we only need to apply a new style if it's not the same as the current // style for example if normal is the current style and a file with @@ -58,21 +171,8 @@ impl<'a> StyleManager<'a> { { style_code.push_str(self.reset(false)); // even though this is an unnecessary reset for gnu compatibility we allow it here - force_suffix_reset = true; + *force_suffix_reset = true; } - - // we need this clear to eol code in some terminals, for instance if the - // text is in the last row of the terminal meaning the terminal need to - // scroll up in order to print new text in this situation if the clear - // to eol code is not present the background of the text would stretch - // till the end of line - let clear_to_eol = if wrap { "\x1b[K" } else { "" }; - - let mut ret: OsString = style_code.into(); - ret.push(name); - ret.push(self.reset(force_suffix_reset)); - ret.push(clear_to_eol); - ret } /// Resets the current style and returns the default ANSI reset code to @@ -87,7 +187,7 @@ impl<'a> StyleManager<'a> { if self.current_style.is_some() || force { self.initial_reset_is_done = true; self.current_style = None; - return "\x1b[0m"; + return ANSI_RESET; } "" } @@ -130,17 +230,239 @@ impl<'a> StyleManager<'a> { let style = self .colors .style_for_path_with_metadata(&path.p_buf, md_option); - self.apply_style(style, name, wrap) + self.apply_style(style, Some(path), name, wrap) } - pub(crate) fn apply_style_based_on_colorable( + pub(crate) fn apply_style_for_path( &mut self, - path: &T, + path: &PathData, name: OsString, wrap: bool, ) -> OsString { let style = self.colors.style_for(path); - self.apply_style(style, name, wrap) + self.apply_style(style, Some(path), name, wrap) + } + + pub(crate) fn apply_indicator_style( + &mut self, + indicator: Indicator, + name: OsString, + wrap: bool, + ) -> OsString { + if let Some(raw) = self.indicator_codes.get(&indicator).cloned() { + if raw.is_empty() { + return self.apply_empty_style(name, wrap); + } + + let mut ret: OsString = self.build_raw_style_code(&raw).into(); + ret.push(name); + ret.push(self.reset(true)); + if wrap { + ret.push(ANSI_CLEAR_EOL); + } + ret + } else { + let style = self.colors.style_for_indicator(indicator); + self.apply_style(style, None, name, wrap) + } + } + + pub(crate) fn has_indicator_style(&self, indicator: Indicator) -> bool { + self.indicator_codes.contains_key(&indicator) + || self.colors.has_explicit_style_for(indicator) + } + + pub(crate) fn apply_orphan_link_style(&mut self, name: OsString, wrap: bool) -> OsString { + if self.has_indicator_style(Indicator::OrphanedSymbolicLink) { + self.apply_indicator_style(Indicator::OrphanedSymbolicLink, name, wrap) + } else { + self.apply_indicator_style(Indicator::MissingFile, name, wrap) + } + } + + pub(crate) fn apply_missing_target_style(&mut self, name: OsString, wrap: bool) -> OsString { + if self.has_indicator_style(Indicator::MissingFile) { + self.apply_indicator_style(Indicator::MissingFile, name, wrap) + } else { + self.apply_indicator_style(Indicator::OrphanedSymbolicLink, name, wrap) + } + } + + fn apply_empty_style(&mut self, name: OsString, wrap: bool) -> OsString { + let mut style_code = String::new(); + style_code.push_str(self.reset(!self.initial_reset_is_done)); + style_code.push_str(EMPTY_STYLE); + + let mut ret: OsString = style_code.into(); + ret.push(name); + ret.push(self.reset(true)); + if wrap { + ret.push(ANSI_CLEAR_EOL); + } + ret + } + + fn color_symlink_name( + &mut self, + path: &PathData, + name: OsString, + wrap: bool, + ) -> Option { + if !self.ln_color_from_target { + return None; + } + if path.must_dereference && path.metadata().is_none() { + return None; + } + let mut target = path.path().read_link().ok()?; + if target.is_relative() { + if let Some(parent) = path.path().parent() { + target = parent.join(target); + } + } + + match fs::metadata(&target) { + Ok(metadata) => { + let style = self + .colors + .style_for_path_with_metadata(&target, Some(&metadata)); + Some(self.apply_style(style, None, name, wrap)) + } + Err(_) => { + if self.has_indicator_style(Indicator::OrphanedSymbolicLink) { + Some(self.apply_orphan_link_style(name, wrap)) + } else { + None + } + } + } + } + + fn indicator_for_raw_code(&self, path: &PathData) -> Option { + if self.indicator_codes.is_empty() { + return None; + } + + let mut existence_cache: Option = None; + let mut entry_exists = + || -> bool { *existence_cache.get_or_insert_with(|| path.path().exists()) }; + + let Some(file_type) = path.file_type() else { + if self.has_indicator_style(Indicator::MissingFile) && !entry_exists() { + return Some(Indicator::MissingFile); + } + return None; + }; + + if file_type.is_symlink() { + let orphan_enabled = self.has_indicator_style(Indicator::OrphanedSymbolicLink); + let missing_enabled = self.has_indicator_style(Indicator::MissingFile); + let needs_target_state = self.ln_color_from_target || orphan_enabled; + let target_missing = needs_target_state && !entry_exists(); + + if target_missing { + let orphan_raw = self.indicator_codes.get(&Indicator::OrphanedSymbolicLink); + let orphan_raw_is_empty = orphan_raw.is_some_and(|value| value.is_empty()); + if orphan_enabled && (!orphan_raw_is_empty || self.ln_color_from_target) { + return Some(Indicator::OrphanedSymbolicLink); + } + if self.ln_color_from_target && missing_enabled { + return Some(Indicator::MissingFile); + } + } + if self.has_indicator_style(Indicator::SymbolicLink) { + return Some(Indicator::SymbolicLink); + } + return None; + } + + if self.has_indicator_style(Indicator::MissingFile) && !entry_exists() { + return Some(Indicator::MissingFile); + } + + if file_type.is_file() { + #[cfg(unix)] + if self.needs_file_metadata() { + if let Some(metadata) = path.metadata() { + let mode = metadata.mode(); + if self.has_indicator_style(Indicator::Setuid) && mode & 0o4000 != 0 { + return Some(Indicator::Setuid); + } + if self.has_indicator_style(Indicator::Setgid) && mode & 0o2000 != 0 { + return Some(Indicator::Setgid); + } + if self.has_indicator_style(Indicator::ExecutableFile) && mode & 0o0111 != 0 { + return Some(Indicator::ExecutableFile); + } + if self.has_indicator_style(Indicator::MultipleHardLinks) + && metadata.nlink() > 1 + { + return Some(Indicator::MultipleHardLinks); + } + } + } + + if self.has_indicator_style(Indicator::RegularFile) { + return Some(Indicator::RegularFile); + } + } else if file_type.is_dir() { + #[cfg(unix)] + if self.needs_dir_metadata() { + if let Some(metadata) = path.metadata() { + let mode = metadata.mode(); + if self.has_indicator_style(Indicator::StickyAndOtherWritable) + && mode & 0o1002 == 0o1002 + { + return Some(Indicator::StickyAndOtherWritable); + } + if self.has_indicator_style(Indicator::OtherWritable) && mode & 0o0002 != 0 { + return Some(Indicator::OtherWritable); + } + if self.has_indicator_style(Indicator::Sticky) && mode & 0o1000 != 0 { + return Some(Indicator::Sticky); + } + } + } + + if self.has_indicator_style(Indicator::Directory) { + return Some(Indicator::Directory); + } + } else { + #[cfg(unix)] + { + if file_type.is_fifo() && self.has_indicator_style(Indicator::FIFO) { + return Some(Indicator::FIFO); + } + if file_type.is_socket() && self.has_indicator_style(Indicator::Socket) { + return Some(Indicator::Socket); + } + if file_type.is_block_device() && self.has_indicator_style(Indicator::BlockDevice) { + return Some(Indicator::BlockDevice); + } + if file_type.is_char_device() + && self.has_indicator_style(Indicator::CharacterDevice) + { + return Some(Indicator::CharacterDevice); + } + } + } + + None + } + + #[cfg(unix)] + fn needs_file_metadata(&self) -> bool { + self.has_indicator_style(Indicator::Setuid) + || self.has_indicator_style(Indicator::Setgid) + || self.has_indicator_style(Indicator::ExecutableFile) + || self.has_indicator_style(Indicator::MultipleHardLinks) + } + + #[cfg(unix)] + fn needs_dir_metadata(&self) -> bool { + self.has_indicator_style(Indicator::StickyAndOtherWritable) + || self.has_indicator_style(Indicator::OtherWritable) + || self.has_indicator_style(Indicator::Sticky) } } @@ -168,27 +490,313 @@ pub(crate) fn color_name( // If the file has capabilities, use a specific style for `ca` (capabilities) if has_capabilities { - return style_manager.apply_style(capabilities, name, wrap); + return style_manager.apply_style(capabilities, Some(path), name, wrap); } } - if !path.must_dereference { - // If we need to dereference (follow) a symlink, we will need to get the metadata - // There is a DirEntry, we don't need to get the metadata for the color - return style_manager.apply_style_based_on_colorable(path, name, wrap); + if target_symlink.is_none() && path.file_type().is_some_and(|ft| ft.is_symlink()) { + if let Some(colored) = style_manager.color_symlink_name(path, name.clone(), wrap) { + return colored; + } } if let Some(target) = target_symlink { // use the optional target_symlink // Use fn symlink_metadata directly instead of get_metadata() here because ls // should not exit with an err, if we are unable to obtain the target_metadata - style_manager.apply_style_based_on_colorable(target, name, wrap) + return style_manager.apply_style_for_path(target, name, wrap); + } + + if !path.must_dereference { + // If we need to dereference (follow) a symlink, we will need to get the metadata + // There is a DirEntry, we don't need to get the metadata for the color + return style_manager.apply_style_for_path(path, name, wrap); + } + + let md_option: Option = path + .metadata() + .cloned() + .or_else(|| path.p_buf.symlink_metadata().ok()); + + style_manager.apply_style_based_on_metadata(path, md_option.as_ref(), name, wrap) +} + +#[derive(Debug)] +pub(crate) enum LsColorsParseError { + UnrecognizedPrefix(String), + InvalidSyntax, +} + +pub(crate) fn validate_ls_colors_env() -> Result<(), LsColorsParseError> { + let Ok(ls_colors) = env::var("LS_COLORS") else { + return Ok(()); + }; + + if ls_colors.is_empty() { + return Ok(()); + } + + validate_ls_colors(&ls_colors) +} + +// GNU-like parser: ensure LS_COLORS has valid labels and well-formed escapes. +fn validate_ls_colors(ls_colors: &str) -> Result<(), LsColorsParseError> { + let bytes = ls_colors.as_bytes(); + let mut idx = 0; + + while idx < bytes.len() { + match bytes[idx] { + b':' => { + idx += 1; + } + b'*' => { + idx += 1; + idx = parse_funky_string(bytes, idx, true)?; + if idx >= bytes.len() || bytes[idx] != b'=' { + return Err(LsColorsParseError::InvalidSyntax); + } + idx += 1; + idx = parse_funky_string(bytes, idx, false)?; + if idx < bytes.len() && bytes[idx] == b':' { + idx += 1; + } + } + _ => { + if idx + 1 >= bytes.len() { + return Err(LsColorsParseError::InvalidSyntax); + } + let label = [bytes[idx], bytes[idx + 1]]; + idx += 2; + if idx >= bytes.len() || bytes[idx] != b'=' { + return Err(LsColorsParseError::InvalidSyntax); + } + if !is_valid_ls_colors_prefix(label) { + let prefix = String::from_utf8_lossy(&label).into_owned(); + return Err(LsColorsParseError::UnrecognizedPrefix(prefix)); + } + idx += 1; + idx = parse_funky_string(bytes, idx, false)?; + if idx < bytes.len() && bytes[idx] == b':' { + idx += 1; + } + } + } + } + + Ok(()) +} + +// Parse a value with GNU-compatible escape sequences, returning the index of the terminator. +fn parse_funky_string( + bytes: &[u8], + mut idx: usize, + equals_end: bool, +) -> Result { + enum State { + Ground, + Backslash, + Octal(u8), + Hex(u8), + Caret, + } + + let mut state = State::Ground; + loop { + let byte = if idx < bytes.len() { bytes[idx] } else { 0 }; + match state { + State::Ground => match byte { + b':' | 0 => return Ok(idx), + b'=' if equals_end => return Ok(idx), + b'\\' => { + state = State::Backslash; + idx += 1; + } + b'^' => { + state = State::Caret; + idx += 1; + } + _ => idx += 1, + }, + State::Backslash => match byte { + 0 => return Err(LsColorsParseError::InvalidSyntax), + b'0'..=b'7' => { + state = State::Octal(byte - b'0'); + idx += 1; + } + b'x' | b'X' => { + state = State::Hex(0); + idx += 1; + } + b'a' | b'b' | b'e' | b'f' | b'n' | b'r' | b't' | b'v' | b'?' | b'_' => { + state = State::Ground; + idx += 1; + } + _ => { + state = State::Ground; + idx += 1; + } + }, + State::Octal(num) => match byte { + b'0'..=b'7' => { + state = State::Octal(num.wrapping_mul(8).wrapping_add(byte - b'0')); + idx += 1; + } + _ => state = State::Ground, + }, + State::Hex(num) => match byte { + b'0'..=b'9' => { + state = State::Hex(num.wrapping_mul(16).wrapping_add(byte - b'0')); + idx += 1; + } + b'a'..=b'f' => { + state = State::Hex(num.wrapping_mul(16).wrapping_add(byte - b'a' + 10)); + idx += 1; + } + b'A'..=b'F' => { + state = State::Hex(num.wrapping_mul(16).wrapping_add(byte - b'A' + 10)); + idx += 1; + } + _ => state = State::Ground, + }, + State::Caret => match byte { + b'@'..=b'~' | b'?' => { + state = State::Ground; + idx += 1; + } + _ => return Err(LsColorsParseError::InvalidSyntax), + }, + } + } +} + +fn is_valid_ls_colors_prefix(label: [u8; 2]) -> bool { + matches!( + label, + [b'l', b'c'] + | [b'r', b'c'] + | [b'e', b'c'] + | [b'r', b's'] + | [b'n', b'o'] + | [b'f', b'i'] + | [b'd', b'i'] + | [b'l', b'n'] + | [b'p', b'i'] + | [b's', b'o'] + | [b'b', b'd'] + | [b'c', b'd'] + | [b'm', b'i'] + | [b'o', b'r'] + | [b'e', b'x'] + | [b'd', b'o'] + | [b's', b'u'] + | [b's', b'g'] + | [b's', b't'] + | [b'o', b'w'] + | [b't', b'w'] + | [b'c', b'a'] + | [b'm', b'h'] + | [b'c', b'l'] + ) +} + +fn parse_indicator_codes() -> (HashMap, bool) { + let mut indicator_codes = HashMap::new(); + let mut ln_color_from_target = false; + + // LS_COLORS validity is checked before enabling color output, so parse + // entries directly here for raw indicator overrides. + if let Ok(ls_colors) = env::var("LS_COLORS") { + for entry in ls_colors.split(':') { + if entry.is_empty() { + continue; + } + let Some((key, value)) = entry.split_once('=') else { + continue; + }; + + if let Some(indicator) = Indicator::from(key) { + if indicator == Indicator::SymbolicLink && value == "target" { + ln_color_from_target = true; + continue; + } + if indicator_value_is_disabled(indicator, value) { + if value.is_empty() + && matches!( + indicator, + Indicator::OrphanedSymbolicLink | Indicator::MissingFile + ) + { + indicator_codes.insert(indicator, String::new()); + } + continue; + } + indicator_codes.insert(indicator, canonicalize_indicator_value(value)); + } + } + } + + (indicator_codes, ln_color_from_target) +} + +fn canonicalize_indicator_value(value: &str) -> String { + if value.len() == 1 && value.chars().all(|c| c.is_ascii_digit()) { + let mut canonical = String::with_capacity(2); + canonical.push('0'); + canonical.push_str(value); + canonical } else { - let md_option: Option = path - .metadata() - .cloned() - .or_else(|| path.p_buf.symlink_metadata().ok()); + value.to_string() + } +} + +fn indicator_value_is_disabled(indicator: Indicator, value: &str) -> bool { + if value.is_empty() { + !matches!( + indicator, + Indicator::OrphanedSymbolicLink | Indicator::MissingFile + ) + } else { + value.chars().all(|c| c == '0') + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn style_manager( + colors: &LsColors, + indicator_codes: HashMap, + ) -> StyleManager<'_> { + StyleManager { + current_style: None, + initial_reset_is_done: false, + colors, + indicator_codes, + ln_color_from_target: false, + } + } + + #[test] + fn has_indicator_style_ignores_fallback_styles() { + let colors = LsColors::from_string("ex=00:fi=32"); + let manager = style_manager(&colors, HashMap::new()); + assert!(!manager.has_indicator_style(Indicator::ExecutableFile)); + } + + #[test] + fn has_indicator_style_detects_explicit_styles() { + let colors = LsColors::from_string("ex=01;32"); + let manager = style_manager(&colors, HashMap::new()); + assert!(manager.has_indicator_style(Indicator::ExecutableFile)); + } - style_manager.apply_style_based_on_metadata(path, md_option.as_ref(), name, wrap) + #[test] + fn has_indicator_style_detects_raw_codes() { + let colors = LsColors::empty(); + let mut indicator_codes = HashMap::new(); + indicator_codes.insert(Indicator::Directory, "01;34".to_string()); + let manager = style_manager(&colors, indicator_codes); + assert!(manager.has_indicator_style(Indicator::Directory)); } } diff --git a/src/uu/ls/src/ls.rs b/src/uu/ls/src/ls.rs index 3a7e8014e18..84016a1af1f 100644 --- a/src/uu/ls/src/ls.rs +++ b/src/uu/ls/src/ls.rs @@ -3,7 +3,8 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (ToDO) somegroup nlink tabsize dired subdired dtype colorterm stringly nohash strtime +// spell-checker:ignore (ToDO) somegroup nlink tabsize dired subdired dtype colorterm stringly +// spell-checker:ignore nohash strtime clocale #[cfg(unix)] use fnv::FnvHashMap as HashMap; @@ -18,7 +19,7 @@ use std::{ cell::{LazyCell, OnceCell}, cmp::Reverse, ffi::{OsStr, OsString}, - fmt::Write as FmtWrite, + fmt::Write as _, fs::{self, DirEntry, FileType, Metadata, ReadDir}, io::{BufWriter, ErrorKind, IsTerminal, Stdout, Write, stdout}, iter, @@ -81,7 +82,7 @@ mod dired; use dired::{DiredOutput, is_dired_arg_present}; mod colors; use crate::options::QUOTING_STYLE; -use colors::{StyleManager, color_name}; +use colors::{LsColorsParseError, StyleManager, color_name, validate_ls_colors_env}; pub mod options { pub mod format { @@ -338,6 +339,12 @@ enum IndicatorStyle { Classify, } +#[derive(Clone, Copy, PartialEq, Eq)] +enum LocaleQuoting { + Single, + Double, +} + pub struct Config { // Dir and vdir needs access to this field pub format: Format, @@ -361,6 +368,7 @@ pub struct Config { width: u16, // Dir and vdir needs access to this field pub quoting_style: QuotingStyle, + locale_quoting: Option, indicator_style: IndicatorStyle, time_format_recent: String, // Time format for recent dates time_format_older: Option, // Time format for older dates (optional, if not present, time_format_recent is used) @@ -655,18 +663,62 @@ fn extract_hyperlink(options: &clap::ArgMatches) -> bool { /// # Returns /// /// * An option with None if the style string is invalid, or a `QuotingStyle` wrapped in `Some`. -fn match_quoting_style_name(style: &str, show_control: bool) -> Option { - match style { - "literal" => Some(QuotingStyle::Literal { show_control }), - "shell" => Some(QuotingStyle::SHELL), - "shell-always" => Some(QuotingStyle::SHELL_QUOTE), - "shell-escape" => Some(QuotingStyle::SHELL_ESCAPE), - "shell-escape-always" => Some(QuotingStyle::SHELL_ESCAPE_QUOTE), - "c" => Some(QuotingStyle::C_DOUBLE), - "escape" => Some(QuotingStyle::C_NO_QUOTES), - _ => None, +struct QuotingStyleSpec { + style: QuotingStyle, + fixed_control: bool, + locale: Option, +} + +impl QuotingStyleSpec { + fn new(style: QuotingStyle) -> Self { + Self { + style, + fixed_control: false, + locale: None, + } + } + + fn with_locale(style: QuotingStyle, locale: LocaleQuoting) -> Self { + Self { + style, + fixed_control: true, + locale: Some(locale), + } } - .map(|qs| qs.show_control(show_control)) +} + +fn match_quoting_style_name( + style: &str, + show_control: bool, +) -> Option<(QuotingStyle, Option)> { + let spec = match style { + "literal" => QuotingStyleSpec::new(QuotingStyle::Literal { + show_control: false, + }), + "shell" => QuotingStyleSpec::new(QuotingStyle::SHELL), + "shell-always" => QuotingStyleSpec::new(QuotingStyle::SHELL_QUOTE), + "shell-escape" => QuotingStyleSpec::new(QuotingStyle::SHELL_ESCAPE), + "shell-escape-always" => QuotingStyleSpec::new(QuotingStyle::SHELL_ESCAPE_QUOTE), + "c" => QuotingStyleSpec::new(QuotingStyle::C_DOUBLE), + "escape" => QuotingStyleSpec::new(QuotingStyle::C_NO_QUOTES), + "locale" => QuotingStyleSpec { + style: QuotingStyle::Literal { + show_control: false, + }, + fixed_control: true, + locale: Some(LocaleQuoting::Single), + }, + "clocale" => QuotingStyleSpec::with_locale(QuotingStyle::C_DOUBLE, LocaleQuoting::Double), + _ => return None, + }; + + let style = if spec.fixed_control { + spec.style + } else { + spec.style.show_control(show_control) + }; + + Some((style, spec.locale)) } /// Extracts the quoting style to use based on the options provided. @@ -681,27 +733,30 @@ fn match_quoting_style_name(style: &str, show_control: bool) -> Option QuotingStyle { +fn extract_quoting_style( + options: &clap::ArgMatches, + show_control: bool, +) -> (QuotingStyle, Option) { let opt_quoting_style = options.get_one::(QUOTING_STYLE); if let Some(style) = opt_quoting_style { match match_quoting_style_name(style, show_control) { - Some(qs) => qs, + Some(pair) => pair, None => unreachable!("Should have been caught by Clap"), } } else if options.get_flag(options::quoting::LITERAL) { - QuotingStyle::Literal { show_control } + (QuotingStyle::Literal { show_control }, None) } else if options.get_flag(options::quoting::ESCAPE) { - QuotingStyle::C_NO_QUOTES + (QuotingStyle::C_NO_QUOTES, None) } else if options.get_flag(options::quoting::C) { - QuotingStyle::C_DOUBLE + (QuotingStyle::C_DOUBLE, None) } else if options.get_flag(options::DIRED) { - QuotingStyle::Literal { show_control } + (QuotingStyle::Literal { show_control }, None) } else { // If set, the QUOTING_STYLE environment variable specifies a default style. if let Ok(style) = std::env::var("QUOTING_STYLE") { match match_quoting_style_name(style.as_str(), show_control) { - Some(qs) => return qs, + Some(pair) => return pair, None => eprintln!( "{}", translate!("ls-invalid-quoting-style", "program" => std::env::args().next().unwrap_or_else(|| "ls".to_string()), "style" => style.clone()) @@ -712,9 +767,9 @@ fn extract_quoting_style(options: &clap::ArgMatches, show_control: bool) -> Quot // By default, `ls` uses Shell escape quoting style when writing to a terminal file // descriptor and Literal otherwise. if stdout().is_terminal() { - QuotingStyle::SHELL_ESCAPE.show_control(show_control) + (QuotingStyle::SHELL_ESCAPE.show_control(show_control), None) } else { - QuotingStyle::Literal { show_control } + (QuotingStyle::Literal { show_control }, None) } } } @@ -970,7 +1025,7 @@ impl Config { !stdout().is_terminal() }; - let mut quoting_style = extract_quoting_style(options, show_control); + let (mut quoting_style, mut locale_quoting) = extract_quoting_style(options, show_control); let indicator_style = extract_indicator_style(options); // Only parse the value to "--time-style" if it will become relevant. let dired = options.get_flag(options::DIRED); @@ -1093,6 +1148,23 @@ impl Config { .unwrap_or(0) { quoting_style = QuotingStyle::Literal { show_control }; + locale_quoting = None; + } + + if needs_color { + if let Err(err) = validate_ls_colors_env() { + if let LsColorsParseError::UnrecognizedPrefix(prefix) = &err { + show_warning!( + "{}", + translate!( + "ls-warning-unrecognized-ls-colors-prefix", + "prefix" => prefix.quote() + ) + ); + } + show_warning!("{}", translate!("ls-warning-unparsable-ls-colors")); + needs_color = false; + } } let color = if needs_color { @@ -1156,6 +1228,7 @@ impl Config { block_size, width, quoting_style, + locale_quoting, indicator_style, time_format_recent, time_format_older, @@ -1358,10 +1431,12 @@ pub fn uu_app() -> Command { .help(translate!("ls-help-set-quoting-style")) .value_parser(ShortcutValueParser::new([ PossibleValue::new("literal"), + PossibleValue::new("locale"), PossibleValue::new("shell"), PossibleValue::new("shell-escape"), PossibleValue::new("shell-always"), PossibleValue::new("shell-escape-always"), + PossibleValue::new("clocale"), PossibleValue::new("c").alias("c-maybe"), PossibleValue::new("escape"), ])) @@ -2034,8 +2109,7 @@ fn show_dir_name( out: &mut BufWriter, config: &Config, ) -> std::io::Result<()> { - let escaped_name = - locale_aware_escape_dir_name(path_data.path().as_os_str(), config.quoting_style); + let escaped_name = escape_dir_name_with_locale(path_data.path().as_os_str(), config); let name = if config.hyperlink && !config.dired { create_hyperlink(&escaped_name, path_data) @@ -2047,6 +2121,70 @@ fn show_dir_name( write!(out, ":") } +fn escape_with_locale(name: &OsStr, config: &Config, fallback: F) -> OsString +where + F: FnOnce(&OsStr, QuotingStyle) -> OsString, +{ + if let Some(locale) = config.locale_quoting { + locale_quote(name, locale) + } else { + fallback(name, config.quoting_style) + } +} + +fn escape_dir_name_with_locale(name: &OsStr, config: &Config) -> OsString { + escape_with_locale(name, config, locale_aware_escape_dir_name) +} + +fn escape_name_with_locale(name: &OsStr, config: &Config) -> OsString { + escape_with_locale(name, config, locale_aware_escape_name) +} + +fn locale_quote(name: &OsStr, style: LocaleQuoting) -> OsString { + let bytes = os_str_as_bytes_lossy(name); + let mut quoted = String::new(); + match style { + LocaleQuoting::Single => quoted.push('\''), + LocaleQuoting::Double => quoted.push('"'), + } + for &byte in bytes.as_ref() { + push_locale_byte(&mut quoted, byte, style); + } + match style { + LocaleQuoting::Single => quoted.push('\''), + LocaleQuoting::Double => quoted.push('"'), + } + OsString::from(quoted) +} + +fn push_locale_byte(buf: &mut String, byte: u8, style: LocaleQuoting) { + match (style, byte) { + (LocaleQuoting::Single, b'\'') => buf.push_str("'\\''"), + (LocaleQuoting::Double, b'"') => buf.push_str("\\\""), + (_, b'\\') => buf.push_str("\\\\"), + _ => push_basic_escape(buf, byte), + } +} + +fn push_basic_escape(buf: &mut String, byte: u8) { + match byte { + b'\x07' => buf.push_str("\\a"), + b'\x08' => buf.push_str("\\b"), + b'\t' => buf.push_str("\\t"), + b'\n' => buf.push_str("\\n"), + b'\x0b' => buf.push_str("\\v"), + b'\x0c' => buf.push_str("\\f"), + b'\r' => buf.push_str("\\r"), + b'\x1b' => buf.push_str("\\e"), + b'"' => buf.push('"'), + b'\'' => buf.push('\''), + b if (0x20..=0x7e).contains(&b) => buf.push(b as char), + _ => { + let _ = write!(buf, "\\{byte:03o}"); + } + } +} + // A struct to encapsulate state that is passed around from `list` functions. struct ListState<'a> { out: BufWriter, @@ -2541,7 +2679,7 @@ fn display_items( // option, print the security context to the left of the size column. let quoted = items.iter().any(|item| { - let name = locale_aware_escape_name(item.display_name(), config.quoting_style); + let name = escape_name_with_locale(item.display_name(), config); os_str_starts_with(&name, b"'") }); @@ -3187,7 +3325,7 @@ fn display_item_name( current_column: LazyCell usize + '_>>, ) -> OsString { // This is our return value. We start by `&path.display_name` and modify it along the way. - let mut name = locale_aware_escape_name(path.display_name(), config.quoting_style); + let mut name = escape_name_with_locale(path.display_name(), config); let is_wrap = |namelen: usize| config.width != 0 && *current_column + namelen > config.width.into(); @@ -3248,6 +3386,7 @@ fn display_item_name( // This makes extra system calls, but provides important information that // people run `ls -l --color` are very interested in. if let Some(style_manager) = &mut state.style_manager { + let escaped_target = escape_name_with_locale(target_path.as_os_str(), config); // We get the absolute path to be able to construct PathData with valid Metadata. // This is because relative symlinks will fail to get_metadata. let mut absolute_target = target_path.clone(); @@ -3257,30 +3396,31 @@ fn display_item_name( } } - let target_data = PathData::new(absolute_target, None, None, config, false); - - // If we have a symlink to a valid file, we use the metadata of said file. - // Because we use an absolute path, we can assume this is guaranteed to exist. - // Otherwise, we use path.md(), which will guarantee we color to the same - // color of non-existent symlinks according to style_for_path_with_metadata. - if path.metadata().is_none() && target_data.metadata().is_none() { - name.push(target_path); - } else { - name.push(color_name( - locale_aware_escape_name(target_path.as_os_str(), config.quoting_style), - path, - style_manager, - Some(&target_data), - is_wrap(name.len()), - )); + match fs::metadata(&absolute_target) { + Ok(_) => { + let target_data = + PathData::new(absolute_target, None, None, config, false); + name.push(color_name( + escaped_target, + &target_data, + style_manager, + None, + is_wrap(name.len()), + )); + } + Err(_) => { + name.push( + style_manager.apply_missing_target_style( + escaped_target, + is_wrap(name.len()), + ), + ); + } } } else { // If no coloring is required, we just use target as is. // Apply the right quoting - name.push(locale_aware_escape_name( - target_path.as_os_str(), - config.quoting_style, - )); + name.push(escape_name_with_locale(target_path.as_os_str(), config)); } } Err(err) => { diff --git a/tests/by-util/test_ls.rs b/tests/by-util/test_ls.rs index 38729d30630..0ee647987bb 100644 --- a/tests/by-util/test_ls.rs +++ b/tests/by-util/test_ls.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. // spell-checker:ignore (words) READMECAREFULLY birthtime doesntexist oneline somebackup lrwx somefile somegroup somehiddenbackup somehiddenfile tabsize aaaaaaaa bbbb cccc dddddddd ncccc neee naaaaa nbcdef nfffff dired subdired tmpfs mdir COLORTERM mexe bcdef mfoo timefile -// spell-checker:ignore (words) fakeroot setcap drwxr bcdlps +// spell-checker:ignore (words) fakeroot setcap drwxr bcdlps mdangling mentry #![allow( clippy::similar_names, clippy::too_many_lines, @@ -1446,31 +1446,213 @@ fn test_ls_long_dangling_symlink_color() { at.mkdir("dir1"); at.symlink_dir("foo", "dir1/dangling_symlink"); + let ls_colors = "ln=target:or=40:mi=34"; let result = ts .ucmd() + .env("LS_COLORS", ls_colors) .arg("-l") .arg("--color=always") .arg("dir1/dangling_symlink") .succeeds(); let stdout = result.stdout_str(); - // stdout contains output like in the below sequence. We match for the color i.e. 01;36 - // \x1b[0m\x1b[01;36mdir1/dangling_symlink\x1b[0m -> \x1b[01;36mfoo\x1b[0m - let color_regex = Regex::new(r"(\d\d;)\d\dm").unwrap(); - // colors_vec[0] contains the symlink color and style and colors_vec[1] contains the color and style of the file the - // symlink points to. - let colors_vec: Vec<_> = color_regex - .find_iter(stdout) - .map(|color| color.as_str()) - .collect(); + // Ensure dangling link name uses `or=` and target uses `mi=`. + let name_regex = + Regex::new(r"(?:\x1b\[[0-9;]*m)*\x1b\[([0-9;]*)mdir1/dangling_symlink\x1b\[0m").unwrap(); + let target_path = regex::escape(&at.plus_as_string("foo")); + let target_pattern = format!(r"(?:\x1b\[[0-9;]*m)*\x1b\[([0-9;]*)m{target_path}\x1b\[0m"); + let target_regex = Regex::new(&target_pattern).unwrap(); + + let name_caps = name_regex + .captures(stdout) + .expect("failed to capture dangling symlink name color"); + let target_caps = target_regex + .captures(stdout) + .expect("failed to capture dangling target color"); + + let name_color = name_caps.get(1).unwrap().as_str(); + let target_color = target_caps.get(1).unwrap().as_str(); + + assert_eq!(name_color, "40"); + assert_eq!(target_color, "34"); +} + +#[test] +/// Mirrors GNU `tests/ls/ls-misc.pl::sl-dangle3`. +fn test_ls_dangling_symlink_or_and_missing_colors() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.symlink_file("nowhere", "dangling"); + + let stdout = ts + .ucmd() + .env("LS_COLORS", "ln=target:or=40:mi=34") + .arg("-o") + .arg("--time-style=+:TIME:") + .arg("--color=always") + .arg("dangling") + .succeeds() + .stdout_str() + .to_string(); + + let color_regex = Regex::new( + r"\x1b\[0m\x1b\[(?P[0-9;]*)mdangling\x1b\[0m -> \x1b\[(?P[0-9;]*)m", + ) + .unwrap(); + let captures = color_regex + .captures(&stdout) + .expect("failed to capture dangling colors"); + + assert_eq!(captures.name("link").unwrap().as_str(), "40"); + assert_eq!(captures.name("target").unwrap().as_str(), "34"); +} + +#[test] +/// Mirrors GNU `tests/ls/ls-misc.pl::sl-dangle4`. +fn test_ls_dangling_symlink_ln_or_priority() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.symlink_file("nowhere", "dangling"); + + let stdout = ts + .ucmd() + .env("LS_COLORS", "ln=34:mi=35:or=36") + .arg("-o") + .arg("--time-style=+:TIME:") + .arg("--color=always") + .arg("dangling") + .succeeds() + .stdout_str() + .to_string(); + + let color_regex = Regex::new( + r"\x1b\[0m\x1b\[(?P[0-9;]*)mdangling\x1b\[0m -> \x1b\[(?P[0-9;]*)m", + ) + .unwrap(); + let captures = color_regex + .captures(&stdout) + .expect("failed to capture dangling colors"); + assert_eq!(captures.name("link").unwrap().as_str(), "36"); + assert_eq!(captures.name("target").unwrap().as_str(), "35"); +} - assert_eq!(colors_vec[0], colors_vec[1]); - // constructs the string of file path with the color code - let symlink_color_name = colors_vec[0].to_owned() + "dir1/dangling_symlink\x1b"; - let target_color_name = colors_vec[1].to_owned() + at.plus_as_string("foo\x1b").as_str(); +#[test] +/// Mirrors GNU `tests/ls/ls-misc.pl::sl-dangle5`. +fn test_ls_dangling_symlink_ln_and_missing_colors() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.symlink_file("nowhere", "dangling"); - assert!(stdout.contains(&symlink_color_name)); - assert!(stdout.contains(&target_color_name)); + let stdout = ts + .ucmd() + .env("LS_COLORS", "ln=34:mi=35") + .arg("-o") + .arg("--time-style=+:TIME:") + .arg("--color=always") + .arg("dangling") + .succeeds() + .stdout_str() + .to_string(); + + let color_regex = Regex::new( + r"\x1b\[0m\x1b\[(?P[0-9;]*)mdangling\x1b\[0m -> \x1b\[(?P[0-9;]*)m", + ) + .unwrap(); + let captures = color_regex + .captures(&stdout) + .expect("failed to capture dangling colors"); + assert_eq!(captures.name("link").unwrap().as_str(), "34"); + assert_eq!(captures.name("target").unwrap().as_str(), "35"); +} + +#[test] +/// Mirrors GNU `tests/ls/ls-misc.pl::sl-dangle7`. +fn test_ls_dangling_symlink_blank_or_still_emits_reset() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.symlink_file("nowhere", "dangling"); + + let stdout = ts + .ucmd() + .env("LS_COLORS", "ln=target:or=:ex=:") + .arg("--color=always") + .arg("dangling") + .succeeds() + .stdout_str() + .to_string(); + + assert!( + stdout.contains("\u{1b}[0m\u{1b}[mdangling\u{1b}[0m"), + "unexpected output: {stdout:?}" + ); +} + +#[test] +/// Mirrors GNU `tests/ls/ls-misc.pl::sl-dangle9`. +fn test_ls_dangling_symlink_blank_or_in_directory_listing() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.mkdir("dir"); + at.symlink_file("nowhere", "dir/entry"); + + let stdout = ts + .ucmd() + .env("LS_COLORS", "ln=target:or=:ex=:") + .arg("--color=always") + .arg("dir") + .succeeds() + .stdout_str() + .to_string(); + + assert!( + stdout.contains("\u{1b}[0m\u{1b}[mentry\u{1b}[0m"), + "unexpected output: {stdout:?}" + ); +} + +#[test] +/// Mirrors GNU `tests/ls/ls-misc.pl::sl-dangle8`. +fn test_ls_dangling_symlink_uses_ln_when_or_blank() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.symlink_file("nowhere", "dangling"); + + let stdout = ts + .ucmd() + .env("LS_COLORS", "ln=1;36:or=:") + .arg("--color=always") + .arg("dangling") + .succeeds() + .stdout_str() + .to_string(); + + assert!( + stdout.contains("\u{1b}[0m\u{1b}[1;36mdangling\u{1b}[0m"), + "unexpected output: {stdout:?}" + ); +} + +#[test] +/// Mirrors GNU `tests/ls/ls-misc.pl::sl-dangle6`. +fn test_ls_directory_dangling_symlink_uses_ln_when_or_blank() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.mkdir("dir"); + at.symlink_file("nowhere", "dir/entry"); + + let stdout = ts + .ucmd() + .env("LS_COLORS", "ln=1;36:or=:") + .arg("--color=always") + .arg("dir") + .succeeds() + .stdout_str() + .to_string(); + + assert!( + stdout.contains("\u{1b}[0m\u{1b}[1;36mentry\u{1b}[0m"), + "unexpected output: {stdout:?}" + ); } #[test] @@ -6498,7 +6680,7 @@ fn test_f_overrides_sort_flags() { // Create files with different sizes for predictable sort order at.write("small.txt", "a"); // 1 byte - at.write("medium.txt", "bb"); // 2 bytes + at.write("medium.txt", "bb"); // 2 bytes at.write("large.txt", "ccc"); // 3 bytes // Get baseline outputs (include -a to match -f behavior which shows all files)