#[cfg(test)]
use std::cell::RefCell;
use std::{
ffi::OsStr,
fs,
io::{BufRead, BufReader},
path::{Path, PathBuf},
};
use cap_std::{ambient_authority, fs::Dir};
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use crate::domain::{
chat_completion::{ProjectFileCompletion, ProjectFileSearchResult},
errors::{AgentError, AgentResult, ErrorCode},
};
pub(crate) const MAX_PROJECT_FILE_SCAN_ENTRIES: usize = 25_000;
pub(crate) const MAX_PROJECT_FILE_MATCHES: usize = 20;
#[derive(Debug, Clone)]
pub(crate) struct ProjectFileSearch {
scan_limit: usize,
result_limit: usize,
}
impl Default for ProjectFileSearch {
fn default() -> Self {
Self {
scan_limit: MAX_PROJECT_FILE_SCAN_ENTRIES,
result_limit: MAX_PROJECT_FILE_MATCHES,
}
}
}
impl ProjectFileSearch {
pub(crate) fn search(
&self,
workspace: &Path,
query: &str,
) -> AgentResult<ProjectFileSearchResult> {
let root = fs::canonicalize(workspace).map_err(file_search_failed)?;
if !root.is_dir() || fs::read_dir(&root).is_err() {
return Err(file_search_failed("workspace is not a readable directory"));
}
if self.scan_limit == 0 {
return Ok(ProjectFileSearchResult {
matches: Vec::new(),
truncated: true,
});
}
let workspace_dir =
Dir::open_ambient_dir(&root, ambient_authority()).map_err(file_search_failed)?;
let workspace_cap = workspace_dir.try_clone().map_err(file_search_failed)?;
let root_display = root.clone();
let root_ignore = IgnoreStack::new(&workspace_cap, &root_display);
let root_entries = workspace_dir.entries().map_err(file_search_failed)?;
let mut scan_budget = ScanBudget::new(self.scan_limit);
let mut traversal = vec![TraversalFrame {
directory: workspace_dir,
entries: root_entries,
lexical_path: PathBuf::new(),
cap_path: PathBuf::new(),
canonical_path: PathBuf::new(),
ignore: root_ignore,
}];
let mut scored = Vec::new();
'walk: while let Some(frame) = traversal.last_mut() {
let next = frame.entries.next();
let _ = &frame.directory;
let parent_lexical_path = frame.lexical_path.clone();
let parent_cap_path = frame.cap_path.clone();
let parent_ignore = frame.ignore.clone();
match next {
None => {
traversal.pop();
}
Some(Err(_)) => {
if !scan_budget.consume() || scan_budget.truncated() {
break;
}
}
Some(Ok(entry)) => {
let lexical_path = parent_lexical_path.join(entry.file_name());
let display_path = root_display.join(&lexical_path);
let cap_path = parent_cap_path.join(entry.file_name());
after_candidate_filter_attempt_hook(&display_path);
if !scan_budget.consume() || scan_budget.truncated() {
break;
}
if contains_git_component(&lexical_path) {
continue;
}
let Some(canonical_path) =
resolve_workspace_path(&workspace_cap, &root_display, workspace, &cap_path)
else {
continue;
};
if contains_git_component(&canonical_path) {
continue;
}
let Ok(metadata) = workspace_cap.metadata(&canonical_path) else {
continue;
};
let is_directory = metadata.is_dir();
if parent_ignore.is_ignored(&display_path, is_directory) {
continue;
}
let child_directory = if is_directory {
match workspace_cap.open_dir(&canonical_path) {
Ok(directory) => {
after_child_directory_opened_hook(&display_path);
Some(directory)
}
Err(_) => continue,
}
} else {
None
};
let child_ignore = child_directory.as_ref().map(|directory| {
parent_ignore
.with_directory_rules(directory, root_display.join(&lexical_path))
});
#[cfg(test)]
after_candidate_validated_hook(&display_path);
let Some(revalidated_path) = resolve_workspace_path(
&workspace_cap,
&root_display,
workspace,
&lexical_path,
) else {
continue;
};
if revalidated_path != canonical_path
|| contains_git_component(&revalidated_path)
{
continue;
}
if let Some(relative) = relative_display_path(&lexical_path)
&& !relative.contains('\\')
{
let name = entry.file_name().to_string_lossy().into_owned();
let score = match_score(&name, &relative, query, is_directory);
if query.is_empty() || score > 0 {
scored.push((
score,
ProjectFileCompletion {
path: relative,
name,
is_directory,
},
));
}
}
if let Some(child_directory) = child_directory {
if traversal
.iter()
.any(|frame| frame.canonical_path == canonical_path)
{
continue;
}
let Ok(entries) = child_directory.entries() else {
if !scan_budget.consume() || scan_budget.truncated() {
break 'walk;
}
continue;
};
traversal.push(TraversalFrame {
directory: child_directory,
entries,
lexical_path,
cap_path: canonical_path.clone(),
canonical_path,
ignore: child_ignore.expect("directory ignore state exists"),
});
}
}
}
}
scored.sort_by(|(left_score, left), (right_score, right)| {
right_score.cmp(left_score).then(left.path.cmp(&right.path))
});
let truncated = scan_budget.truncated() || scored.len() > self.result_limit;
scored.truncate(self.result_limit);
Ok(ProjectFileSearchResult {
matches: scored.into_iter().map(|(_, item)| item).collect(),
truncated,
})
}
}
struct TraversalFrame {
directory: Dir,
entries: cap_std::fs::ReadDir,
lexical_path: PathBuf,
cap_path: PathBuf,
canonical_path: PathBuf,
ignore: IgnoreStack,
}
#[derive(Clone)]
struct IgnoreStack {
ignores: Vec<Gitignore>,
gitignores: Vec<Gitignore>,
git_exclude: Gitignore,
global: Gitignore,
}
impl IgnoreStack {
fn new(root: &Dir, root_display: &Path) -> Self {
let (global, _) = GitignoreBuilder::new(root_display).build_global();
let git_exclude = load_ignore_matcher(root, Path::new(".git/info/exclude"), root_display)
.unwrap_or_else(Gitignore::empty);
let mut stack = Self {
ignores: Vec::new(),
gitignores: Vec::new(),
git_exclude,
global,
};
stack.load_directory_rules(root, root_display);
stack
}
fn with_directory_rules(&self, directory: &Dir, directory_display: PathBuf) -> Self {
let mut child = self.clone();
child.load_directory_rules(directory, &directory_display);
child
}
fn load_directory_rules(&mut self, directory: &Dir, directory_display: &Path) {
if let Some(matcher) =
load_ignore_matcher(directory, Path::new(".ignore"), directory_display)
{
self.ignores.push(matcher);
}
if let Some(matcher) =
load_ignore_matcher(directory, Path::new(".gitignore"), directory_display)
{
self.gitignores.push(matcher);
}
}
fn is_ignored(&self, path: &Path, is_directory: bool) -> bool {
first_match(&self.ignores, path, is_directory)
.or_else(|| first_match(&self.gitignores, path, is_directory))
.or_else(|| match_result(&self.git_exclude, path, is_directory))
.or_else(|| match_result(&self.global, path, is_directory))
.unwrap_or(false)
}
}
fn load_ignore_matcher(directory: &Dir, file_name: &Path, root: &Path) -> Option<Gitignore> {
let bytes = directory.read(file_name).ok()?;
let source = root.join(file_name);
after_ignore_file_loaded_hook(&source, &bytes);
let mut builder = GitignoreBuilder::new(root);
let reader = BufReader::new(bytes.as_slice());
for (index, line) in reader.lines().enumerate() {
let Ok(line) = line else {
break;
};
let line = if index == 0 {
line.trim_start_matches('\u{feff}')
} else {
&line
};
let _ = builder.add_line(Some(source.clone()), line);
}
builder.build().ok()
}
fn first_match(matchers: &[Gitignore], path: &Path, is_directory: bool) -> Option<bool> {
matchers
.iter()
.rev()
.find_map(|matcher| match_result(matcher, path, is_directory))
}
fn match_result(matcher: &Gitignore, path: &Path, is_directory: bool) -> Option<bool> {
let matched = matcher.matched(path, is_directory);
(!matched.is_none()).then(|| matched.is_ignore())
}
fn resolve_workspace_path(
workspace: &Dir,
root: &Path,
workspace_alias: &Path,
path: &Path,
) -> Option<PathBuf> {
let mut resolved = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::CurDir => continue,
std::path::Component::Normal(name) => resolved.push(name),
std::path::Component::ParentDir => resolved.push(".."),
std::path::Component::RootDir | std::path::Component::Prefix(_) => return None,
}
let metadata = workspace.symlink_metadata(&resolved).ok()?;
if metadata.file_type().is_symlink() {
let target = workspace.read_link_contents(&resolved).ok()?;
resolved = if target.is_absolute() {
target
.strip_prefix(root)
.or_else(|_| target.strip_prefix(workspace_alias))
.ok()?
.to_path_buf()
} else {
resolved.parent()?.join(target)
};
}
resolved = workspace.canonicalize(&resolved).ok()?;
}
Some(resolved)
}
#[derive(Debug)]
struct ScanBudget {
limit: usize,
scanned: usize,
truncated: bool,
}
impl ScanBudget {
fn new(limit: usize) -> Self {
Self {
limit,
scanned: 0,
truncated: false,
}
}
fn consume(&mut self) -> bool {
if self.scanned >= self.limit {
self.truncated = true;
return false;
}
self.scanned += 1;
self.truncated = self.scanned == self.limit;
true
}
fn truncated(&self) -> bool {
self.truncated
}
}
#[cfg(test)]
type CandidateValidatedHook = Box<dyn FnMut(&Path) -> bool>;
#[cfg(test)]
type CandidateFilterAttemptHook = Box<dyn FnMut(&Path)>;
#[cfg(test)]
type ChildDirectoryOpenedHook = Box<dyn FnMut(&Path)>;
#[cfg(test)]
type IgnoreFileLoadedHook = Box<dyn FnMut(&Path, &[u8])>;
#[cfg(test)]
std::thread_local! {
static AFTER_CANDIDATE_VALIDATED_HOOK: RefCell<Option<CandidateValidatedHook>> =
const { RefCell::new(None) };
static CANDIDATE_FILTER_ATTEMPT_HOOK: RefCell<Option<CandidateFilterAttemptHook>> =
const { RefCell::new(None) };
static CHILD_DIRECTORY_OPENED_HOOK: RefCell<Option<ChildDirectoryOpenedHook>> =
const { RefCell::new(None) };
static IGNORE_FILE_LOADED_HOOK: RefCell<Option<IgnoreFileLoadedHook>> =
const { RefCell::new(None) };
}
#[cfg(not(test))]
fn after_candidate_filter_attempt_hook(_path: &Path) {}
#[cfg(test)]
fn set_candidate_filter_attempt_hook(hook: impl FnMut(&Path) + 'static) {
CANDIDATE_FILTER_ATTEMPT_HOOK.with(|stored| {
*stored.borrow_mut() = Some(Box::new(hook));
});
}
#[cfg(test)]
fn clear_candidate_filter_attempt_hook() {
CANDIDATE_FILTER_ATTEMPT_HOOK.with(|stored| {
*stored.borrow_mut() = None;
});
}
#[cfg(test)]
fn after_candidate_filter_attempt_hook(path: &Path) {
CANDIDATE_FILTER_ATTEMPT_HOOK.with(|stored| {
if let Some(hook) = stored.borrow_mut().as_mut() {
hook(path);
}
});
}
#[cfg(not(test))]
fn after_child_directory_opened_hook(_path: &Path) {}
#[cfg(test)]
fn set_child_directory_opened_hook(hook: impl FnMut(&Path) + 'static) {
CHILD_DIRECTORY_OPENED_HOOK.with(|stored| {
*stored.borrow_mut() = Some(Box::new(hook));
});
}
#[cfg(test)]
fn clear_child_directory_opened_hook() {
CHILD_DIRECTORY_OPENED_HOOK.with(|stored| {
*stored.borrow_mut() = None;
});
}
#[cfg(test)]
fn after_child_directory_opened_hook(path: &Path) {
CHILD_DIRECTORY_OPENED_HOOK.with(|stored| {
if let Some(hook) = stored.borrow_mut().as_mut() {
hook(path);
}
});
}
#[cfg(not(test))]
fn after_ignore_file_loaded_hook(_path: &Path, _contents: &[u8]) {}
#[cfg(test)]
fn set_ignore_file_loaded_hook(hook: impl FnMut(&Path, &[u8]) + 'static) {
IGNORE_FILE_LOADED_HOOK.with(|stored| {
*stored.borrow_mut() = Some(Box::new(hook));
});
}
#[cfg(test)]
fn clear_ignore_file_loaded_hook() {
IGNORE_FILE_LOADED_HOOK.with(|stored| {
*stored.borrow_mut() = None;
});
}
#[cfg(test)]
fn after_ignore_file_loaded_hook(path: &Path, contents: &[u8]) {
IGNORE_FILE_LOADED_HOOK.with(|stored| {
if let Some(hook) = stored.borrow_mut().as_mut() {
hook(path, contents);
}
});
}
#[cfg(test)]
fn set_after_candidate_validated_hook(hook: impl FnMut(&Path) -> bool + 'static) {
AFTER_CANDIDATE_VALIDATED_HOOK.with(|stored| {
*stored.borrow_mut() = Some(Box::new(hook));
});
}
#[cfg(test)]
fn clear_after_candidate_validated_hook() {
AFTER_CANDIDATE_VALIDATED_HOOK.with(|stored| {
*stored.borrow_mut() = None;
});
}
#[cfg(test)]
fn after_candidate_validated_hook(path: &Path) {
AFTER_CANDIDATE_VALIDATED_HOOK.with(|stored| {
let mut stored = stored.borrow_mut();
if stored.as_mut().is_some_and(|hook| hook(path)) {
*stored = None;
}
});
}
fn contains_git_component(path: &Path) -> bool {
path.components()
.any(|component| component.as_os_str() == OsStr::new(".git"))
}
fn relative_display_path(relative: &Path) -> Option<String> {
Some(
relative
.components()
.map(|part| part.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/"),
)
}
fn match_score(name: &str, path: &str, query: &str, is_directory: bool) -> u16 {
if query.is_empty() {
return 1;
}
let name = name.to_lowercase();
let path = path.to_lowercase();
let query = query.to_lowercase();
let base = if name == query {
100
} else if name.starts_with(&query) {
80
} else if name.contains(&query) {
50
} else if path.contains(&query) {
30
} else {
0
};
base + u16::from(is_directory && base > 0) * 10
}
fn file_search_failed(error: impl std::fmt::Display) -> AgentError {
AgentError::new(
ErrorCode::ChatFileSearchFailed,
format!("project file search failed: {error}"),
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::{fs, path::Path};
fn write(root: &Path, path: &str) {
let target = root.join(path);
fs::create_dir_all(target.parent().unwrap()).unwrap();
fs::write(target, b"fixture").unwrap();
}
#[test]
fn bare_query_returns_relative_files_and_directories() {
let root = tempfile::tempdir().unwrap();
write(root.path(), "src/main.rs");
write(root.path(), "README.md");
let result = ProjectFileSearch::default()
.search(root.path(), "")
.unwrap();
assert!(
result
.matches
.iter()
.any(|item| item.path == "src" && item.is_directory)
);
assert!(
result
.matches
.iter()
.any(|item| item.path == "README.md" && !item.is_directory)
);
assert!(
result
.matches
.iter()
.all(|item| !item.path.starts_with('/'))
);
}
#[test]
fn search_respects_gitignore_excludes_git_tree_and_retains_git_control_files() {
let root = tempfile::tempdir().unwrap();
write(root.path(), ".git/config");
write(root.path(), "target/generated.rs");
write(root.path(), "src/lib.rs");
fs::write(root.path().join(".gitignore"), "target/\n").unwrap();
fs::write(root.path().join(".gitmodules"), "[submodule \"example\"]\n").unwrap();
let result = ProjectFileSearch::default()
.search(root.path(), "")
.unwrap();
let paths = result
.matches
.iter()
.map(|item| item.path.as_str())
.collect::<Vec<_>>();
assert!(paths.contains(&"src"));
assert!(paths.contains(&".gitignore"));
assert!(paths.contains(&".gitmodules"));
assert!(
!paths
.iter()
.any(|path| *path == ".git" || path.starts_with(".git/"))
);
assert!(!paths.iter().any(|path| path.starts_with("target")));
}
#[test]
fn gitignore_is_honored_without_a_git_directory() {
let root = tempfile::tempdir().unwrap();
write(root.path(), "ignored.txt");
write(root.path(), "ignored-directory/nested.txt");
write(root.path(), "src/lib.rs");
fs::write(
root.path().join(".gitignore"),
"ignored.txt\nignored-directory/\n",
)
.unwrap();
let result = ProjectFileSearch::default()
.search(root.path(), "")
.unwrap();
let paths = result
.matches
.iter()
.map(|item| item.path.as_str())
.collect::<Vec<_>>();
assert!(paths.contains(&".gitignore"));
assert!(paths.contains(&"src"));
assert!(!paths.iter().any(|path| path.starts_with("ignored")));
}
#[test]
fn nested_gitignore_whitelist_overrides_the_nested_ignore_rule() {
let root = tempfile::tempdir().unwrap();
write(root.path(), "nested/drop.tmp");
write(root.path(), "nested/keep.tmp");
fs::write(root.path().join("nested/.gitignore"), "*.tmp\n!keep.tmp\n").unwrap();
let result = ProjectFileSearch::default()
.search(root.path(), "")
.unwrap();
let paths = result
.matches
.iter()
.map(|item| item.path.as_str())
.collect::<Vec<_>>();
assert!(paths.contains(&"nested/keep.tmp"));
assert!(!paths.contains(&"nested/drop.tmp"));
}
#[test]
fn git_info_exclude_is_applied_without_returning_git_metadata() {
let root = tempfile::tempdir().unwrap();
write(root.path(), ".git/info/exclude");
fs::write(
root.path().join(".git/info/exclude"),
"excluded-by-info.txt\n",
)
.unwrap();
write(root.path(), "excluded-by-info.txt");
let result = ProjectFileSearch::default()
.search(root.path(), "")
.unwrap();
let paths = result
.matches
.iter()
.map(|item| item.path.as_str())
.collect::<Vec<_>>();
assert!(!paths.contains(&"excluded-by-info.txt"));
assert!(!paths.iter().any(|path| path.starts_with(".git/")));
}
#[test]
fn filename_matches_rank_ahead_of_full_path_matches() {
let root = tempfile::tempdir().unwrap();
write(root.path(), "notes/parser-guide.md");
write(root.path(), "src/parser.rs");
write(root.path(), "parser");
let result = ProjectFileSearch::default()
.search(root.path(), "parser")
.unwrap();
assert_eq!(result.matches[0].path, "parser");
assert!(
result
.matches
.iter()
.any(|item| item.path == "src/parser.rs")
);
}
#[test]
fn scan_and_result_limits_mark_results_truncated() {
let root = tempfile::tempdir().unwrap();
write(root.path(), "a.rs");
write(root.path(), "b.rs");
let result = ProjectFileSearch {
scan_limit: 2,
result_limit: 1,
}
.search(root.path(), "")
.unwrap();
assert!(result.truncated);
assert_eq!(result.matches.len(), 1);
}
#[test]
fn zero_scan_limit_returns_no_matches_and_is_truncated() {
let root = tempfile::tempdir().unwrap();
write(root.path(), "visible.rs");
let result = ProjectFileSearch {
scan_limit: 0,
result_limit: 20,
}
.search(root.path(), "")
.unwrap();
assert!(result.matches.is_empty());
assert!(result.truncated);
}
#[test]
fn result_limit_marks_results_truncated_without_hitting_scan_limit() {
let root = tempfile::tempdir().unwrap();
for index in 0..21 {
write(root.path(), &format!("file-{index}.rs"));
}
let result = ProjectFileSearch {
scan_limit: 100,
result_limit: 20,
}
.search(root.path(), "")
.unwrap();
assert_eq!(result.matches.len(), 20);
assert!(result.truncated);
}
#[cfg(unix)]
#[test]
fn rejected_entries_consume_scan_budget_without_returning_matches() {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
write(outside.path(), "secret.txt");
for index in 0..4 {
symlink(outside.path(), root.path().join(format!("escape-{index}"))).unwrap();
}
let result = ProjectFileSearch {
scan_limit: 2,
result_limit: 20,
}
.search(root.path(), "")
.unwrap();
assert!(result.matches.is_empty());
assert!(result.truncated);
}
#[cfg(unix)]
#[test]
fn scan_cap_stops_filter_attempts_after_rejected_candidates() {
use std::{cell::Cell, os::unix::fs::symlink, rc::Rc};
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
write(outside.path(), "secret.txt");
for index in 0..4 {
symlink(outside.path(), root.path().join(format!("escape-{index}"))).unwrap();
}
let attempts = Rc::new(Cell::new(0));
let hook_attempts = Rc::clone(&attempts);
set_candidate_filter_attempt_hook(move |_| {
hook_attempts.set(hook_attempts.get() + 1);
});
let result = ProjectFileSearch {
scan_limit: 2,
result_limit: 20,
}
.search(root.path(), "")
.unwrap();
clear_candidate_filter_attempt_hook();
assert_eq!(attempts.get(), 2);
assert!(result.matches.is_empty());
assert!(result.truncated);
}
#[cfg(unix)]
#[test]
fn escaping_directory_symlink_never_reaches_child_open_hook() {
use std::{cell::Cell, os::unix::fs::symlink, rc::Rc};
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
write(outside.path(), "secret/nested.txt");
symlink(outside.path(), root.path().join("escape")).unwrap();
let opened_escape = Rc::new(Cell::new(false));
let hook_opened_escape = Rc::clone(&opened_escape);
let escape = fs::canonicalize(root.path()).unwrap().join("escape");
set_child_directory_opened_hook(move |path| {
if path == escape {
hook_opened_escape.set(true);
}
});
ProjectFileSearch::default()
.search(root.path(), "")
.unwrap();
clear_child_directory_opened_hook();
assert!(!opened_escape.get());
}
#[test]
fn ignored_entries_consume_raw_scan_budget_before_ignore_matching() {
use std::{cell::Cell, rc::Rc};
let root = tempfile::tempdir().unwrap();
fs::write(root.path().join(".gitignore"), ".gitignore\nignored-*\n").unwrap();
for index in 0..4 {
write(root.path(), &format!("ignored-{index}.txt"));
}
let attempts = Rc::new(Cell::new(0));
let hook_attempts = Rc::clone(&attempts);
set_candidate_filter_attempt_hook(move |_| {
hook_attempts.set(hook_attempts.get() + 1);
});
let result = ProjectFileSearch {
scan_limit: 2,
result_limit: 20,
}
.search(root.path(), "")
.unwrap();
clear_candidate_filter_attempt_hook();
assert_eq!(attempts.get(), 2);
assert!(result.matches.is_empty());
assert!(result.truncated);
}
#[cfg(unix)]
#[test]
fn git_metadata_is_excluded_through_nested_paths_and_symlink_aliases() {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir().unwrap();
write(root.path(), ".git/config");
write(root.path(), "nested/.git/config");
symlink(root.path().join(".git"), root.path().join("git-alias")).unwrap();
let result = ProjectFileSearch::default()
.search(root.path(), "config")
.unwrap();
assert!(result.matches.is_empty());
}
#[test]
fn spaces_unicode_and_nested_paths_are_preserved_with_forward_slashes() {
let root = tempfile::tempdir().unwrap();
write(
root.path(),
"nested folder/\u{0444}\u{0430}\u{0439}\u{043b}.rs",
);
let result = ProjectFileSearch::default()
.search(root.path(), "\u{0444}\u{0430}\u{0439}\u{043b}")
.unwrap();
let item = result
.matches
.iter()
.find(|item| item.path == "nested folder/\u{0444}\u{0430}\u{0439}\u{043b}.rs")
.unwrap();
assert_eq!(item.name, "\u{0444}\u{0430}\u{0439}\u{043b}.rs");
assert!(!item.path.contains('\\'));
}
#[cfg(unix)]
#[test]
fn unreadable_and_disappearing_entries_are_skipped() {
use std::os::unix::fs::{PermissionsExt, symlink};
let root = tempfile::tempdir().unwrap();
let unreadable = root.path().join("unreadable");
fs::create_dir(&unreadable).unwrap();
fs::set_permissions(&unreadable, fs::Permissions::from_mode(0o000)).unwrap();
let vanished = root.path().join("vanished");
symlink(root.path().join("missing"), &vanished).unwrap();
let result = ProjectFileSearch::default()
.search(root.path(), "")
.unwrap();
fs::set_permissions(&unreadable, fs::Permissions::from_mode(0o700)).unwrap();
assert!(!result.matches.iter().any(|item| item.path == "vanished"));
}
#[cfg(unix)]
#[test]
fn symlinks_within_the_workspace_are_searchable() {
use std::{cell::Cell, os::unix::fs::symlink, rc::Rc};
let root = tempfile::tempdir().unwrap();
write(root.path(), "source/inside.rs");
symlink(root.path().join("source"), root.path().join("linked")).unwrap();
let linked_opened = Rc::new(Cell::new(false));
let hook_linked_opened = Rc::clone(&linked_opened);
let linked = fs::canonicalize(root.path()).unwrap().join("linked");
set_child_directory_opened_hook(move |path| {
if path == linked {
hook_linked_opened.set(true);
}
});
let result = ProjectFileSearch::default()
.search(root.path(), "inside")
.unwrap();
clear_child_directory_opened_hook();
assert!(linked_opened.get());
assert!(
result
.matches
.iter()
.any(|item| item.path == "linked/inside.rs")
);
}
#[cfg(unix)]
#[test]
fn retargeted_workspace_symlink_cannot_return_outside_descendants() {
use std::{cell::Cell, os::unix::fs::symlink, rc::Rc};
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
write(root.path(), "inside/inside.txt");
write(outside.path(), "outside-secret.txt");
let linked = root.path().join("linked");
symlink(root.path().join("inside"), &linked).unwrap();
let retargeted = Rc::new(Cell::new(false));
let hook_link = fs::canonicalize(root.path()).unwrap().join("linked");
let outside_target = outside.path().to_path_buf();
let hook_retargeted = Rc::clone(&retargeted);
set_after_candidate_validated_hook(move |path| {
if path != hook_link {
return false;
}
fs::remove_file(&hook_link).unwrap();
symlink(&outside_target, &hook_link).unwrap();
hook_retargeted.set(true);
true
});
let result = ProjectFileSearch::default()
.search(root.path(), "outside-secret")
.unwrap();
clear_after_candidate_validated_hook();
assert!(retargeted.get());
assert!(result.matches.is_empty());
}
#[cfg(unix)]
#[test]
fn retargeted_workspace_symlink_is_not_emitted_itself() {
use std::{cell::Cell, os::unix::fs::symlink, rc::Rc};
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
write(root.path(), "inside/inside.txt");
write(outside.path(), "outside-secret.txt");
let linked = root.path().join("linked");
symlink(root.path().join("inside"), &linked).unwrap();
let retargeted = Rc::new(Cell::new(false));
let hook_link = fs::canonicalize(root.path()).unwrap().join("linked");
let outside_target = outside.path().to_path_buf();
let hook_retargeted = Rc::clone(&retargeted);
set_after_candidate_validated_hook(move |path| {
if path != hook_link {
return false;
}
fs::remove_file(&hook_link).unwrap();
symlink(&outside_target, &hook_link).unwrap();
hook_retargeted.set(true);
true
});
let result = ProjectFileSearch::default()
.search(root.path(), "")
.unwrap();
clear_after_candidate_validated_hook();
assert!(retargeted.get());
assert!(!result.matches.iter().any(|item| item.path == "linked"));
}
#[cfg(unix)]
#[test]
fn nested_ignore_under_retargeted_symlink_uses_the_stable_directory_handle() {
use std::{cell::Cell, os::unix::fs::symlink, rc::Rc};
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
write(root.path(), "inside/visible.txt");
fs::write(root.path().join("inside/.gitignore"), "stable-rule\n").unwrap();
fs::write(outside.path().join(".gitignore"), "outside-rule\n").unwrap();
let linked = root.path().join("linked");
symlink(root.path().join("inside"), &linked).unwrap();
let hook_link = fs::canonicalize(root.path()).unwrap().join("linked");
let outside_target = outside.path().to_path_buf();
set_child_directory_opened_hook(move |path| {
if path == hook_link {
fs::remove_file(&hook_link).unwrap();
symlink(&outside_target, &hook_link).unwrap();
}
});
let stable_contents_were_loaded = Rc::new(Cell::new(false));
let hook_stable_contents_were_loaded = Rc::clone(&stable_contents_were_loaded);
let expected_ignore_path = fs::canonicalize(root.path())
.unwrap()
.join("linked/.gitignore");
set_ignore_file_loaded_hook(move |path, contents| {
if path == expected_ignore_path && contents == b"stable-rule\n" {
hook_stable_contents_were_loaded.set(true);
}
});
ProjectFileSearch::default()
.search(root.path(), "")
.unwrap();
clear_child_directory_opened_hook();
clear_ignore_file_loaded_hook();
assert!(stable_contents_were_loaded.get());
}
#[cfg(unix)]
#[test]
fn symlinks_cannot_escape_the_workspace() {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
write(outside.path(), "secret.txt");
symlink(outside.path(), root.path().join("outside")).unwrap();
let result = ProjectFileSearch::default()
.search(root.path(), "secret")
.unwrap();
assert!(result.matches.is_empty());
}
}