diff --git a/src/highlight/file_tester.rs b/src/highlight/file_tester.rs new file mode 100644 index 000000000..c3c166e4b --- /dev/null +++ b/src/highlight/file_tester.rs @@ -0,0 +1,417 @@ +// Support for testing whether files exist and have the correct permissions, +// to support highlighting. +// Because this may perform blocking I/O, we compute results in a separate thread, +// and provide them optimistically. +use crate::common::{unescape_string, UnescapeFlags, UnescapeStringStyle}; +use crate::expand::{ + expand_one, BRACE_BEGIN, BRACE_END, BRACE_SEP, INTERNAL_SEPARATOR, PROCESS_EXPAND_SELF, + VARIABLE_EXPAND, VARIABLE_EXPAND_SINGLE, +}; +use crate::expand::{expand_tilde, ExpandFlags, HOME_DIRECTORY}; +use crate::libc::_PC_CASE_SENSITIVE; +use crate::operation_context::OperationContext; +use crate::path::path_apply_working_directory; +use crate::redirection::RedirectionMode; +use crate::threads::assert_is_background_thread; +use crate::wchar::{wstr, WString, L}; +use crate::wchar_ext::WExt; +use crate::wcstringutil::{ + string_prefixes_string, string_prefixes_string_case_insensitive, string_suffixes_string, +}; +use crate::wildcard::{ANY_CHAR, ANY_STRING, ANY_STRING_RECURSIVE}; +use crate::wutil::{ + dir_iter::DirIter, fish_wcstoi, normalize_path, waccess, wbasename, wdirname, wstat, +}; +use libc::PATH_MAX; +use std::collections::hash_map::Entry; +use std::collections::{HashMap, HashSet}; +use std::os::fd::RawFd; + +// This is used only internally to this file, and is exposed only for testing. +#[derive(Clone, Copy, Default)] +pub struct PathFlags { + // The path must be to a directory. + pub require_dir: bool, + // Expand any leading tilde in the path. + pub expand_tilde: bool, + // Normalize directories before resolving, as "cd". + pub for_cd: bool, +} + +// When a file test is OK, we may also return whether this was a file. +// This is used for underlining and is dependent on the particular file test. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct IsFile(pub bool); + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct IsErr; + +/// The result of a file test. +pub type FileTestResult = Result; + +pub struct FileTester<'s> { + // The working directory, for resolving paths against. + working_directory: WString, + // The operation context. + ctx: &'s OperationContext<'s>, +} + +impl<'s> FileTester<'s> { + pub fn new(working_directory: WString, ctx: &'s OperationContext<'s>) -> Self { + Self { + working_directory, + ctx, + } + } + + /// Test whether a file exists and is readable. + /// The input string 'token' is given as an escaped string (as the user may type in). + /// If 'prefix' is true, instead check if the path is a prefix of a valid path. + /// Returns false on cancellation. + pub fn test_path(&self, token: &wstr, prefix: bool) -> bool { + // Skip strings exceeding PATH_MAX. See #7837. + // Note some paths may exceed PATH_MAX, but this is just for highlighting. + if token.len() > (PATH_MAX as usize) { + return false; + } + + // Unescape the token. + let Some(mut token) = + unescape_string(token, UnescapeStringStyle::Script(UnescapeFlags::SPECIAL)) + else { + return false; + }; + + // Big hack: is_potential_path expects a tilde, but unescape_string gives us HOME_DIRECTORY. + // Put it back. + if token.char_at(0) == HOME_DIRECTORY { + token.as_char_slice_mut()[0] = '~'; + } + + is_potential_path( + &token, + prefix, + &[self.working_directory.to_owned()], + self.ctx, + PathFlags { + expand_tilde: true, + ..Default::default() + }, + ) + } + + // Test if the string is a prefix of a valid path we could cd into, or is some other token + // we recognize (primarily --help). + // If is_prefix is true, we test if the string is a prefix of a valid path we could cd into. + pub fn test_cd_path(&self, token: &wstr, is_prefix: bool) -> FileTestResult { + let mut param = token.to_owned(); + if !expand_one(&mut param, ExpandFlags::FAIL_ON_CMDSUBST, self.ctx, None) { + // Failed expansion (e.g. may contain a command substitution). Ignore it. + return FileTestResult::Ok(IsFile(false)); + } + // Maybe it's just --help. + if string_prefixes_string(¶m, L!("--help")) || string_prefixes_string(¶m, L!("-h")) + { + return FileTestResult::Ok(IsFile(false)); + } + let valid_path = is_potential_cd_path( + ¶m, + is_prefix, + &self.working_directory, + self.ctx, + PathFlags { + expand_tilde: true, + ..Default::default() + }, + ); + // cd into an invalid path is an error. + if valid_path { + Ok(IsFile(valid_path)) + } else { + Err(IsErr) + } + } + + // Test if a the given string is a valid redirection target, given the mode. + // Note we return bool, because we never underline redirection targets. + pub fn test_redirection_target(&self, target: &wstr, mode: RedirectionMode) -> bool { + // Skip targets exceeding PATH_MAX. See #7837. + if target.len() > (PATH_MAX as usize) { + return false; + } + let mut target = target.to_owned(); + if !expand_one(&mut target, ExpandFlags::FAIL_ON_CMDSUBST, self.ctx, None) { + // Could not be expanded. + return false; + } + // Ok, we successfully expanded our target. Now verify that it works with this + // redirection. We will probably need it as a path (but not in the case of fd + // redirections). Note that the target is now unescaped. + let target_path = path_apply_working_directory(&target, &self.working_directory); + match mode { + RedirectionMode::fd => { + if target == "-" { + return true; + } + match fish_wcstoi(&target) { + Ok(fd) => fd >= 0, + Err(_) => false, + } + } + RedirectionMode::input | RedirectionMode::try_input => { + // Input redirections must have a readable non-directory. + // Note we color "try_input" files as errors if they are invalid, + // even though it's possible to execute these (replaced via /dev/null). + waccess(&target_path, libc::R_OK) == 0 + && wstat(&target_path).map_or(false, |md| !md.file_type().is_dir()) + } + RedirectionMode::overwrite | RedirectionMode::append | RedirectionMode::noclob => { + if string_suffixes_string(L!("/"), &target) { + // Redirections to things that are directories is definitely not + // allowed. + return false; + } + // Test whether the file exists, and whether it's writable (possibly after + // creating it). access() returns failure if the file does not exist. + // TODO: we do not need to compute file_exists for an 'overwrite' redirection. + let file_exists; + let file_is_writable; + match wstat(&target_path) { + Ok(md) => { + // No err. We can write to it if it's not a directory and we have + // permission. + file_exists = true; + file_is_writable = + !md.file_type().is_dir() && waccess(&target_path, libc::W_OK) == 0; + } + Err(err) => { + if err.raw_os_error() == Some(libc::ENOENT) { + // File does not exist. Check if its parent directory is writable. + let mut parent = wdirname(&target_path).to_owned(); + + // Ensure that the parent ends with the path separator. This will ensure + // that we get an error if the parent directory is not really a + // directory. + if !string_suffixes_string(L!("/"), &parent) { + parent.push('/'); + } + + // Now the file is considered writable if the parent directory is + // writable. + file_exists = false; + file_is_writable = waccess(&parent, libc::W_OK) == 0; + } else { + // Other errors we treat as not writable. This includes things like + // ENOTDIR. + file_exists = false; + file_is_writable = false; + } + } + } + // NOCLOB means that we must not overwrite files that exist. + file_is_writable && !(file_exists && mode == RedirectionMode::noclob) + } + } + } +} + +/// Tests whether the specified string cpath is the prefix of anything we could cd to. directories +/// is a list of possible parent directories (typically either the working directory, or the +/// cdpath). This does I/O! +/// +/// We expect the path to already be unescaped. +pub fn is_potential_path( + potential_path_fragment: &wstr, + at_cursor: bool, + directories: &[WString], + ctx: &OperationContext<'_>, + flags: PathFlags, +) -> bool { + // This function is expected to be called from the background thread. + // But in tests, threads get weird. + if cfg!(not(test)) { + assert_is_background_thread(); + } + + if ctx.check_cancel() { + return false; + } + + let require_dir = flags.require_dir; + let mut clean_potential_path_fragment = WString::new(); + let mut has_magic = false; + + let mut path_with_magic = potential_path_fragment.to_owned(); + if flags.expand_tilde { + expand_tilde(&mut path_with_magic, ctx.vars()); + } + + for c in path_with_magic.chars() { + match c { + PROCESS_EXPAND_SELF + | VARIABLE_EXPAND + | VARIABLE_EXPAND_SINGLE + | BRACE_BEGIN + | BRACE_END + | BRACE_SEP + | ANY_CHAR + | ANY_STRING + | ANY_STRING_RECURSIVE => { + has_magic = true; + } + INTERNAL_SEPARATOR => (), + _ => clean_potential_path_fragment.push(c), + } + } + + if has_magic || clean_potential_path_fragment.is_empty() { + return false; + } + + // Don't test the same path multiple times, which can happen if the path is absolute and the + // CDPATH contains multiple entries. + let mut checked_paths = HashSet::new(); + + // Keep a cache of which paths / filesystems are case sensitive. + let mut case_sensitivity_cache = CaseSensitivityCache::new(); + + for wd in directories { + if ctx.check_cancel() { + return false; + } + let mut abs_path = path_apply_working_directory(&clean_potential_path_fragment, wd); + let must_be_full_dir = abs_path.chars().next_back() == Some('/'); + if flags.for_cd { + abs_path = normalize_path(&abs_path, /*allow_leading_double_slashes=*/ true); + } + + // Skip this if it's empty or we've already checked it. + if abs_path.is_empty() || checked_paths.contains(&abs_path) { + continue; + } + checked_paths.insert(abs_path.clone()); + + // If the user is still typing the argument, we want to highlight it if it's the prefix + // of a valid path. This means we need to potentially walk all files in some directory. + // There are two easy cases where we can skip this: + // 1. If the argument ends with a slash, it must be a valid directory, no prefix. + // 2. If the cursor is not at the argument, it means the user is definitely not typing it, + // so we can skip the prefix-match. + if must_be_full_dir || !at_cursor { + if let Ok(md) = wstat(&abs_path) { + if !at_cursor || md.file_type().is_dir() { + return true; + } + } + } else { + // We do not end with a slash; it does not have to be a directory. + let dir_name = wdirname(&abs_path); + let filename_fragment = wbasename(&abs_path); + if dir_name == "/" && filename_fragment == "/" { + // cd ///.... No autosuggestion. + return true; + } + + if let Ok(mut dir) = DirIter::new(dir_name) { + // Check if we're case insensitive. + let do_case_insensitive = + fs_is_case_insensitive(dir_name, dir.fd(), &mut case_sensitivity_cache); + + // We opened the dir_name; look for a string where the base name prefixes it. + while let Some(entry) = dir.next() { + let Ok(entry) = entry else { continue }; + if ctx.check_cancel() { + return false; + } + + // Maybe skip directories. + if require_dir && !entry.is_dir() { + continue; + } + + if string_prefixes_string(filename_fragment, &entry.name) + || (do_case_insensitive + && string_prefixes_string_case_insensitive( + filename_fragment, + &entry.name, + )) + { + return true; + } + } + } + } + } + false +} + +// Given a string, return whether it prefixes a path that we could cd into. Return that path in +// out_path. Expects path to be unescaped. +pub fn is_potential_cd_path( + path: &wstr, + at_cursor: bool, + working_directory: &wstr, + ctx: &OperationContext<'_>, + mut flags: PathFlags, +) -> bool { + let mut directories = vec![]; + + if string_prefixes_string(L!("./"), path) { + // Ignore the CDPATH in this case; just use the working directory. + directories.push(working_directory.to_owned()); + } else { + // Get the CDPATH. + let cdpath = ctx.vars().get_unless_empty(L!("CDPATH")); + let mut pathsv = match cdpath { + None => vec![L!(".").to_owned()], + Some(cdpath) => cdpath.as_list().to_vec(), + }; + // The current $PWD is always valid. + pathsv.push(L!(".").to_owned()); + + for mut next_path in pathsv { + if next_path.is_empty() { + next_path = L!(".").to_owned(); + } + // Ensure that we use the working directory for relative cdpaths like ".". + directories.push(path_apply_working_directory(&next_path, working_directory)); + } + } + + // Call is_potential_path with all of these directories. + flags.require_dir = true; + flags.for_cd = true; + is_potential_path(path, at_cursor, &directories, ctx, flags) +} + +/// Determine if the filesystem containing the given fd is case insensitive for lookups regardless +/// of whether it preserves the case when saving a pathname. +/// +/// Returns: +/// false: the filesystem is not case insensitive +/// true: the file system is case insensitive +pub type CaseSensitivityCache = HashMap; +fn fs_is_case_insensitive( + path: &wstr, + fd: RawFd, + case_sensitivity_cache: &mut CaseSensitivityCache, +) -> bool { + let mut result = false; + if *_PC_CASE_SENSITIVE != 0 { + // Try the cache first. + match case_sensitivity_cache.entry(path.to_owned()) { + Entry::Occupied(e) => { + /* Use the cached value */ + result = *e.get(); + } + Entry::Vacant(e) => { + // Ask the system. A -1 value means error (so assume case sensitive), a 1 value means case + // sensitive, and a 0 value means case insensitive. + let ret = unsafe { libc::fpathconf(fd, *_PC_CASE_SENSITIVE) }; + result = ret == 0; + e.insert(result); + } + } + } + result +} diff --git a/src/highlight.rs b/src/highlight/highlight.rs similarity index 73% rename from src/highlight.rs rename to src/highlight/highlight.rs index 29b466580..c7196a60c 100644 --- a/src/highlight.rs +++ b/src/highlight/highlight.rs @@ -7,23 +7,17 @@ use crate::builtins::shared::builtin_exists; use crate::color::RgbColor; use crate::common::{ - unescape_string, valid_var_name, valid_var_name_char, UnescapeFlags, ASCII_MAX, - EXPAND_RESERVED_BASE, EXPAND_RESERVED_END, + valid_var_name, valid_var_name_char, ASCII_MAX, EXPAND_RESERVED_BASE, EXPAND_RESERVED_END, }; use crate::complete::complete_wrap_map; use crate::env::Environment; use crate::expand::{ - expand_one, expand_tilde, expand_to_command_and_args, ExpandFlags, ExpandResultCode, - HOME_DIRECTORY, PROCESS_EXPAND_SELF_STR, -}; -use crate::expand::{ - BRACE_BEGIN, BRACE_END, BRACE_SEP, INTERNAL_SEPARATOR, PROCESS_EXPAND_SELF, VARIABLE_EXPAND, - VARIABLE_EXPAND_SINGLE, + expand_one, expand_to_command_and_args, ExpandFlags, ExpandResultCode, PROCESS_EXPAND_SELF_STR, }; use crate::function; use crate::future_feature_flags::{feature_test, FeatureFlag}; +use crate::highlight::file_tester::FileTester; use crate::history::{all_paths_are_valid, HistoryItem}; -use crate::libc::_PC_CASE_SENSITIVE; use crate::operation_context::OperationContext; use crate::output::{parse_color, Outputter}; use crate::parse_constants::{ @@ -32,27 +26,16 @@ use crate::parse_util::{ parse_util_locate_cmdsubst_range, parse_util_slice_length, MaybeParentheses, }; -use crate::path::{ - path_apply_working_directory, path_as_implicit_cd, path_get_cdpath, path_get_path, - paths_are_same_file, -}; -use crate::redirection::RedirectionMode; +use crate::path::{path_as_implicit_cd, path_get_cdpath, path_get_path, paths_are_same_file}; use crate::threads::assert_is_background_thread; use crate::tokenizer::{variable_assignment_equals_pos, PipeOrRedir}; use crate::wchar::{wstr, WString, L}; use crate::wchar_ext::WExt; -use crate::wcstringutil::{ - string_prefixes_string, string_prefixes_string_case_insensitive, string_suffixes_string, -}; -use crate::wildcard::{ANY_CHAR, ANY_STRING, ANY_STRING_RECURSIVE}; -use crate::wutil::dir_iter::DirIter; -use crate::wutil::fish_wcstoi; -use crate::wutil::{normalize_path, waccess, wstat}; -use crate::wutil::{wbasename, wdirname}; -use libc::{ENOENT, PATH_MAX, R_OK, W_OK}; +use crate::wcstringutil::string_prefixes_string; use std::collections::hash_map::Entry; -use std::collections::{HashMap, HashSet}; -use std::os::fd::RawFd; +use std::collections::HashMap; + +use super::file_tester::IsFile; impl HighlightSpec { pub fn new() -> Self { @@ -664,230 +647,6 @@ enum Mode { } } -/// Indicates whether the source range of the given node forms a valid path in the given -/// working_directory. -fn range_is_potential_path( - src: &wstr, - range: SourceRange, - at_cursor: bool, - ctx: &OperationContext, - working_directory: &wstr, -) -> bool { - // Skip strings exceeding PATH_MAX. See #7837. - // Note some paths may exceed PATH_MAX, but this is just for highlighting. - if range.length() > (PATH_MAX as usize) { - return false; - } - // Get the node source, unescape it, and then pass it to is_potential_path along with the - // working directory (as a one element list). - let mut result = false; - if let Some(mut token) = unescape_string( - &src[range.start()..range.end()], - crate::common::UnescapeStringStyle::Script(UnescapeFlags::SPECIAL), - ) { - // Big hack: is_potential_path expects a tilde, but unescape_string gives us HOME_DIRECTORY. - // Put it back. - if token.char_at(0) == HOME_DIRECTORY { - token.as_char_slice_mut()[0] = '~'; - } - - result = is_potential_path( - &token, - at_cursor, - &[working_directory.to_owned()], - ctx, - PathFlags { - expand_tilde: true, - ..Default::default() - }, - ); - } - result -} - -// Tests whether the specified string cpath is the prefix of anything we could cd to. directories is -// a list of possible parent directories (typically either the working directory, or the cdpath). -// This does I/O! -// -// This is used only internally to this file, and is exposed only for testing. -#[derive(Clone, Copy, Default)] -pub struct PathFlags { - // The path must be to a directory. - pub require_dir: bool, - // Expand any leading tilde in the path. - pub expand_tilde: bool, - // Normalize directories before resolving, as "cd". - pub for_cd: bool, -} - -/// Tests whether the specified string cpath is the prefix of anything we could cd to. directories -/// is a list of possible parent directories (typically either the working directory, or the -/// cdpath). This does I/O! -/// -/// Hack: if out_suggested_cdpath is not NULL, it returns the autosuggestion for cd. This descends -/// the deepest unique directory hierarchy. -/// -/// We expect the path to already be unescaped. -pub fn is_potential_path( - potential_path_fragment: &wstr, - at_cursor: bool, - directories: &[WString], - ctx: &OperationContext<'_>, - flags: PathFlags, -) -> bool { - assert_is_background_thread(); - - if ctx.check_cancel() { - return false; - } - - let require_dir = flags.require_dir; - let mut clean_potential_path_fragment = WString::new(); - let mut has_magic = false; - - let mut path_with_magic = potential_path_fragment.to_owned(); - if flags.expand_tilde { - expand_tilde(&mut path_with_magic, ctx.vars()); - } - - for c in path_with_magic.chars() { - match c { - PROCESS_EXPAND_SELF - | VARIABLE_EXPAND - | VARIABLE_EXPAND_SINGLE - | BRACE_BEGIN - | BRACE_END - | BRACE_SEP - | ANY_CHAR - | ANY_STRING - | ANY_STRING_RECURSIVE => { - has_magic = true; - } - INTERNAL_SEPARATOR => (), - _ => clean_potential_path_fragment.push(c), - } - } - - if has_magic || clean_potential_path_fragment.is_empty() { - return false; - } - - // Don't test the same path multiple times, which can happen if the path is absolute and the - // CDPATH contains multiple entries. - let mut checked_paths = HashSet::new(); - - // Keep a cache of which paths / filesystems are case sensitive. - let mut case_sensitivity_cache = CaseSensitivityCache::new(); - - for wd in directories { - if ctx.check_cancel() { - return false; - } - let mut abs_path = path_apply_working_directory(&clean_potential_path_fragment, wd); - let must_be_full_dir = abs_path.chars().next_back() == Some('/'); - if flags.for_cd { - abs_path = normalize_path(&abs_path, /*allow_leading_double_slashes=*/ true); - } - - // Skip this if it's empty or we've already checked it. - if abs_path.is_empty() || checked_paths.contains(&abs_path) { - continue; - } - checked_paths.insert(abs_path.clone()); - - // If the user is still typing the argument, we want to highlight it if it's the prefix - // of a valid path. This means we need to potentially walk all files in some directory. - // There are two easy cases where we can skip this: - // 1. If the argument ends with a slash, it must be a valid directory, no prefix. - // 2. If the cursor is not at the argument, it means the user is definitely not typing it, - // so we can skip the prefix-match. - if must_be_full_dir || !at_cursor { - if let Ok(md) = wstat(&abs_path) { - if !at_cursor || md.file_type().is_dir() { - return true; - } - } - } else { - // We do not end with a slash; it does not have to be a directory. - let dir_name = wdirname(&abs_path); - let filename_fragment = wbasename(&abs_path); - if dir_name == "/" && filename_fragment == "/" { - // cd ///.... No autosuggestion. - return true; - } - - if let Ok(mut dir) = DirIter::new(dir_name) { - // Check if we're case insensitive. - let do_case_insensitive = - fs_is_case_insensitive(dir_name, dir.fd(), &mut case_sensitivity_cache); - - // We opened the dir_name; look for a string where the base name prefixes it. - while let Some(entry) = dir.next() { - let Ok(entry) = entry else { continue }; - if ctx.check_cancel() { - return false; - } - - // Maybe skip directories. - if require_dir && !entry.is_dir() { - continue; - } - - if string_prefixes_string(filename_fragment, &entry.name) - || (do_case_insensitive - && string_prefixes_string_case_insensitive( - filename_fragment, - &entry.name, - )) - { - return true; - } - } - } - } - } - false -} - -// Given a string, return whether it prefixes a path that we could cd into. Return that path in -// out_path. Expects path to be unescaped. -fn is_potential_cd_path( - path: &wstr, - at_cursor: bool, - working_directory: &wstr, - ctx: &OperationContext<'_>, - mut flags: PathFlags, -) -> bool { - let mut directories = vec![]; - - if string_prefixes_string(L!("./"), path) { - // Ignore the CDPATH in this case; just use the working directory. - directories.push(working_directory.to_owned()); - } else { - // Get the CDPATH. - let cdpath = ctx.vars().get_unless_empty(L!("CDPATH")); - let mut pathsv = match cdpath { - None => vec![L!(".").to_owned()], - Some(cdpath) => cdpath.as_list().to_vec(), - }; - // The current $PWD is always valid. - pathsv.push(L!(".").to_owned()); - - for mut next_path in pathsv { - if next_path.is_empty() { - next_path = L!(".").to_owned(); - } - // Ensure that we use the working directory for relative cdpaths like ".". - directories.push(path_apply_working_directory(&next_path, working_directory)); - } - } - - // Call is_potential_path with all of these directories. - flags.require_dir = true; - flags.for_cd = true; - is_potential_path(path, at_cursor, &directories, ctx, flags) -} - pub type ColorArray = Vec; /// Syntax highlighter helper. @@ -902,6 +661,8 @@ struct Highlighter<'s> { io_ok: bool, // Working directory. working_directory: WString, + // Our component for testing strings for being potential file paths. + file_tester: FileTester<'s>, // The resulting colors. color_array: ColorArray, // A stack of variables that the current commandline probably defines. We mark redirections @@ -918,12 +679,14 @@ pub fn new( working_directory: WString, can_do_io: bool, ) -> Self { + let file_tester = FileTester::new(working_directory.clone(), ctx); Self { buff, cursor, ctx, io_ok: can_do_io, working_directory, + file_tester, color_array: vec![], pending_variables: vec![], done: false, @@ -1123,56 +886,35 @@ fn visit_argument(&mut self, arg: &Argument, cmd_is_cd: bool, options_allowed: b if !self.io_still_ok() { return; } + // Underline every valid path. - let mut is_valid_path = false; - let at_cursor = self + let source_range = arg.source_range(); + let is_prefix = self .cursor - .is_some_and(|c| arg.source_range().contains_inclusive(c)); - if cmd_is_cd { - // Mark this as an error if it's not 'help' and not a valid cd path. - let mut param = arg.source(self.buff).to_owned(); - if expand_one(&mut param, ExpandFlags::FAIL_ON_CMDSUBST, self.ctx, None) { - let is_help = string_prefixes_string(¶m, L!("--help")) - || string_prefixes_string(¶m, L!("-h")); - if !is_help { - is_valid_path = is_potential_cd_path( - ¶m, - at_cursor, - &self.working_directory, - self.ctx, - PathFlags { - expand_tilde: true, - ..Default::default() - }, - ); - if !is_valid_path { - self.color_node( - arg.as_node(), - HighlightSpec::with_fg(HighlightRole::error), - ); - } + .map_or(false, |c| source_range.contains_inclusive(c)); + let token = arg.source(self.buff).to_owned(); + let test_result = if cmd_is_cd { + self.file_tester.test_cd_path(&token, is_prefix) + } else { + let is_path = self.file_tester.test_path(&token, is_prefix); + Ok(IsFile(is_path)) + }; + match test_result { + Ok(IsFile(false)) => (), + Ok(IsFile(true)) => { + for i in source_range.as_usize() { + self.color_array[i].valid_path = true; } } - } else if range_is_potential_path( - self.buff, - arg.range().unwrap(), - at_cursor, - self.ctx, - &self.working_directory, - ) { - is_valid_path = true; - } - if is_valid_path { - for i in arg.range().unwrap().start()..arg.range().unwrap().end() { - self.color_array[i].valid_path = true; - } + Err(..) => self.color_node(arg.as_node(), HighlightSpec::with_fg(HighlightRole::error)), } } + fn visit_redirection(&mut self, redir: &Redirection) { // like 2> let oper = PipeOrRedir::try_from(redir.oper.source(self.buff)) .expect("Should have successfully parsed a pipe_or_redir_t since it was in our ast"); - let mut target = redir.target.source(self.buff).to_owned(); // like &1 or file path + let target = redir.target.source(self.buff).to_owned(); // like &1 or file path // Color the > part. // It may have parsed successfully yet still be invalid (e.g. 9999999999999>&1) @@ -1196,107 +938,30 @@ fn visit_redirection(&mut self, redir: &Redirection) { // even though it's a command redirection, and don't try to do any other validation. if has_cmdsub(&target) { self.color_as_argument(redir.target.leaf_as_node(), true); - } else { - // No command substitution, so we can highlight the target file or fd. For example, - // disallow redirections into a non-existent directory. - let target_is_valid; - if !self.io_still_ok() { - // I/O is disallowed, so we don't have much hope of catching anything but gross - // errors. Assume it's valid. - target_is_valid = true; - } else if contains_pending_variable(&self.pending_variables, &target) { - target_is_valid = true; - } else if !expand_one(&mut target, ExpandFlags::FAIL_ON_CMDSUBST, self.ctx, None) { - // Could not be expanded. - target_is_valid = false; - } else { - // Ok, we successfully expanded our target. Now verify that it works with this - // redirection. We will probably need it as a path (but not in the case of fd - // redirections). Note that the target is now unescaped. - let target_path = path_apply_working_directory(&target, &self.working_directory); - match oper.mode { - RedirectionMode::fd => { - if target == "-" { - target_is_valid = true; - } else { - target_is_valid = match fish_wcstoi(&target) { - Ok(fd) => fd >= 0, - Err(_) => false, - }; - } - } - RedirectionMode::input | RedirectionMode::try_input => { - // Input redirections must have a readable non-directory. - target_is_valid = waccess(&target_path, R_OK) == 0 - && match wstat(&target_path) { - Ok(md) => !md.file_type().is_dir(), - Err(_) => false, - }; - } - RedirectionMode::overwrite - | RedirectionMode::append - | RedirectionMode::noclob => { - // Test whether the file exists, and whether it's writable (possibly after - // creating it). access() returns failure if the file does not exist. - let file_exists; - let file_is_writable; - - if string_suffixes_string(L!("/"), &target) { - // Redirections to things that are directories is definitely not - // allowed. - file_exists = false; - file_is_writable = false; - } else { - match wstat(&target_path) { - Ok(md) => { - // No err. We can write to it if it's not a directory and we have - // permission. - file_exists = true; - file_is_writable = !md.file_type().is_dir() - && waccess(&target_path, W_OK) == 0; - } - Err(err) => { - if err.raw_os_error() == Some(ENOENT) { - // File does not exist. Check if its parent directory is writable. - let mut parent = wdirname(&target_path).to_owned(); - - // Ensure that the parent ends with the path separator. This will ensure - // that we get an error if the parent directory is not really a - // directory. - if !string_suffixes_string(L!("/"), &parent) { - parent.push('/'); - } - - // Now the file is considered writable if the parent directory is - // writable. - file_exists = false; - file_is_writable = waccess(&parent, W_OK) == 0; - } else { - // Other errors we treat as not writable. This includes things like - // ENOTDIR. - file_exists = false; - file_is_writable = false; - } - } - } - } - - // NOCLOB means that we must not overwrite files that exist. - target_is_valid = file_is_writable - && !(file_exists && oper.mode == RedirectionMode::noclob); - } - } - } - self.color_node( - redir.target.leaf_as_node(), - HighlightSpec::with_fg(if target_is_valid { - HighlightRole::redirection - } else { - HighlightRole::error - }), - ); + return; } + // No command substitution, so we can highlight the target file or fd. For example, + // disallow redirections into a non-existent directory. + let target_is_valid = if !self.io_still_ok() { + // I/O is disallowed, so we don't have much hope of catching anything but gross + // errors. Assume it's valid. + true + } else if contains_pending_variable(&self.pending_variables, &target) { + true + } else { + // Validate the redirection target.. + self.file_tester.test_redirection_target(&target, oper.mode) + }; + self.color_node( + redir.target.leaf_as_node(), + HighlightSpec::with_fg(if target_is_valid { + HighlightRole::redirection + } else { + HighlightRole::error + }), + ); } + fn visit_variable_assignment(&mut self, varas: &VariableAssignment) { self.color_as_argument(varas, true); // Highlight the '=' in variable assignments as an operator. @@ -1547,38 +1212,6 @@ fn get_fallback(role: HighlightRole) -> HighlightRole { } } -/// Determine if the filesystem containing the given fd is case insensitive for lookups regardless -/// of whether it preserves the case when saving a pathname. -/// -/// Returns: -/// false: the filesystem is not case insensitive -/// true: the file system is case insensitive -pub type CaseSensitivityCache = HashMap; -fn fs_is_case_insensitive( - path: &wstr, - fd: RawFd, - case_sensitivity_cache: &mut CaseSensitivityCache, -) -> bool { - let mut result = false; - if *_PC_CASE_SENSITIVE != 0 { - // Try the cache first. - match case_sensitivity_cache.entry(path.to_owned()) { - Entry::Occupied(e) => { - /* Use the cached value */ - result = *e.get(); - } - Entry::Vacant(e) => { - // Ask the system. A -1 value means error (so assume case sensitive), a 1 value means case - // sensitive, and a 0 value means case insensitive. - let ret = unsafe { libc::fpathconf(fd, *_PC_CASE_SENSITIVE) }; - result = ret == 0; - e.insert(result); - } - } - } - result -} - impl Default for HighlightRole { fn default() -> Self { Self::normal diff --git a/src/highlight/mod.rs b/src/highlight/mod.rs new file mode 100644 index 000000000..ced381ab1 --- /dev/null +++ b/src/highlight/mod.rs @@ -0,0 +1,8 @@ +pub mod file_tester; +#[allow(clippy::module_inception)] +mod highlight; +pub use file_tester::is_potential_path; +pub use highlight::*; + +#[cfg(test)] +mod tests; diff --git a/src/tests/highlight.rs b/src/highlight/tests.rs similarity index 67% rename from src/tests/highlight.rs rename to src/highlight/tests.rs index 276b045b8..cb944cad2 100644 --- a/src/tests/highlight.rs +++ b/src/highlight/tests.rs @@ -5,7 +5,8 @@ use crate::wchar::prelude::*; use crate::{ env::EnvStack, - highlight::{highlight_shell, is_potential_path, HighlightRole, HighlightSpec, PathFlags}, + highlight::file_tester::{is_potential_path, PathFlags}, + highlight::{highlight_shell, HighlightRole, HighlightSpec}, operation_context::{OperationContext, EXPANSION_LIMIT_BACKGROUND, EXPANSION_LIMIT_DEFAULT}, }; use libc::PATH_MAX; @@ -646,3 +647,250 @@ macro_rules! validate { ("echo", fg(HighlightRole::error)), ); } + +pub use super::file_tester::{FileTester, IsErr, IsFile}; +mod file_tester_tests { + use super::*; + use crate::common::charptr2wcstring; + use crate::redirection::RedirectionMode; + use std::fs::{self, create_dir_all, File, Permissions}; + use std::os::unix::fs::PermissionsExt; + + struct TempDir { + basepath: WString, + ctx: OperationContext<'static>, + } + + impl TempDir { + fn new() -> TempDir { + let mut t1 = *b"/tmp/fish_file_tester_dir.XXXXXX\0"; + let basepath_narrow = unsafe { libc::mkdtemp(t1.as_mut_ptr().cast()) }; + assert!(!basepath_narrow.is_null(), "mkdtemp failed"); + let basepath: WString = charptr2wcstring(basepath_narrow); + TempDir { + basepath, + ctx: OperationContext::empty(), + } + } + + fn filepath(&self, name: &str) -> String { + let mut result = self.basepath.to_string(); + result.push('/'); + result.push_str(name); + result + } + + fn file_tester(&self) -> FileTester<'_> { + FileTester::new(self.basepath.clone(), &self.ctx) + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(self.basepath.to_string()); + } + } + + #[test] + fn test_ispath() { + let temp = TempDir::new(); + let tester = temp.file_tester(); + + let file_path = temp.filepath("file.txt"); + File::create(&file_path).unwrap(); + + let result = tester.test_path(L!("file.txt"), false); + assert!(result); + + let result = tester.test_path(L!("file.txt"), true); + assert!(result); + + let result = tester.test_path(L!("fi"), false); + assert!(!result); + + let result = tester.test_path(L!("fi"), true); + assert!(result); + + let result = tester.test_path(L!("file.txt-more"), false); + assert!(!result); + + let result = tester.test_path(L!("file.txt-more"), true); + assert!(!result); + + let result = tester.test_path(L!("ffiledfk.txt"), false); + assert!(!result); + + let result = tester.test_path(L!("ffiledfk.txt"), true); + assert!(!result); + + // Directories are also files. + let dir_path = temp.filepath("somedir"); + create_dir_all(&dir_path).unwrap(); + + let result = tester.test_path(L!("somedir"), false); + assert!(result); + + let result = tester.test_path(L!("somedir"), true); + assert!(result); + + let result = tester.test_path(L!("some"), false); + assert!(!result); + + let result = tester.test_path(L!("some"), true); + assert!(result); + } + + #[test] + fn test_iscdpath() { + let temp = TempDir::new(); + let tester = temp.file_tester(); + + // Note cd (unlike file paths) should report IsErr for invalid cd paths, + // rather than IsFile(false). + + let dir_path = temp.filepath("somedir"); + create_dir_all(&dir_path).unwrap(); + + let result = tester.test_cd_path(L!("somedir"), false); + assert_eq!(result, Ok(IsFile(true))); + + let result = tester.test_cd_path(L!("somedir"), true); + assert_eq!(result, Ok(IsFile(true))); + + let result = tester.test_cd_path(L!("some"), false); + assert_eq!(result, Err(IsErr)); + + let result = tester.test_cd_path(L!("some"), true); + assert_eq!(result, Ok(IsFile(true))); + + let result = tester.test_cd_path(L!("notdir"), false); + assert_eq!(result, Err(IsErr)); + + let result = tester.test_cd_path(L!("notdir"), true); + assert_eq!(result, Err(IsErr)); + } + + #[test] + fn test_redirections() { + // Note we use is_ok and is_err since we don't care about the IsFile part. + let temp = TempDir::new(); + let tester = temp.file_tester(); + let file_path = temp.filepath("file.txt"); + File::create(&file_path).unwrap(); + + let dir_path = temp.filepath("somedir"); + create_dir_all(&dir_path).unwrap(); + + // Normal redirection. + let result = tester.test_redirection_target(L!("file.txt"), RedirectionMode::input); + assert!(result); + + // Can't redirect from a missing file + let result = tester.test_redirection_target(L!("notfile.txt"), RedirectionMode::input); + assert!(!result); + let result = + tester.test_redirection_target(L!("bogus_path/file.txt"), RedirectionMode::input); + assert!(!result); + + // Can't redirect from a directory. + let result = tester.test_redirection_target(L!("somedir"), RedirectionMode::input); + assert!(!result); + + // Can't redirect from an unreadable file. + fs::set_permissions(&file_path, Permissions::from_mode(0o200)).unwrap(); + let result = tester.test_redirection_target(L!("file.txt"), RedirectionMode::input); + assert!(!result); + fs::set_permissions(&file_path, Permissions::from_mode(0o600)).unwrap(); + + // try_input syntax highlighting reports an error even though the command will succeed. + let result = tester.test_redirection_target(L!("file.txt"), RedirectionMode::try_input); + assert!(result); + let result = tester.test_redirection_target(L!("notfile.txt"), RedirectionMode::try_input); + assert!(!result); + let result = + tester.test_redirection_target(L!("bogus_path/file.txt"), RedirectionMode::try_input); + assert!(!result); + + // Test write redirections. + // Overwrite an existing file. + let result = tester.test_redirection_target(L!("file.txt"), RedirectionMode::overwrite); + assert!(result); + + // Append to an existing file. + let result = tester.test_redirection_target(L!("file.txt"), RedirectionMode::append); + assert!(result); + + // Write to a missing file. + let result = tester.test_redirection_target(L!("newfile.txt"), RedirectionMode::overwrite); + assert!(result); + + // No-clobber write to existing file should fail. + let result = tester.test_redirection_target(L!("file.txt"), RedirectionMode::noclob); + assert!(!result); + + // No-clobber write to missing file should succeed. + let result = tester.test_redirection_target(L!("unique.txt"), RedirectionMode::noclob); + assert!(result); + + let write_modes = &[ + RedirectionMode::overwrite, + RedirectionMode::append, + RedirectionMode::noclob, + ]; + + // Can't write to a directory. + for mode in write_modes { + assert!( + !tester.test_redirection_target(L!("somedir"), *mode), + "Should not be able to write to a directory with mode {:?}", + mode + ); + } + + // Can't write without write permissions. + fs::set_permissions(&file_path, Permissions::from_mode(0o400)).unwrap(); // Read-only. + for mode in write_modes { + assert!( + !tester.test_redirection_target(L!("file.txt"), *mode), + "Should not be able to write to a read-only file with mode {:?}", + mode + ); + } + fs::set_permissions(&file_path, Permissions::from_mode(0o600)).unwrap(); // Restore permissions. + + // Writing into a directory without write permissions (loop through all modes). + fs::set_permissions(&dir_path, Permissions::from_mode(0o500)).unwrap(); // Read and execute, no write. + for mode in write_modes { + assert!( + !tester.test_redirection_target(L!("somedir/newfile.txt"), *mode), + "Should not be able to create/write in a read-only directory with mode {:?}", + mode + ); + } + fs::set_permissions(&dir_path, Permissions::from_mode(0o700)).unwrap(); // Restore permissions. + + // Test fd redirections. + assert!(tester.test_redirection_target(L!("-"), RedirectionMode::fd)); + assert!(tester.test_redirection_target(L!("0"), RedirectionMode::fd)); + assert!(tester.test_redirection_target(L!("1"), RedirectionMode::fd)); + assert!(tester.test_redirection_target(L!("2"), RedirectionMode::fd)); + assert!(tester.test_redirection_target(L!("3"), RedirectionMode::fd)); + assert!(tester.test_redirection_target(L!("500"), RedirectionMode::fd)); + + // We are base 10, despite the leading 0. + assert!(tester.test_redirection_target(L!("000"), RedirectionMode::fd)); + assert!(tester.test_redirection_target(L!("01"), RedirectionMode::fd)); + assert!(tester.test_redirection_target(L!("07"), RedirectionMode::fd)); + + // Invalid fd redirections. + assert!(!tester.test_redirection_target(L!("0x2"), RedirectionMode::fd)); + assert!(!tester.test_redirection_target(L!("0x3F"), RedirectionMode::fd)); + assert!(!tester.test_redirection_target(L!("0F"), RedirectionMode::fd)); + assert!(!tester.test_redirection_target(L!("-1"), RedirectionMode::fd)); + assert!(!tester.test_redirection_target(L!("-0009"), RedirectionMode::fd)); + assert!(!tester.test_redirection_target(L!("--"), RedirectionMode::fd)); + assert!(!tester.test_redirection_target(L!("derp"), RedirectionMode::fd)); + assert!(!tester.test_redirection_target(L!("123boo"), RedirectionMode::fd)); + assert!(!tester.test_redirection_target(L!("18446744073709551616"), RedirectionMode::fd)); + } +} diff --git a/src/parse_constants.rs b/src/parse_constants.rs index d9f19e809..98ef58211 100644 --- a/src/parse_constants.rs +++ b/src/parse_constants.rs @@ -44,6 +44,21 @@ pub struct SourceRange { pub length: u32, } +impl Default for SourceRange { + fn default() -> Self { + SourceRange { + start: 0, + length: 0, + } + } +} + +impl SourceRange { + pub fn as_usize(&self) -> std::ops::Range { + (*self).into() + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ParseTokenType { invalid = 1, diff --git a/src/redirection.rs b/src/redirection.rs index 251a23c00..23416ff68 100644 --- a/src/redirection.rs +++ b/src/redirection.rs @@ -6,7 +6,7 @@ use nix::fcntl::OFlag; use std::os::fd::RawFd; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] pub enum RedirectionMode { overwrite, // normal redirection: > file.txt append, // appending redirection: >> file.txt diff --git a/src/tests/mod.rs b/src/tests/mod.rs index e8c71775d..207b1b257 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -8,7 +8,6 @@ mod env_universal_common; mod expand; mod fd_monitor; -mod highlight; mod history; mod input; mod input_common;