Factor file testing out of highlighting

Syntax highlighting wants to underline arguments that are files, and in other
cases do disk I/O (such as testing if a command is valid). Factor out this I/O
logic to untangle highlighting, and add some tests. No functional change
expected.
This commit is contained in:
Peter Ammon 2025-01-01 16:52:52 -08:00
parent 4f3d6427ce
commit 9785824794
No known key found for this signature in database
7 changed files with 743 additions and 423 deletions

View file

@ -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<IsFile, IsErr>;
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(&param, L!("--help")) || string_prefixes_string(&param, L!("-h"))
{
return FileTestResult::Ok(IsFile(false));
}
let valid_path = is_potential_cd_path(
&param,
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<WString, bool>;
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
}

View file

@ -7,23 +7,17 @@ use crate::ast::{
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_constants::{
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 @@ fn color_string_internal(buffstr: &wstr, base_color: HighlightSpec, colors: &mut
}
}
/// 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<HighlightSpec>;
/// 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 @@ impl<'s> Highlighter<'s> {
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 @@ impl<'s> Highlighter<'s> {
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(&param, L!("--help"))
|| string_prefixes_string(&param, L!("-h"));
if !is_help {
is_valid_path = is_potential_cd_path(
&param,
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 @@ impl<'s> Highlighter<'s> {
// 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<WString, bool>;
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

8
src/highlight/mod.rs Normal file
View file

@ -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;

View file

@ -5,7 +5,8 @@ use crate::tests::prelude::*;
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 @@ fn test_highlighting() {
("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));
}
}

View file

@ -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<usize> {
(*self).into()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ParseTokenType {
invalid = 1,

View file

@ -6,7 +6,7 @@ use crate::wutil::fish_wcstoi;
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

View file

@ -8,7 +8,6 @@ mod env;
mod env_universal_common;
mod expand;
mod fd_monitor;
mod highlight;
mod history;
mod input;
mod input_common;