use std::path::PathBuf;
pub struct PathSearcher {
dirs: Vec<PathBuf>,
canon_dirs: std::cell::RefCell<Vec<Option<PathBuf>>>,
}
fn validate_path_entry(path: &str) -> Result<(), String> {
if path.contains('\0') {
return Err("PATH entry contains null byte".to_string());
}
for ch in path.chars() {
if ch.is_control() && ch != '\t' {
return Err(format!("PATH entry contains control character: {ch:?}"));
}
}
Ok(())
}
fn warn_suspicious_path(path: &str) {
const DANGEROUS_CHARS: &[char] = &['$', '`', ';', '&', '|', '<', '>', '(', ')', '{', '}'];
for &ch in DANGEROUS_CHARS {
if path.contains(ch) {
eprintln!("Warning: PATH entry contains shell metacharacter '{ch}': {path}");
return;
}
}
if !path.starts_with('/') && !path.is_empty() && path != "." {
eprintln!("Warning: Relative PATH entry detected: {path}");
}
}
impl PathSearcher {
#[must_use]
pub fn new(path_var: &str) -> Self {
let mut has_empty = false;
let dirs: Vec<PathBuf> = path_var
.split(':')
.filter_map(|s| {
if s.is_empty() {
has_empty = true;
return None; }
if let Err(e) = validate_path_entry(s) {
eprintln!("Warning: Skipping invalid PATH entry: {e}");
return None;
}
warn_suspicious_path(s);
Some(PathBuf::from(s))
})
.collect();
if has_empty {
eprintln!("Warning: Empty PATH component(s) detected and skipped. Empty components can be a security risk.");
}
let canon_dirs = std::cell::RefCell::new(vec![None; dirs.len()]);
PathSearcher { dirs, canon_dirs }
}
#[must_use]
pub fn dirs(&self) -> &[PathBuf] {
&self.dirs
}
fn canonicalize_index(&self, idx: usize) -> Option<PathBuf> {
let mut cache = self.canon_dirs.borrow_mut();
if cache[idx].is_none() {
cache[idx] = std::fs::canonicalize(&self.dirs[idx]).ok();
}
cache[idx].clone()
}
#[must_use]
pub fn contains(&self, path: &std::path::Path) -> bool {
self.find_path_index(path).is_some()
}
pub fn insert_at(&mut self, path: &std::path::Path, position: usize) -> Result<(), String> {
if position == 0 {
return Err("Position must be >= 1".to_string());
}
let path_buf = path.to_path_buf();
let insert_idx = (position - 1).min(self.dirs.len());
self.dirs.insert(insert_idx, path_buf);
self.canon_dirs.borrow_mut().insert(insert_idx, None);
Ok(())
}
pub fn move_entry(&self, from: usize, to: usize) -> Result<String, String> {
let len = self.dirs.len();
if from == 0 || to == 0 {
return Err(format!(
"Invalid index: indices must be >= 1 (got from={from}, to={to})"
));
}
if from > len {
return Err(format!(
"Index {from} out of bounds (PATH has {len} entries)"
));
}
if to > len {
return Err(format!("Index {to} out of bounds (PATH has {len} entries)"));
}
let from_idx = from - 1;
let to_idx = to - 1;
let mut new_dirs = self.dirs.clone();
let item = new_dirs.remove(from_idx);
new_dirs.insert(to_idx, item);
Ok(new_dirs
.iter()
.map(|d| d.display().to_string())
.collect::<Vec<_>>()
.join(":"))
}
pub fn swap_entries(&self, idx1: usize, idx2: usize) -> Result<String, String> {
let len = self.dirs.len();
if idx1 == 0 || idx2 == 0 {
return Err(format!(
"Invalid index: indices must be >= 1 (got idx1={idx1}, idx2={idx2})"
));
}
if idx1 > len {
return Err(format!(
"Index {idx1} out of bounds (PATH has {len} entries)"
));
}
if idx2 > len {
return Err(format!(
"Index {idx2} out of bounds (PATH has {len} entries)"
));
}
let idx1_0 = idx1 - 1;
let idx2_0 = idx2 - 1;
let mut new_dirs = self.dirs.clone();
new_dirs.swap(idx1_0, idx2_0);
Ok(new_dirs
.iter()
.map(|d| d.display().to_string())
.collect::<Vec<_>>()
.join(":"))
}
#[must_use]
pub fn clean_duplicates(&self) -> (String, Vec<usize>) {
let mut seen = std::collections::HashSet::new();
let mut cleaned = Vec::new();
let mut removed_indices = Vec::new();
for (idx, dir) in self.dirs.iter().enumerate() {
let dir_str = dir.display().to_string();
if seen.insert(dir_str.clone()) {
cleaned.push(dir_str);
} else {
removed_indices.push(idx + 1);
}
}
(cleaned.join(":"), removed_indices)
}
pub fn delete_entry(&self, idx: usize) -> Result<String, String> {
let len = self.dirs.len();
if idx == 0 {
return Err(format!("Invalid index: {idx} (must be >= 1)"));
}
if idx > len {
return Err(format!(
"Index {idx} out of bounds (PATH has {len} entries)"
));
}
let idx_0 = idx - 1;
let mut new_dirs = self.dirs.clone();
new_dirs.remove(idx_0);
Ok(new_dirs
.iter()
.map(|d| d.display().to_string())
.collect::<Vec<_>>()
.join(":"))
}
pub fn delete_entries(&self, indices: &[usize]) -> Result<String, String> {
let len = self.dirs.len();
for &idx in indices {
if idx == 0 {
return Err(format!("Invalid index: {idx} (indices must be >= 1)"));
}
if idx > len {
return Err(format!(
"Index {idx} out of bounds (PATH has {len} entries)"
));
}
}
let mut sorted_indices: Vec<usize> = indices.to_vec();
sorted_indices.sort_unstable_by(|a, b| b.cmp(a));
sorted_indices.dedup();
let mut new_dirs = self.dirs.clone();
for &idx in &sorted_indices {
let idx_0 = idx - 1; new_dirs.remove(idx_0);
}
Ok(new_dirs
.iter()
.map(|d| d.display().to_string())
.collect::<Vec<_>>()
.join(":"))
}
pub fn add_path(&self, path: &std::path::Path) -> Result<(String, usize), String> {
match self.add_path_at_position(path, 1) {
Ok(new_path) => Ok((new_path, 1)),
Err(e) => Err(e),
}
}
pub fn add_path_at_position(
&self,
path: &std::path::Path,
position: usize,
) -> Result<String, String> {
let path_buf = path.to_path_buf();
if let Some(_idx) = self.find_path_index(&path_buf) {
return Ok(self.to_path_string());
}
if position == 0 {
return Err("Position must be >= 1".to_string());
}
let mut new_dirs = self.dirs.clone();
let insert_idx = (position - 1).min(new_dirs.len());
new_dirs.insert(insert_idx, path_buf);
let new_path = new_dirs
.iter()
.map(|d| d.display().to_string())
.collect::<Vec<_>>()
.join(":");
Ok(new_path)
}
#[must_use]
pub fn find_path_index(&self, path: &std::path::Path) -> Option<usize> {
use std::fs;
let canonical_search = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
for (idx, dir) in self.dirs.iter().enumerate() {
if dir == path || dir == &canonical_search {
return Some(idx + 1); }
if let Some(canonical_dir) = self.canonicalize_index(idx) {
if canonical_dir == canonical_search {
return Some(idx + 1);
}
}
}
None
}
#[must_use]
pub fn find_fuzzy_indices(
&self,
pattern: &str,
executable_name: Option<&str>,
) -> Vec<(usize, &PathBuf)> {
use crate::path_resolver::FuzzyMatcher;
let matcher = FuzzyMatcher::new(pattern);
let mut fuzzy_results = Vec::new();
for (idx, dir) in self.dirs.iter().enumerate() {
if matcher.matches(dir) {
if let Some(name) = executable_name {
let exec_path = dir.join(name);
if !exec_path.exists() {
continue;
}
}
fuzzy_results.push((idx + 1, dir)); }
}
fuzzy_results.sort_by_key(|(_, path)| path.as_os_str().len());
fuzzy_results
}
#[allow(dead_code)]
pub fn delete_by_path(&self, path: &std::path::Path) -> Result<String, String> {
if let Some(idx) = self.find_path_index(path) {
self.delete_entry(idx)
} else {
Err(format!("Path not found in PATH: {}", path.display()))
}
}
#[must_use]
pub fn has_executable(&self, dir: &std::path::Path, name: &str) -> bool {
use crate::executor::ExecutableCheck;
let exec_path = dir.join(name);
exec_path.exists() && ExecutableCheck::new(&exec_path).is_executable()
}
#[must_use]
pub fn to_path_string(&self) -> String {
self.dirs
.iter()
.map(|d| d.display().to_string())
.collect::<Vec<_>>()
.join(":")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_move_entry_forward() {
let searcher = PathSearcher::new("/a:/b:/c:/d:/e");
let result = searcher.move_entry(5, 2).unwrap();
assert_eq!(result, "/a:/e:/b:/c:/d");
}
#[test]
fn test_move_entry_backward() {
let searcher = PathSearcher::new("/a:/b:/c:/d:/e");
let result = searcher.move_entry(2, 4).unwrap();
assert_eq!(result, "/a:/c:/d:/b:/e");
}
#[test]
fn test_move_entry_to_first() {
let searcher = PathSearcher::new("/a:/b:/c:/d:/e");
let result = searcher.move_entry(4, 1).unwrap();
assert_eq!(result, "/d:/a:/b:/c:/e");
}
#[test]
fn test_move_entry_to_last() {
let searcher = PathSearcher::new("/a:/b:/c:/d:/e");
let result = searcher.move_entry(2, 5).unwrap();
assert_eq!(result, "/a:/c:/d:/e:/b");
}
#[test]
fn test_move_entry_same_position() {
let searcher = PathSearcher::new("/a:/b:/c:/d:/e");
let result = searcher.move_entry(3, 3).unwrap();
assert_eq!(result, "/a:/b:/c:/d:/e");
}
#[test]
fn test_move_entry_zero_index() {
let searcher = PathSearcher::new("/a:/b:/c");
let result = searcher.move_entry(0, 2);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.contains("must be >= 1"));
assert!(err.contains("0"));
}
#[test]
fn test_move_entry_out_of_bounds() {
let searcher = PathSearcher::new("/a:/b:/c");
let result = searcher.move_entry(1, 5);
assert!(result.is_err());
assert!(result.unwrap_err().contains("out of bounds"));
}
#[test]
fn test_swap_entries_basic() {
let searcher = PathSearcher::new("/a:/b:/c:/d:/e");
let result = searcher.swap_entries(2, 4).unwrap();
assert_eq!(result, "/a:/d:/c:/b:/e");
}
#[test]
fn test_swap_entries_same_index() {
let searcher = PathSearcher::new("/a:/b:/c:/d:/e");
let result = searcher.swap_entries(3, 3).unwrap();
assert_eq!(result, "/a:/b:/c:/d:/e");
}
#[test]
fn test_swap_entries_first_and_last() {
let searcher = PathSearcher::new("/a:/b:/c:/d:/e");
let result = searcher.swap_entries(1, 5).unwrap();
assert_eq!(result, "/e:/b:/c:/d:/a");
}
#[test]
fn test_swap_entries_zero_index() {
let searcher = PathSearcher::new("/a:/b:/c");
let result = searcher.swap_entries(0, 2);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.contains("must be >= 1"));
assert!(err.contains("0"));
}
#[test]
fn test_swap_entries_out_of_bounds() {
let searcher = PathSearcher::new("/a:/b:/c");
let result = searcher.swap_entries(2, 5);
assert!(result.is_err());
assert!(result.unwrap_err().contains("out of bounds"));
}
#[test]
fn test_clean_no_duplicates() {
let searcher = PathSearcher::new("/a:/b:/c:/d:/e");
let (result, removed) = searcher.clean_duplicates();
assert_eq!(result, "/a:/b:/c:/d:/e");
assert!(removed.is_empty());
}
#[test]
fn test_clean_with_duplicates() {
let searcher = PathSearcher::new("/a:/b:/c:/b:/d:/a");
let (result, removed) = searcher.clean_duplicates();
assert_eq!(result, "/a:/b:/c:/d");
assert_eq!(removed, vec![4, 6]); }
#[test]
fn test_clean_all_same() {
let searcher = PathSearcher::new("/a:/a:/a");
let (result, removed) = searcher.clean_duplicates();
assert_eq!(result, "/a");
assert_eq!(removed, vec![2, 3]);
}
#[test]
fn test_clean_consecutive_duplicates() {
let searcher = PathSearcher::new("/a:/a:/b:/b:/c");
let (result, removed) = searcher.clean_duplicates();
assert_eq!(result, "/a:/b:/c");
assert_eq!(removed, vec![2, 4]);
}
#[test]
fn test_clean_empty() {
let searcher = PathSearcher::new("");
let (result, removed) = searcher.clean_duplicates();
assert_eq!(result, "");
assert!(removed.is_empty());
}
#[test]
fn test_clean_matches_delete() {
let path = "/a:/b:/c:/b:/d:/a:/e:/c";
let searcher = PathSearcher::new(path);
let (clean_result, removed) = searcher.clean_duplicates();
let delete_result = searcher.delete_entries(&removed).unwrap();
assert_eq!(clean_result, delete_result);
assert_eq!(removed, vec![4, 6, 8]); }
#[test]
fn test_delete_first() {
let searcher = PathSearcher::new("/a:/b:/c:/d:/e");
let result = searcher.delete_entry(1).unwrap();
assert_eq!(result, "/b:/c:/d:/e");
}
#[test]
fn test_delete_middle() {
let searcher = PathSearcher::new("/a:/b:/c:/d:/e");
let result = searcher.delete_entry(3).unwrap();
assert_eq!(result, "/a:/b:/d:/e");
}
#[test]
fn test_delete_last() {
let searcher = PathSearcher::new("/a:/b:/c:/d:/e");
let result = searcher.delete_entry(5).unwrap();
assert_eq!(result, "/a:/b:/c:/d");
}
#[test]
fn test_delete_only_entry() {
let searcher = PathSearcher::new("/a");
let result = searcher.delete_entry(1).unwrap();
assert_eq!(result, "");
}
#[test]
fn test_delete_zero_index() {
let searcher = PathSearcher::new("/a:/b:/c");
let result = searcher.delete_entry(0);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.contains("must be >= 1"));
assert!(err.contains("0"));
}
#[test]
fn test_delete_out_of_bounds() {
let searcher = PathSearcher::new("/a:/b:/c");
let result = searcher.delete_entry(5);
assert!(result.is_err());
assert!(result.unwrap_err().contains("out of bounds"));
}
#[test]
fn test_delete_entries_multiple() {
let searcher = PathSearcher::new("/a:/b:/c:/d:/e");
let result = searcher.delete_entries(&[2, 4]).unwrap();
assert_eq!(result, "/a:/c:/e");
}
#[test]
fn test_delete_entries_unordered() {
let searcher = PathSearcher::new("/a:/b:/c:/d:/e");
let result = searcher.delete_entries(&[5, 2, 3]).unwrap();
assert_eq!(result, "/a:/d");
}
#[test]
fn test_delete_entries_with_duplicates() {
let searcher = PathSearcher::new("/a:/b:/c:/d:/e");
let result = searcher.delete_entries(&[2, 2, 4, 4]).unwrap();
assert_eq!(result, "/a:/c:/e");
}
#[test]
fn test_delete_entries_all() {
let searcher = PathSearcher::new("/a:/b:/c");
let result = searcher.delete_entries(&[1, 2, 3]).unwrap();
assert_eq!(result, "");
}
#[test]
fn test_delete_entries_zero_index() {
let searcher = PathSearcher::new("/a:/b:/c");
let result = searcher.delete_entries(&[1, 0, 3]);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.contains("must be >= 1"));
assert!(err.contains("0"));
}
#[test]
fn test_delete_entries_out_of_bounds() {
let searcher = PathSearcher::new("/a:/b:/c");
let result = searcher.delete_entries(&[1, 5, 2]);
assert!(result.is_err());
assert!(result.unwrap_err().contains("out of bounds"));
}
#[test]
fn test_delete_entries_single() {
let searcher = PathSearcher::new("/a:/b:/c:/d:/e");
let result = searcher.delete_entries(&[3]).unwrap();
assert_eq!(result, "/a:/b:/d:/e");
}
#[test]
fn test_path_validation_null_byte() {
let result = validate_path_entry("hello\0world");
assert!(result.is_err());
assert!(result.unwrap_err().contains("null byte"));
}
#[test]
fn test_path_validation_control_chars() {
let result = validate_path_entry("hello\x01world");
assert!(result.is_err());
assert!(result.unwrap_err().contains("control character"));
}
#[test]
fn test_path_validation_tab_allowed() {
let result = validate_path_entry("hello\tworld");
assert!(result.is_ok());
}
#[test]
fn test_path_validation_newline_rejected() {
let result = validate_path_entry("hello\nworld");
assert!(result.is_err());
}
#[test]
fn test_empty_path_components_skipped() {
let searcher = PathSearcher::new("/a::/b");
let dirs = searcher.dirs();
assert_eq!(dirs.len(), 2);
assert_eq!(dirs[0].to_str().unwrap(), "/a");
assert_eq!(dirs[1].to_str().unwrap(), "/b");
}
#[test]
fn test_malicious_path_filtered() {
let searcher = PathSearcher::new("/good:/bad\0path:/alsogood");
let dirs = searcher.dirs();
assert_eq!(dirs.len(), 2);
assert_eq!(dirs[0].to_str().unwrap(), "/good");
assert_eq!(dirs[1].to_str().unwrap(), "/alsogood");
}
#[test]
fn test_error_messages_include_values() {
let searcher = PathSearcher::new("/a:/b:/c");
let err = searcher.move_entry(0, 2).unwrap_err();
assert!(err.contains("0"));
assert!(err.contains("must be >= 1"));
let err = searcher.move_entry(5, 2).unwrap_err();
assert!(err.contains("5"));
assert!(err.contains("out of bounds"));
assert!(err.contains("3 entries"));
}
}