Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions crates/forge_services/src/fd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,19 @@ pub(crate) fn has_allowed_extension(path: &Path) -> bool {
}
}

/// Returns `true` if `path` is a symlink (does not follow the link).
fn is_symlink(path: &Path) -> bool {
path.symlink_metadata()
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
}

/// Filters relative path strings down to those with an allowed extension,
/// resolves each against `dir_path`, and returns them as absolute `PathBuf`s.
///
/// Symlinks are always excluded regardless of their target or extension, so
/// that the sync pipeline only ever processes real files.
///
/// Returns an error when the filtered list is empty, indicating no indexable
/// source files exist in the workspace.
pub(crate) fn filter_and_resolve(
Expand All @@ -42,6 +52,7 @@ pub(crate) fn filter_and_resolve(
let filtered: Vec<PathBuf> = paths
.into_iter()
.map(|p| dir_path.join(&p))
.filter(|p| !is_symlink(p))
.filter(|p| has_allowed_extension(p))
.collect();

Expand Down Expand Up @@ -116,3 +127,82 @@ impl<F: CommandInfra + WalkerInfra + 'static> FileDiscovery for FdDefault<F> {
}
}
}

#[cfg(test)]
mod tests {
use std::fs::{self, File};
use std::io::Write;

use pretty_assertions::assert_eq;
use tempfile::tempdir;

use super::*;

#[test]
fn test_filter_and_resolve_excludes_symlinks() {
let dir = tempdir().unwrap();
let base = dir.path();

// Real file with an allowed extension.
let real_path = base.join("main.rs");
File::create(&real_path)
.unwrap()
.write_all(b"fn main() {}")
.unwrap();

// Symlink pointing to the real file (also carries an allowed extension).
let link_path = base.join("link.rs");
std::os::unix::fs::symlink(&real_path, &link_path).unwrap();

let paths = vec!["main.rs".to_string(), "link.rs".to_string()];
let actual = filter_and_resolve(base, paths).unwrap();

let expected = vec![base.join("main.rs")];
assert_eq!(actual, expected);
}

#[test]
fn test_filter_and_resolve_excludes_dangling_symlinks() {
let dir = tempdir().unwrap();
let base = dir.path();

// Real file with an allowed extension (keeps the result non-empty).
let real_path = base.join("lib.rs");
File::create(&real_path).unwrap().write_all(b"").unwrap();

// Dangling symlink — target does not exist.
let dangling = base.join("missing.rs");
std::os::unix::fs::symlink(base.join("nonexistent.rs"), &dangling).unwrap();

let paths = vec!["lib.rs".to_string(), "missing.rs".to_string()];
let actual = filter_and_resolve(base, paths).unwrap();

let expected = vec![base.join("lib.rs")];
assert_eq!(actual, expected);
}

#[test]
fn test_filter_and_resolve_excludes_symlinks_to_directories() {
let dir = tempdir().unwrap();
let base = dir.path();

// Real file with an allowed extension.
let real_path = base.join("src").join("main.rs");
fs::create_dir_all(real_path.parent().unwrap()).unwrap();
File::create(&real_path).unwrap().write_all(b"").unwrap();

// Symlink to a directory — even if it appears as a file path it should
// be excluded.
let link_dir = base.join("src_link");
std::os::unix::fs::symlink(base.join("src"), &link_dir).unwrap();

let paths = vec!["src/main.rs".to_string(), "src_link".to_string()];
let actual = filter_and_resolve(base, paths).unwrap();

// src_link has no allowed extension so it is dropped by the extension
// filter before symlink detection could be needed, but the real file
// must always be present.
let expected = vec![base.join("src/main.rs")];
assert_eq!(actual, expected);
}
}
65 changes: 65 additions & 0 deletions crates/forge_walker/src/walker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@ impl Walker {
'walk_loop: for entry in walk.flatten() {
let path = entry.path();

// Skip symlinks — we only process real files and directories.
if entry.path_is_symlink() {
continue;
}

// Calculate depth relative to base directory
let depth = path
.strip_prefix(&self.cwd)
Expand Down Expand Up @@ -616,4 +621,64 @@ mod tests {
"should respect nested .gitignore in git repos"
);
}

#[tokio::test]
async fn test_walker_excludes_symlinks() {
let fixture = fixtures::Fixture::default();

// Real file that should appear in results.
fixture.add_file("real.txt", "content").unwrap();

// Symlink pointing to the real file — must be excluded.
let link_path = fixture.as_path().join("link.txt");
std::os::unix::fs::symlink(fixture.as_path().join("real.txt"), &link_path).unwrap();

let actual = Walker::max_all()
.cwd(fixture.as_path().to_path_buf())
.get()
.await
.unwrap();

let actual_files: Vec<_> = actual
.iter()
.filter(|f| !f.is_dir())
.map(|f| f.path.as_str())
.collect();

let expected = vec!["real.txt"];
assert_eq!(
actual_files, expected,
"symlinks should be excluded from walker results"
);
}

#[tokio::test]
async fn test_walker_excludes_dangling_symlinks() {
let fixture = fixtures::Fixture::default();

// Real file that should appear in results.
fixture.add_file("present.txt", "").unwrap();

// Dangling symlink — target does not exist.
let dangling = fixture.as_path().join("dangling.txt");
std::os::unix::fs::symlink(fixture.as_path().join("ghost.txt"), &dangling).unwrap();

let actual = Walker::max_all()
.cwd(fixture.as_path().to_path_buf())
.get()
.await
.unwrap();

let actual_files: Vec<_> = actual
.iter()
.filter(|f| !f.is_dir())
.map(|f| f.path.as_str())
.collect();

let expected = vec!["present.txt"];
assert_eq!(
actual_files, expected,
"dangling symlinks should be excluded from walker results"
);
}
}
Loading