#![allow(non_snake_case)]
use crate::ported::lex::untokenize;
use crate::ported::params::getsparam;
use crate::ported::subst::{filesubstr, singsub};
use crate::ported::utils::errflag;
use crate::ported::zsh_h::{
Bang, Bnull, Bnullkeep, Comma, Dash, Dnull, Inpar, Inparmath, Nularg, Qtick, Snull, Tick,
Tilde,
};
use std::collections::{HashMap, HashSet};
use std::ffi::CString;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
#[derive(Clone, Copy, Default)]
pub struct PathFlags {
pub require_dir: bool,
pub expand_tilde: bool,
pub for_cd: bool,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub struct IsFile(pub bool);
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub struct IsErr;
pub type FileTestResult = Result<IsFile, IsErr>;
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum RedirectionMode {
Overwrite,
Append,
Input,
TryInput,
Fd,
NoClob,
}
pub struct OperationContext {
cancel_flag: Arc<AtomicBool>,
deadline: Option<std::time::Instant>,
}
impl OperationContext {
pub fn empty() -> Self {
Self {
cancel_flag: Arc::new(AtomicBool::new(false)),
deadline: None,
}
}
pub fn with_cancel_flag(cancel_flag: Arc<AtomicBool>) -> Self {
Self {
cancel_flag,
deadline: None,
}
}
pub fn with_budget(budget: std::time::Duration) -> Self {
Self {
cancel_flag: Arc::new(AtomicBool::new(false)),
deadline: Some(std::time::Instant::now() + budget),
}
}
pub fn check_cancel(&self) -> bool {
self.cancel_flag.load(Ordering::Relaxed)
|| self
.deadline
.is_some_and(|d| std::time::Instant::now() >= d)
}
}
pub struct FileTester<'s> {
working_directory: String,
ctx: &'s OperationContext,
}
impl<'s> FileTester<'s> {
pub fn new(working_directory: String, ctx: &'s OperationContext) -> Self {
Self {
working_directory,
ctx,
}
}
pub fn test_path(&self, token: &str, prefix: bool) -> bool {
if token.len() > (libc::PATH_MAX as usize) {
return false;
}
is_potential_path(
token,
prefix,
std::slice::from_ref(&self.working_directory),
self.ctx,
PathFlags {
expand_tilde: true,
..Default::default()
},
)
}
pub fn test_cd_path(&self, token: &str, is_prefix: bool) -> FileTestResult {
let mut param = token.to_owned(); if !expand_one_no_cmdsubst(&mut param) {
return FileTestResult::Ok(IsFile(false));
}
if "--help".starts_with(¶m) || "-h".starts_with(¶m) {
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()
},
); if valid_path {
Ok(IsFile(valid_path))
} else {
Err(IsErr)
}
}
pub fn test_redirection_target(&self, target: &str, mode: RedirectionMode) -> FileTestResult {
if target.len() > (libc::PATH_MAX as usize) {
return Err(IsErr);
}
let mut target = target.to_owned(); if !expand_one_no_cmdsubst(&mut target) {
return Err(IsErr);
}
let target_path = path_apply_working_directory(&target, &self.working_directory);
match mode {
RedirectionMode::Fd => {
if target == "-" {
return Ok(IsFile(false));
}
match fish_wcstoi(&target) {
Ok(fd) if fd >= 0 => Ok(IsFile(false)),
_ => Err(IsErr),
}
}
RedirectionMode::Input | RedirectionMode::TryInput => {
if waccess(&target_path, libc::R_OK)
&& wstat(&target_path).is_ok_and(|md| !md.file_type().is_dir())
{
Ok(IsFile(true))
} else {
Err(IsErr)
}
}
RedirectionMode::Overwrite | RedirectionMode::Append | RedirectionMode::NoClob => {
if target.ends_with('/') {
return Err(IsErr);
}
let file_exists;
let file_is_writable;
match wstat(&target_path) {
Ok(md) => {
file_exists = true;
file_is_writable =
!md.file_type().is_dir() && waccess(&target_path, libc::W_OK);
}
Err(err) => {
if err.raw_os_error() == Some(libc::ENOENT) {
let mut parent = wdirname(&target_path);
if !parent.ends_with('/') {
parent.push('/');
}
file_exists = false;
file_is_writable = waccess(&parent, libc::W_OK);
} else {
file_exists = false;
file_is_writable = false;
}
}
}
if !file_is_writable || (mode == RedirectionMode::NoClob && file_exists) {
return Err(IsErr);
}
Ok(IsFile(file_exists)) }
}
}
}
pub fn is_potential_path(
potential_path_fragment: &str,
at_cursor: bool,
directories: &[String],
ctx: &OperationContext,
flags: PathFlags,
) -> bool {
if ctx.check_cancel() {
return false;
}
let require_dir = flags.require_dir; let mut clean_potential_path_fragment = String::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);
}
for c in path_with_magic.chars() {
match c {
Snull | Dnull | Bnull | Bnullkeep | Nularg => (), Tilde => clean_potential_path_fragment.push('~'),
Comma => clean_potential_path_fragment.push(','),
Dash => clean_potential_path_fragment.push('-'),
Bang => clean_potential_path_fragment.push('!'),
c if ('\u{84}'..='\u{a1}').contains(&c) => {
has_magic = true;
}
_ => clean_potential_path_fragment.push(c), }
}
if has_magic || clean_potential_path_fragment.is_empty() {
return false;
}
let mut checked_paths: HashSet<String> = HashSet::new();
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.ends_with('/'); if flags.for_cd {
abs_path = normalize_path(&abs_path, true);
}
if abs_path.is_empty() || checked_paths.contains(&abs_path) {
continue;
}
checked_paths.insert(abs_path.clone());
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 {
let dir_name = wdirname(&abs_path); let filename_fragment = wbasename(&abs_path); if dir_name == "/" && filename_fragment == "/" {
return true;
}
if let Ok(dir) = std::fs::read_dir(&dir_name) {
let do_case_insensitive =
fs_is_case_insensitive(&dir_name, &mut case_sensitivity_cache);
for entry in dir {
let Ok(entry) = entry else { continue }; if ctx.check_cancel() {
return false;
}
if require_dir && !dir_entry_is_dir(&entry) {
continue;
}
let entry_name = entry.file_name().to_string_lossy().into_owned();
if entry_name.starts_with(&filename_fragment)
|| (do_case_insensitive
&& string_prefixes_string_case_insensitive(
&filename_fragment,
&entry_name,
))
{
return true;
}
}
}
}
}
false }
pub fn is_potential_cd_path(
path: &str,
at_cursor: bool,
working_directory: &str,
ctx: &OperationContext,
mut flags: PathFlags,
) -> bool {
let mut directories: Vec<String> = vec![];
if path.starts_with("./") {
directories.push(working_directory.to_owned());
} else {
let cdpath_str = getsparam("CDPATH").unwrap_or_default();
let mut pathsv: Vec<String> = if cdpath_str.is_empty() {
vec![".".to_owned()]
} else {
cdpath_str.split(':').map(str::to_owned).collect()
};
pathsv.push(".".to_owned());
for mut next_path in pathsv {
if next_path.is_empty() {
next_path = ".".to_owned();
}
directories.push(path_apply_working_directory(&next_path, working_directory));
}
}
flags.require_dir = true;
flags.for_cd = true;
is_potential_path(path, at_cursor, &directories, ctx, flags)
}
pub type CaseSensitivityCache = HashMap<String, bool>;
#[cfg(any(target_os = "macos", target_os = "ios"))]
fn fs_is_case_insensitive(path: &str, case_sensitivity_cache: &mut CaseSensitivityCache) -> bool {
if let Some(cached) = case_sensitivity_cache.get(path) {
return *cached;
}
let Ok(cpath) = CString::new(path) else {
return false;
};
let ret = unsafe { libc::pathconf(cpath.as_ptr(), libc::_PC_CASE_SENSITIVE) };
let icase = ret == 0; case_sensitivity_cache.insert(path.to_owned(), icase); icase }
#[cfg(not(any(target_os = "macos", target_os = "ios")))]
fn fs_is_case_insensitive(
_path: &str,
_case_sensitivity_cache: &mut CaseSensitivityCache,
) -> bool {
false
}
pub fn normalize_path(path: &str, allow_leading_double_slashes: bool) -> String {
let sep = '/';
let mut leading_slashes: usize = 0;
for c in path.chars() {
if c != sep {
break;
}
leading_slashes += 1;
}
let comps: Vec<&str> = path.split(sep).collect(); let mut new_comps: Vec<&str> = Vec::new(); for comp in comps {
if comp.is_empty() || comp == "." {
continue;
} else if comp != ".." {
new_comps.push(comp);
} else if !new_comps.is_empty() && *new_comps.last().unwrap() != ".." {
new_comps.pop();
} else if leading_slashes == 0 {
new_comps.push("..");
}
}
let mut result = new_comps.join(&sep.to_string()); let mut numslashes = if leading_slashes > 0 { 1 } else { 0 };
if allow_leading_double_slashes && leading_slashes == 2 {
numslashes = 2;
}
for _ in 0..numslashes {
result.insert(0, sep); }
if result.is_empty() {
result.push('.');
}
result
}
pub fn path_apply_working_directory(path: &str, working_directory: &str) -> String {
if path.is_empty() || working_directory.is_empty() {
return path.to_owned();
}
let prepend_wd = !path.starts_with('/');
if !prepend_wd {
return path.to_owned();
}
let mut path_component = path.to_owned();
if path_component.starts_with("./") {
path_component.replace_range(0..2, "");
}
while path_component.starts_with('/') {
path_component.replace_range(0..1, "");
}
let mut result = working_directory.to_owned();
if !result.ends_with('/') {
result.push('/');
}
result.push_str(&path_component);
result
}
pub fn fish_wcstoi(s: &str) -> Result<i32, ()> {
let t = s.trim_start_matches([' ', '\t']);
let (neg, digits) = match t.strip_prefix('-') {
Some(rest) => (true, rest),
None => (false, t.strip_prefix('+').unwrap_or(t)),
};
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return Err(());
}
let mut acc: i64 = 0;
for b in digits.bytes() {
acc = acc.checked_mul(10).ok_or(())?;
acc = acc.checked_add((b - b'0') as i64).ok_or(())?;
if acc > i32::MAX as i64 + 1 {
return Err(());
}
}
let signed = if neg { -acc } else { acc };
i32::try_from(signed).map_err(|_| ())
}
pub fn expand_one_no_cmdsubst(param: &mut String) -> bool {
if param
.chars()
.any(|c| c == Tick || c == Qtick || c == Inpar || c == Inparmath)
{
return false; }
let saved_errflag = errflag.load(Ordering::Relaxed);
let expanded = singsub(param);
let failed = errflag.load(Ordering::Relaxed) != saved_errflag;
errflag.store(saved_errflag, Ordering::Relaxed);
if failed {
return false;
}
*param = untokenize(&expanded);
true
}
fn expand_tilde(input: &mut String) {
let mut s = input.clone();
if let Some(rest) = s.strip_prefix('~') {
s = format!("{Tilde}{rest}");
}
if s.starts_with(Tilde) {
if let Some(expanded) = filesubstr(&s, false) {
*input = expanded;
}
}
}
fn waccess(path: &str, mode: libc::c_int) -> bool {
let Ok(cpath) = CString::new(path) else {
return false;
};
unsafe { libc::access(cpath.as_ptr(), mode) == 0 }
}
fn wstat(path: &str) -> std::io::Result<std::fs::Metadata> {
std::fs::metadata(path)
}
fn wdirname(path: &str) -> String {
let trimmed = path.trim_end_matches('/');
if trimmed.is_empty() {
return "/".to_owned(); }
match trimmed.rfind('/') {
None => ".".to_owned(),
Some(0) => "/".to_owned(),
Some(idx) => trimmed[..idx].trim_end_matches('/').to_owned(),
}
}
fn wbasename(path: &str) -> String {
if path.is_empty() {
return ".".to_owned();
}
let trimmed = path.trim_end_matches('/');
if trimmed.is_empty() {
return "/".to_owned(); }
match trimmed.rfind('/') {
None => trimmed.to_owned(),
Some(idx) => trimmed[idx + 1..].to_owned(),
}
}
fn dir_entry_is_dir(entry: &std::fs::DirEntry) -> bool {
match entry.file_type() {
Ok(ft) if ft.is_dir() => true,
Ok(ft) if ft.is_symlink() => std::fs::metadata(entry.path())
.map(|md| md.is_dir())
.unwrap_or(false),
_ => false,
}
}
fn string_prefixes_string_case_insensitive(proposed_prefix: &str, value: &str) -> bool {
let mut vc = value.chars();
for pc in proposed_prefix.chars() {
match vc.next() {
Some(c) if c.to_lowercase().eq(pc.to_lowercase()) => (),
_ => return false,
}
}
true
}
#[cfg(test)]
mod tests {
use super::{
is_potential_path, FileTester, IsErr, IsFile, OperationContext, PathFlags,
RedirectionMode,
};
use std::fs::{self, create_dir_all, File, Permissions};
use std::os::unix::fs::PermissionsExt as _;
use std::path::PathBuf;
fn is_root() -> bool {
unsafe { libc::geteuid() == 0 }
}
struct TempDirWithCtx {
tempdir: tempfile::TempDir,
ctx: OperationContext,
}
impl TempDirWithCtx {
fn new() -> TempDirWithCtx {
TempDirWithCtx {
tempdir: tempfile::tempdir().unwrap(),
ctx: OperationContext::empty(),
}
}
fn filepath(&self, name: &str) -> PathBuf {
self.tempdir.path().join(name)
}
fn file_tester(&self) -> FileTester<'_> {
FileTester::new(
self.tempdir.path().to_string_lossy().into_owned(),
&self.ctx,
)
}
}
#[test]
fn test_ispath() {
let temp = TempDirWithCtx::new();
let tester = temp.file_tester();
let file_path = temp.filepath("file.txt");
File::create(file_path).unwrap();
assert!(tester.test_path("file.txt", false));
assert!(tester.test_path("file.txt", true));
assert!(!tester.test_path("fi", false));
assert!(tester.test_path("fi", true));
assert!(!tester.test_path("file.txt-more", false));
assert!(!tester.test_path("file.txt-more", true));
assert!(!tester.test_path("ffiledfk.txt", false));
assert!(!tester.test_path("ffiledfk.txt", true));
let dir_path = temp.filepath("somedir");
create_dir_all(dir_path).unwrap();
assert!(tester.test_path("somedir", false));
assert!(tester.test_path("somedir", true));
assert!(!tester.test_path("some", false));
assert!(tester.test_path("some", true));
}
#[test]
fn test_iscdpath() {
let temp = TempDirWithCtx::new();
let tester = temp.file_tester();
let dir_path = temp.filepath("somedir");
create_dir_all(dir_path).unwrap();
assert_eq!(tester.test_cd_path("somedir", false), Ok(IsFile(true)));
assert_eq!(tester.test_cd_path("somedir", true), Ok(IsFile(true)));
assert_eq!(tester.test_cd_path("some", false), Err(IsErr));
assert_eq!(tester.test_cd_path("some", true), Ok(IsFile(true)));
assert_eq!(tester.test_cd_path("notdir", false), Err(IsErr));
assert_eq!(tester.test_cd_path("notdir", true), Err(IsErr));
}
#[test]
fn test_redirections() {
let temp = TempDirWithCtx::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();
assert_eq!(
tester.test_redirection_target("file.txt", RedirectionMode::Input),
Ok(IsFile(true))
);
assert_eq!(
tester.test_redirection_target("notfile.txt", RedirectionMode::Input),
Err(IsErr)
);
assert_eq!(
tester.test_redirection_target("bogus_path/file.txt", RedirectionMode::Input),
Err(IsErr)
);
assert_eq!(
tester.test_redirection_target("somedir", RedirectionMode::Input),
Err(IsErr)
);
if !is_root() {
fs::set_permissions(&file_path, Permissions::from_mode(0o200)).unwrap();
assert_eq!(
tester.test_redirection_target("file.txt", RedirectionMode::Input),
Err(IsErr)
);
fs::set_permissions(&file_path, Permissions::from_mode(0o600)).unwrap();
}
assert_eq!(
tester.test_redirection_target("file.txt", RedirectionMode::TryInput),
Ok(IsFile(true))
);
assert_eq!(
tester.test_redirection_target("notfile.txt", RedirectionMode::TryInput),
Err(IsErr)
);
assert_eq!(
tester.test_redirection_target("bogus_path/file.txt", RedirectionMode::TryInput),
Err(IsErr)
);
assert_eq!(
tester.test_redirection_target("file.txt", RedirectionMode::Overwrite),
Ok(IsFile(true))
);
assert_eq!(
tester.test_redirection_target("file.txt", RedirectionMode::Append),
Ok(IsFile(true))
);
assert_eq!(
tester.test_redirection_target("newfile.txt", RedirectionMode::Overwrite),
Ok(IsFile(false))
);
assert_eq!(
tester.test_redirection_target("file.txt", RedirectionMode::NoClob),
Err(IsErr)
);
assert_eq!(
tester.test_redirection_target("unique.txt", RedirectionMode::NoClob),
Ok(IsFile(false))
);
let write_modes = &[
RedirectionMode::Overwrite,
RedirectionMode::Append,
RedirectionMode::NoClob,
];
for mode in write_modes {
assert_eq!(
tester.test_redirection_target("somedir", *mode),
Err(IsErr),
"Should not be able to write to a directory with mode {:?}",
mode
);
}
if !is_root() {
fs::set_permissions(&file_path, Permissions::from_mode(0o400)).unwrap(); for mode in write_modes {
assert_eq!(
tester.test_redirection_target("file.txt", *mode),
Err(IsErr),
"Should not be able to write to a read-only file with mode {:?}",
mode
);
}
fs::set_permissions(&file_path, Permissions::from_mode(0o600)).unwrap();
fs::set_permissions(&dir_path, Permissions::from_mode(0o500)).unwrap(); for mode in write_modes {
assert_eq!(
tester.test_redirection_target("somedir/newfile.txt", *mode),
Err(IsErr),
"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();
}
for good in ["-", "0", "1", "2", "3", "500", "000", "01", "07"] {
assert_eq!(
tester.test_redirection_target(good, RedirectionMode::Fd),
Ok(IsFile(false)),
"fd target {:?} should be valid",
good
);
}
for bad in [
"0x2",
"0x3F",
"0F",
"-1",
"-0009",
"--",
"derp",
"123boo",
"18446744073709551616",
] {
assert_eq!(
tester.test_redirection_target(bad, RedirectionMode::Fd),
Err(IsErr),
"fd target {:?} should be invalid",
bad
);
}
}
#[test]
fn test_is_potential_path() {
let temp = TempDirWithCtx::new();
let root = temp.tempdir.path();
create_dir_all(root.join("is_potential_path_test/alpha")).unwrap();
create_dir_all(root.join("is_potential_path_test/beta")).unwrap();
fs::write(root.join("is_potential_path_test/aardvark"), []).unwrap();
fs::write(root.join("is_potential_path_test/gamma"), []).unwrap();
let wd = root
.join("is_potential_path_test")
.to_string_lossy()
.into_owned()
+ "/";
let root_str = root.to_string_lossy().into_owned();
let wds = [root_str.clone(), wd];
let ctx = OperationContext::empty();
let path_require_dir = PathFlags {
require_dir: true,
..Default::default()
};
assert!(is_potential_path("al", true, &wds[1..], &ctx, path_require_dir));
assert!(is_potential_path("alpha/", true, &wds[1..], &ctx, path_require_dir));
assert!(is_potential_path("aard", true, &wds[1..], &ctx, PathFlags::default()));
assert!(!is_potential_path("aard", false, &wds[1..], &ctx, PathFlags::default()));
assert!(!is_potential_path(
"alp/",
true,
&wds[1..],
&ctx,
PathFlags {
require_dir: true,
for_cd: true,
..Default::default()
}
));
assert!(!is_potential_path("balpha/", true, &wds[1..], &ctx, path_require_dir));
assert!(!is_potential_path("aard", true, &wds[1..], &ctx, path_require_dir));
assert!(!is_potential_path("aarde", true, &wds[1..], &ctx, path_require_dir));
assert!(!is_potential_path("aarde", true, &wds[1..], &ctx, PathFlags::default()));
assert!(is_potential_path(
"is_potential_path_test/aardvark",
true,
&wds[..1],
&ctx,
PathFlags::default()
));
assert!(is_potential_path(
"is_potential_path_test/al",
true,
&wds[..1],
&ctx,
path_require_dir
));
assert!(is_potential_path(
"is_potential_path_test/aardv",
true,
&wds[..1],
&ctx,
PathFlags::default()
));
assert!(!is_potential_path(
"is_potential_path_test/aardvark",
true,
&wds[..1],
&ctx,
path_require_dir
));
assert!(!is_potential_path(
"is_potential_path_test/al/",
true,
&wds[..1],
&ctx,
PathFlags::default()
));
assert!(!is_potential_path(
"is_potential_path_test/ar",
true,
&wds[..1],
&ctx,
PathFlags::default()
));
assert!(is_potential_path("/usr", true, &wds[..1], &ctx, path_require_dir));
}
}