use deps_core::{Ecosystem, EcosystemRegistry};
use ignore::WalkBuilder;
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
pub const MAX_WALKED_FILES: usize = 50_000;
const PRUNED_DIRECTORIES: &[&str] = &[
".git",
".hg",
".svn",
".bzr",
"node_modules",
"bower_components",
"target",
"vendor",
".venv",
"venv",
"__pycache__",
".tox",
".mypy_cache",
".pytest_cache",
".ruff_cache",
".bundle",
".dart_tool",
".gradle",
".build",
"Pods",
"DerivedData",
"dist",
"build",
];
fn is_not_pruned_directory(entry: &ignore::DirEntry) -> bool {
entry.depth() == 0
|| !entry
.file_type()
.is_some_and(|file_type| file_type.is_dir())
|| !entry
.file_name()
.to_str()
.is_some_and(|name| PRUNED_DIRECTORIES.contains(&name))
}
fn is_not_escaping_directory(
entry: &ignore::DirEntry,
follow_symlinks: bool,
canonical_root: Option<&Path>,
) -> bool {
if !follow_symlinks || entry.depth() == 0 {
return true;
}
if !entry
.file_type()
.is_some_and(|file_type| file_type.is_dir())
{
return true;
}
let Some(canonical_root) = canonical_root else {
return false;
};
let Ok(canonical_path) = std::fs::canonicalize(entry.path()) else {
return false;
};
canonical_path.starts_with(canonical_root)
}
pub struct DiscoveredManifest {
pub path: PathBuf,
pub uri_path: PathBuf,
pub display_path: PathBuf,
pub ecosystem: Arc<dyn Ecosystem>,
}
pub use outcome::WalkOutcome;
mod outcome {
use super::DiscoveredManifest;
use std::path::PathBuf;
#[derive(Default)]
pub struct WalkOutcome {
manifests: Vec<DiscoveredManifest>,
walk_errors: Vec<String>,
pub truncated: bool,
unrecognized_explicit_paths: Vec<PathBuf>,
ignored_manifests: Vec<PathBuf>,
broken_manifest_symlinks: Vec<PathBuf>,
}
impl WalkOutcome {
#[must_use]
pub fn manifests(&self) -> &[DiscoveredManifest] {
&self.manifests
}
#[must_use]
pub fn walk_errors(&self) -> &[String] {
&self.walk_errors
}
#[must_use]
pub fn unrecognized_explicit_paths(&self) -> &[PathBuf] {
&self.unrecognized_explicit_paths
}
#[must_use]
pub fn ignored_manifests(&self) -> &[PathBuf] {
&self.ignored_manifests
}
#[must_use]
pub fn broken_manifest_symlinks(&self) -> &[PathBuf] {
&self.broken_manifest_symlinks
}
pub(super) fn push_manifest(&mut self, manifest: DiscoveredManifest) {
self.manifests.push(manifest);
}
pub(super) fn push_walk_error(&mut self, error: String) {
self.walk_errors
.push(crate::sanitize::sanitize_message_for_display(&error));
}
pub(super) fn push_unrecognized_explicit_path(&mut self, path: PathBuf) {
self.unrecognized_explicit_paths
.push(crate::sanitize::sanitize_path_for_display(&path));
}
pub(super) fn push_ignored_manifest(&mut self, path: PathBuf) {
self.ignored_manifests
.push(crate::sanitize::sanitize_path_for_display(&path));
}
pub(super) fn push_broken_manifest_symlink(&mut self, path: PathBuf) {
self.broken_manifest_symlinks
.push(crate::sanitize::sanitize_path_for_display(&path));
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GitignorePolicy {
Respect,
Ignore,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SymlinkPolicy {
Follow,
Skip,
}
#[must_use]
pub fn walk(
roots: &[PathBuf],
registry: &EcosystemRegistry,
gitignore_policy: GitignorePolicy,
symlink_policy: SymlinkPolicy,
) -> WalkOutcome {
walk_with_limit(
roots,
registry,
MAX_WALKED_FILES,
gitignore_policy,
symlink_policy,
)
}
fn walk_with_limit(
roots: &[PathBuf],
registry: &EcosystemRegistry,
limit: usize,
gitignore_policy: GitignorePolicy,
symlink_policy: SymlinkPolicy,
) -> WalkOutcome {
let mut ctx = WalkCtx {
registry,
limit,
entries_walked: 0,
outcome: WalkOutcome::default(),
};
let hidden_ecosystem_dirs = hidden_ecosystem_directories(registry);
let options = WalkOptions {
respect_gitignore: matches!(gitignore_policy, GitignorePolicy::Respect),
follow_symlinks: matches!(symlink_policy, SymlinkPolicy::Follow),
};
'roots: for root in roots {
if ctx.outcome.truncated {
break;
}
let Ok(absolute_root) = std::path::absolute(root) else {
ctx.outcome
.push_walk_error(format!("could not resolve path: {}", root.display()));
continue;
};
if root.is_file() {
if ctx.entries_walked >= ctx.limit {
ctx.outcome.truncated = true;
tracing::warn!(
limit,
"walk truncated: reached the maximum number of entries per run"
);
break;
}
ctx.entries_walked += 1;
let matched_before = ctx.outcome.manifests().len();
route_file(
&absolute_root,
&absolute_root,
root,
registry,
&mut ctx.outcome,
);
if ctx.outcome.manifests().len() == matched_before {
ctx.outcome.push_unrecognized_explicit_path(root.clone());
}
continue;
}
if is_symlink(root) {
let sink: Option<fn(&mut WalkOutcome, PathBuf)> =
match classify_symlink(&absolute_root, registry) {
SymlinkClassification::Broken => {
Some(WalkOutcome::push_broken_manifest_symlink)
}
SymlinkClassification::Irrelevant if std::fs::metadata(root).is_err() => {
Some(WalkOutcome::push_unrecognized_explicit_path)
}
SymlinkClassification::Resolvable | SymlinkClassification::Irrelevant => None,
};
if let Some(push) = sink {
if ctx.entries_walked >= ctx.limit {
ctx.outcome.truncated = true;
tracing::warn!(
limit,
"walk truncated: reached the maximum number of entries per run"
);
break;
}
ctx.entries_walked += 1;
push(&mut ctx.outcome, root.clone());
continue;
}
}
let canonical_root = std::fs::canonicalize(&absolute_root).ok();
if !walk_directory(
&absolute_root,
&absolute_root,
DotDirs::Skip,
options,
canonical_root.as_deref(),
&mut ctx,
) {
break 'roots;
}
for dir_name in &hidden_ecosystem_dirs {
let sub_root = absolute_root.join(dir_name);
if !sub_root.is_dir() {
continue;
}
match (
canonical_root.as_deref(),
std::fs::canonicalize(&sub_root).ok(),
) {
(Some(canonical_root), Some(canonical_sub_root))
if canonical_sub_root.starts_with(canonical_root) => {}
_ => continue,
}
if !walk_directory(
&sub_root,
&absolute_root,
DotDirs::Descend,
options,
canonical_root.as_deref(),
&mut ctx,
) {
break 'roots;
}
}
}
ctx.outcome
}
struct WalkCtx<'a> {
registry: &'a EcosystemRegistry,
limit: usize,
entries_walked: usize,
outcome: WalkOutcome,
}
#[derive(Clone, Copy)]
struct WalkOptions {
respect_gitignore: bool,
follow_symlinks: bool,
}
#[derive(Clone, Copy)]
enum DotDirs {
Skip,
Descend,
}
impl DotDirs {
fn skip_hidden(self) -> bool {
matches!(self, Self::Skip)
}
}
fn walk_directory(
walk_root: &Path,
display_root: &Path,
dot_dirs: DotDirs,
options: WalkOptions,
canonical_root: Option<&Path>,
ctx: &mut WalkCtx<'_>,
) -> bool {
let hidden = dot_dirs.skip_hidden();
let pruned_dirs: Arc<Mutex<Vec<PathBuf>>> = Arc::new(Mutex::new(Vec::new()));
let mut builder = WalkBuilder::new(walk_root);
builder
.hidden(hidden)
.parents(true)
.ignore(options.respect_gitignore)
.git_ignore(options.respect_gitignore)
.git_global(true)
.git_exclude(true)
.follow_links(options.follow_symlinks);
{
let pruned_dirs = Arc::clone(&pruned_dirs);
let canonical_root_owned = canonical_root.map(Path::to_path_buf);
let follow_symlinks = options.follow_symlinks;
builder.filter_entry(move |entry| {
if !is_not_pruned_directory(entry) {
pruned_dirs
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(entry.path().to_path_buf());
return false;
}
is_not_escaping_directory(entry, follow_symlinks, canonical_root_owned.as_deref())
});
}
let mut visited: BTreeSet<PathBuf> = BTreeSet::new();
for entry in builder.build() {
if ctx.entries_walked >= ctx.limit {
ctx.outcome.truncated = true;
tracing::warn!(
limit = ctx.limit,
"walk truncated: reached the maximum number of entries per run"
);
return false;
}
ctx.entries_walked += 1;
match entry {
Ok(entry) if entry.file_type().is_some_and(|t| t.is_file()) => {
let path = entry.path();
let display = display_relative_path(path, display_root);
if options.respect_gitignore {
visited.insert(display.clone());
}
if options.follow_symlinks {
match canonicalize_within_root(path, canonical_root) {
Some(canonical_path) => {
route_file(
path,
&canonical_path,
&display,
ctx.registry,
&mut ctx.outcome,
);
}
None => {
classify_symlink(path, ctx.registry).record(&mut ctx.outcome, display);
}
}
} else {
route_file(path, path, &display, ctx.registry, &mut ctx.outcome);
}
}
Ok(entry) => {
if entry.path_is_symlink() {
let path = entry.path();
let classification = classify_symlink(path, ctx.registry);
if !matches!(classification, SymlinkClassification::Irrelevant) {
let display = display_relative_path(path, display_root);
if options.respect_gitignore {
visited.insert(display.clone());
}
classification.record(&mut ctx.outcome, display);
}
}
}
Err(error) => {
let classified_as_broken = if let ignore::Error::WithPath { path, err } = &error
&& err.is_io()
&& is_symlink(path)
&& is_manifest_shaped_by_name(path, ctx.registry)
{
let display = display_relative_path(path, display_root);
if options.respect_gitignore {
visited.insert(display.clone());
}
ctx.outcome.push_broken_manifest_symlink(display);
true
} else {
false
};
if !classified_as_broken {
ctx.outcome.push_walk_error(error.to_string());
}
}
}
}
for pruned_dir in pruned_dirs
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.iter()
{
warn_on_pruned_directory_manifest(pruned_dir, display_root, ctx);
}
if options.respect_gitignore {
detect_ignored_manifests(walk_root, display_root, hidden, ctx, &visited);
}
true
}
fn warn_on_pruned_directory_manifest(
pruned_dir: &Path,
display_root: &Path,
ctx: &mut WalkCtx<'_>,
) {
let Ok(read_dir) = std::fs::read_dir(pruned_dir) else {
return;
};
for entry in read_dir.flatten() {
let path = entry.path();
let display = display_relative_path(&path, display_root);
classify_symlink(&path, ctx.registry).record(&mut ctx.outcome, display);
}
}
fn detect_ignored_manifests(
walk_root: &Path,
display_root: &Path,
hidden: bool,
ctx: &mut WalkCtx<'_>,
visited: &BTreeSet<PathBuf>,
) {
let mut builder = WalkBuilder::new(walk_root);
builder
.hidden(hidden)
.ignore(false)
.git_ignore(false)
.git_global(true)
.git_exclude(true)
.filter_entry(is_not_pruned_directory);
for (detection_entries, entry) in builder.build().enumerate() {
if detection_entries >= ctx.limit {
tracing::warn!(
limit = ctx.limit,
"ignored-manifest detection walk truncated: reached the maximum number of \
entries per run; some .gitignore/.ignore exclusions may go unreported"
);
return;
}
let entry = match entry {
Ok(entry) => entry,
Err(error) => {
ctx.outcome.push_walk_error(error.to_string());
continue;
}
};
if !entry.file_type().is_some_and(|t| t.is_file()) {
let path = entry.path();
if entry.path_is_symlink() {
let display = display_relative_path(path, display_root);
if !visited.contains(&display) {
classify_symlink(path, ctx.registry).record(&mut ctx.outcome, display);
}
}
continue;
}
let path = entry.path();
let display = display_relative_path(path, display_root);
if visited.contains(&display) {
continue;
}
match url::Url::from_file_path(path) {
Ok(uri) => {
if ctx.registry.for_uri(&uri).is_some() {
ctx.outcome.push_ignored_manifest(display);
}
}
Err(()) => {
ctx.outcome.push_walk_error(format!(
"could not convert to a file URI while checking for ignore-suppressed \
manifests, skipping: {}",
display.display()
));
}
}
}
}
fn hidden_ecosystem_directories(registry: &EcosystemRegistry) -> BTreeSet<String> {
let mut dirs = BTreeSet::new();
for id in registry.ecosystem_ids() {
let Some(ecosystem) = registry.get(id) else {
continue;
};
for (dir_pattern, _suffix) in ecosystem.manifest_directory_patterns() {
if let Some(first) = dir_pattern.split('/').next()
&& first.starts_with('.')
{
dirs.insert(first.to_string());
}
}
}
dirs
}
fn canonicalize_within_root(path: &Path, canonical_root: Option<&Path>) -> Option<PathBuf> {
let canonical_root = canonical_root?;
let canonical_path = std::fs::canonicalize(path).ok()?;
canonical_path
.starts_with(canonical_root)
.then_some(canonical_path)
}
enum SymlinkClassification {
Resolvable,
Broken,
Irrelevant,
}
impl SymlinkClassification {
fn record(self, outcome: &mut WalkOutcome, display: PathBuf) {
match self {
Self::Resolvable => outcome.push_ignored_manifest(display),
Self::Broken => outcome.push_broken_manifest_symlink(display),
Self::Irrelevant => {}
}
}
}
fn classify_symlink(path: &Path, registry: &EcosystemRegistry) -> SymlinkClassification {
if !is_manifest_shaped_by_name(path, registry) {
return SymlinkClassification::Irrelevant;
}
match std::fs::metadata(path) {
Ok(metadata) if metadata.is_file() => SymlinkClassification::Resolvable,
_ if is_symlink(path) => SymlinkClassification::Broken,
_ => SymlinkClassification::Irrelevant,
}
}
fn is_symlink(path: &Path) -> bool {
std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink())
}
fn display_relative_path(path: &Path, display_root: &Path) -> PathBuf {
let stripped = path.strip_prefix(display_root).unwrap_or(path);
if stripped.as_os_str().is_empty() {
path.to_path_buf()
} else {
stripped.to_path_buf()
}
}
fn is_manifest_shaped_by_name(path: &Path, registry: &EcosystemRegistry) -> bool {
let Ok(uri) = url::Url::from_file_path(path) else {
return false;
};
registry.for_uri(&uri).is_some()
}
fn route_file(
route_path: &Path,
read_path: &Path,
display_path: &Path,
registry: &EcosystemRegistry,
outcome: &mut WalkOutcome,
) {
let display_path = crate::sanitize::sanitize_path_for_display(display_path);
let Ok(uri) = url::Url::from_file_path(route_path) else {
outcome.push_walk_error(format!(
"could not convert to a file URI, skipping: {}",
display_path.display()
));
return;
};
if let Some(ecosystem) = registry.for_uri(&uri) {
outcome.push_manifest(DiscoveredManifest {
path: read_path.to_path_buf(),
uri_path: route_path.to_path_buf(),
display_path,
ecosystem,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::sync::Mutex;
static CWD_LOCK: Mutex<()> = Mutex::new(());
struct CwdGuard {
original: PathBuf,
_lock: std::sync::MutexGuard<'static, ()>,
}
impl CwdGuard {
fn chdir(dir: &Path) -> Self {
let lock = CWD_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let original = std::env::current_dir().expect("read cwd");
std::env::set_current_dir(dir).expect("chdir");
Self {
original,
_lock: lock,
}
}
}
impl Drop for CwdGuard {
fn drop(&mut self) {
let _ = std::env::set_current_dir(&self.original);
}
}
fn test_registry() -> EcosystemRegistry {
let registry = EcosystemRegistry::new();
let runtime = deps_engine::setup::EcosystemRuntime::from_policy(
&deps_core::policy_config::PolicyConfig::default(),
);
deps_engine::setup::register_ecosystems(
®istry,
Arc::new(deps_core::HttpCache::new()),
&runtime,
);
registry
}
#[test]
fn test_walk_empty_directory_finds_nothing() {
let dir = tempfile::tempdir().expect("create temp dir");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert!(outcome.manifests().is_empty());
assert!(!outcome.truncated);
}
#[test]
fn test_walk_finds_cargo_toml() {
let dir = tempfile::tempdir().expect("create temp dir");
fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n")
.expect("write manifest");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert_eq!(outcome.manifests().len(), 1);
assert_eq!(
outcome.manifests()[0].display_path,
PathBuf::from("Cargo.toml")
);
assert_eq!(outcome.manifests()[0].ecosystem.id(), "cargo");
}
#[test]
fn test_walk_respect_gitignore_true_skips_gitignored_manifest() {
let dir = tempfile::tempdir().expect("create temp dir");
fs::create_dir(dir.path().join(".git")).expect("create .git marker");
fs::write(dir.path().join(".gitignore"), "ignored/\n").expect("write gitignore");
fs::create_dir(dir.path().join("ignored")).expect("mkdir");
fs::write(dir.path().join("ignored").join("Cargo.toml"), "[package]\n")
.expect("write manifest");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Respect,
SymlinkPolicy::Skip,
);
assert!(outcome.manifests().is_empty());
assert_eq!(
outcome.ignored_manifests(),
vec![PathBuf::from("ignored").join("Cargo.toml")]
);
}
#[test]
fn test_walk_default_does_not_respect_gitignore() {
let dir = tempfile::tempdir().expect("create temp dir");
fs::create_dir(dir.path().join(".git")).expect("create .git marker");
fs::write(dir.path().join(".gitignore"), "Cargo.toml\n").expect("write gitignore");
fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert_eq!(outcome.manifests().len(), 1);
assert!(outcome.ignored_manifests().is_empty());
}
#[test]
fn test_walk_default_ignores_nested_gitignore() {
let dir = tempfile::tempdir().expect("create temp dir");
fs::create_dir(dir.path().join(".git")).expect("create .git marker");
fs::create_dir(dir.path().join("sub")).expect("mkdir sub");
fs::write(dir.path().join("sub").join(".gitignore"), "Cargo.toml\n")
.expect("write nested gitignore");
fs::write(dir.path().join("sub").join("Cargo.toml"), "[package]\n")
.expect("write manifest");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert_eq!(outcome.manifests().len(), 1);
}
#[test]
fn test_walk_default_ignores_dot_ignore_file_without_git_repo() {
let dir = tempfile::tempdir().expect("create temp dir");
fs::write(dir.path().join(".ignore"), "Cargo.toml\n").expect("write .ignore");
fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert_eq!(outcome.manifests().len(), 1);
}
#[test]
fn test_walk_default_prunes_node_modules() {
let dir = tempfile::tempdir().expect("create temp dir");
fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
fs::create_dir_all(dir.path().join("node_modules").join("left-pad"))
.expect("mkdir node_modules/left-pad");
fs::write(
dir.path()
.join("node_modules")
.join("left-pad")
.join("package.json"),
"{}",
)
.expect("write vendored manifest");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert_eq!(outcome.manifests().len(), 1);
assert_eq!(
outcome.manifests()[0].display_path,
PathBuf::from("Cargo.toml")
);
}
#[test]
fn test_walk_default_warns_on_manifest_directly_inside_pruned_directory() {
let dir = tempfile::tempdir().expect("create temp dir");
fs::create_dir(dir.path().join("vendor")).expect("mkdir vendor");
fs::write(dir.path().join("vendor").join("Cargo.toml"), "[package]\n")
.expect("write manifest directly under pruned dir");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert!(
outcome.manifests().is_empty(),
"still pruned from the primary scan"
);
assert_eq!(
outcome.ignored_manifests(),
vec![PathBuf::from("vendor").join("Cargo.toml")]
);
}
#[test]
fn test_walk_sanitizes_bidi_and_ansi_in_a_walked_directory_name() {
let payload_name = if cfg!(windows) {
"ev\u{202E}il"
} else {
"ev\u{202E}il\x1B[31m"
};
let dir = tempfile::tempdir().expect("create temp dir");
let payload_dir = dir.path().join(payload_name);
fs::create_dir(&payload_dir).expect("mkdir payload dir");
fs::create_dir(payload_dir.join("vendor")).expect("mkdir vendor");
fs::write(payload_dir.join("vendor").join("Cargo.toml"), "[package]\n")
.expect("write manifest directly under pruned dir");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert_eq!(outcome.ignored_manifests().len(), 1);
let reported = outcome.ignored_manifests()[0]
.to_string_lossy()
.into_owned();
assert!(
!reported.contains('\u{202E}'),
"bidi override survived the walk: {reported:?}"
);
if !cfg!(windows) {
assert!(
!reported.contains('\x1B'),
"raw ANSI escape byte survived the walk: {reported:?}"
);
}
assert!(reported.contains("vendor"), "legitimate path info lost");
assert!(reported.contains("Cargo.toml"), "legitimate path info lost");
}
#[test]
fn test_walk_manifest_nested_two_levels_inside_pruned_directory_remains_unreported() {
let dir = tempfile::tempdir().expect("create temp dir");
fs::create_dir_all(dir.path().join("vendor").join("sub")).expect("mkdir vendor/sub");
fs::write(
dir.path().join("vendor").join("sub").join("Cargo.toml"),
"[package]\n",
)
.expect("write nested manifest");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert!(outcome.manifests().is_empty());
assert!(outcome.ignored_manifests().is_empty());
}
#[test]
fn test_detect_ignored_manifests_uses_its_own_budget_and_does_not_set_truncated() {
let dir = tempfile::tempdir().expect("create temp dir");
fs::create_dir(dir.path().join(".git")).expect("create .git marker");
fs::write(dir.path().join(".gitignore"), "noise/\n").expect("write gitignore");
fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
fs::create_dir(dir.path().join("noise")).expect("mkdir noise");
for i in 0..20 {
fs::write(dir.path().join("noise").join(format!("f{i}.txt")), "")
.expect("write noise file");
}
let outcome = walk_with_limit(
&[dir.path().to_path_buf()],
&test_registry(),
5,
GitignorePolicy::Respect,
SymlinkPolicy::Skip,
);
assert!(
!outcome.truncated,
"the diagnostic pass' own budget exhaustion must not mark the primary walk truncated"
);
assert_eq!(
outcome.manifests().len(),
1,
"primary walk result must still be complete"
);
}
#[test]
fn test_walk_respect_gitignore_does_not_warn_on_pruned_directory() {
let dir = tempfile::tempdir().expect("create temp dir");
fs::create_dir(dir.path().join(".git")).expect("create .git marker");
fs::write(dir.path().join(".gitignore"), "node_modules/\n").expect("write gitignore");
fs::create_dir_all(dir.path().join("node_modules").join("left-pad"))
.expect("mkdir node_modules/left-pad");
fs::write(
dir.path()
.join("node_modules")
.join("left-pad")
.join("package.json"),
"{}",
)
.expect("write vendored manifest");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Respect,
SymlinkPolicy::Skip,
);
assert!(outcome.ignored_manifests().is_empty());
}
#[test]
fn test_walk_git_info_exclude_suppression_not_misreported_as_ignored_manifest() {
let dir = tempfile::tempdir().expect("create temp dir");
fs::create_dir_all(dir.path().join(".git").join("info")).expect("mkdir .git/info");
fs::write(
dir.path().join(".git").join("info").join("exclude"),
"Cargo.toml\n",
)
.expect("write git info/exclude");
fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Respect,
SymlinkPolicy::Skip,
);
assert!(outcome.manifests().is_empty());
assert!(
outcome.ignored_manifests().is_empty(),
".git/info/exclude is operator-controlled, not a .gitignore/.ignore rule"
);
}
#[test]
fn test_walk_single_file_path_bypasses_gitignore() {
let dir = tempfile::tempdir().expect("create temp dir");
fs::write(dir.path().join(".gitignore"), "Cargo.toml\n").expect("write gitignore");
let manifest = dir.path().join("Cargo.toml");
fs::write(&manifest, "[package]\n").expect("write manifest");
let outcome = walk(
&[manifest],
&test_registry(),
GitignorePolicy::Respect,
SymlinkPolicy::Skip,
);
assert_eq!(outcome.manifests().len(), 1);
}
#[cfg(unix)]
#[test]
fn test_walk_explicit_broken_symlink_manifest_path_reports_the_given_path() {
let dir = tempfile::tempdir().expect("create temp dir");
let manifest = dir.path().join("Cargo.toml");
std::os::unix::fs::symlink(dir.path().join("does-not-exist"), &manifest)
.expect("create broken symlink");
let outcome = walk(
std::slice::from_ref(&manifest),
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert!(outcome.manifests().is_empty());
assert!(outcome.ignored_manifests().is_empty());
assert!(outcome.unrecognized_explicit_paths().is_empty());
assert_eq!(outcome.broken_manifest_symlinks(), vec![manifest]);
}
#[cfg(unix)]
#[test]
fn test_walk_explicit_symlink_to_directory_root_is_still_walked() {
let real_dir = tempfile::tempdir().expect("create real dir");
fs::write(real_dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
let link_parent = tempfile::tempdir().expect("create link parent");
let link = link_parent.path().join("link-to-real");
std::os::unix::fs::symlink(real_dir.path(), &link)
.expect("create symlink to a real directory");
let outcome = walk(
std::slice::from_ref(&link),
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert_eq!(outcome.manifests().len(), 1);
assert!(outcome.broken_manifest_symlinks().is_empty());
}
#[cfg(unix)]
#[test]
fn test_walk_explicit_manifest_shaped_symlink_to_directory_root_reports_a_non_empty_path() {
let real_dir = tempfile::tempdir().expect("create real dir");
fs::write(real_dir.path().join("package.json"), "{}").expect("write real manifest");
let link_parent = tempfile::tempdir().expect("create link parent");
let link = link_parent.path().join("Cargo.toml");
std::os::unix::fs::symlink(real_dir.path(), &link)
.expect("create manifest-shaped symlink to a real directory");
let outcome = walk(
std::slice::from_ref(&link),
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert!(
outcome.manifests().is_empty(),
"must not also walk the target directory's contents: {:?}",
outcome
.manifests()
.iter()
.map(|m| &m.display_path)
.collect::<Vec<_>>()
);
assert!(outcome.ignored_manifests().is_empty());
assert_eq!(outcome.broken_manifest_symlinks().len(), 1);
assert!(
!outcome.broken_manifest_symlinks()[0].as_os_str().is_empty(),
"must never report an empty display path"
);
assert_eq!(
outcome.broken_manifest_symlinks()[0].file_name(),
Some(std::ffi::OsStr::new("Cargo.toml")),
"reported path must still name the manifest: {:?}",
outcome.broken_manifest_symlinks()
);
}
#[cfg(unix)]
#[test]
fn test_walk_explicit_broken_symlink_non_manifest_path_is_unrecognized() {
let dir = tempfile::tempdir().expect("create temp dir");
let notes = dir.path().join("notes.txt");
std::os::unix::fs::symlink(dir.path().join("does-not-exist"), ¬es)
.expect("create broken, non-manifest-shaped symlink");
let outcome = walk(
std::slice::from_ref(¬es),
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert!(outcome.broken_manifest_symlinks().is_empty());
assert_eq!(outcome.unrecognized_explicit_paths(), vec![notes]);
}
#[test]
fn test_walk_relative_root_finds_manifest() {
let dir = tempfile::tempdir().expect("create temp dir");
fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
let _guard = CwdGuard::chdir(dir.path());
let outcome = walk(
&[PathBuf::from(".")],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert_eq!(outcome.manifests().len(), 1);
assert!(outcome.walk_errors().is_empty());
assert_eq!(
outcome.manifests()[0].display_path,
PathBuf::from("Cargo.toml")
);
}
#[test]
fn test_walk_relative_explicit_file_path_is_found() {
let dir = tempfile::tempdir().expect("create temp dir");
fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
let _guard = CwdGuard::chdir(dir.path());
let outcome = walk(
&[PathBuf::from("Cargo.toml")],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert_eq!(outcome.manifests().len(), 1);
assert!(outcome.unrecognized_explicit_paths().is_empty());
}
#[test]
fn test_walk_with_limit_truncates_on_walked_entries_not_just_manifests() {
let dir = tempfile::tempdir().expect("create temp dir");
for i in 0..5 {
fs::write(dir.path().join(format!("noise-{i}.txt")), "").expect("write noise file");
}
fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
let outcome = walk_with_limit(
&[dir.path().to_path_buf()],
&test_registry(),
2,
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert!(
outcome.truncated,
"a 2-entry limit against a 6-entry tree must truncate"
);
}
#[test]
fn test_walk_with_limit_does_not_truncate_when_under_the_cap() {
let dir = tempfile::tempdir().expect("create temp dir");
fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
let outcome = walk_with_limit(
&[dir.path().to_path_buf()],
&test_registry(),
100,
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert!(!outcome.truncated);
assert_eq!(outcome.manifests().len(), 1);
}
#[test]
fn test_walk_explicit_unrecognized_path_is_reported() {
let dir = tempfile::tempdir().expect("create temp dir");
let unknown = dir.path().join("notes.txt");
fs::write(&unknown, "not a manifest").expect("write file");
let outcome = walk(
std::slice::from_ref(&unknown),
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert!(outcome.manifests().is_empty());
assert_eq!(outcome.unrecognized_explicit_paths(), vec![unknown]);
}
#[test]
fn test_walk_unrecognized_file_found_during_directory_walk_is_not_reported() {
let dir = tempfile::tempdir().expect("create temp dir");
fs::write(dir.path().join("notes.txt"), "not a manifest").expect("write file");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert!(outcome.unrecognized_explicit_paths().is_empty());
}
#[test]
fn test_walk_multiple_ecosystems_in_one_tree() {
let dir = tempfile::tempdir().expect("create temp dir");
fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write cargo manifest");
fs::write(dir.path().join("package.json"), "{}").expect("write npm manifest");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert_eq!(outcome.manifests().len(), 2);
}
#[test]
fn test_walk_never_descends_into_dot_git() {
let dir = tempfile::tempdir().expect("create temp dir");
fs::create_dir(dir.path().join(".git")).expect("create .git marker");
for i in 0..100 {
fs::write(dir.path().join(".git").join(format!("object-{i}")), "")
.expect("write dummy git-internal file");
}
fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
let outcome = walk_with_limit(
&[dir.path().to_path_buf()],
&test_registry(),
3,
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert!(
!outcome.truncated,
".git's 100 dummy files must never be walked, so a limit of 3 must suffice"
);
assert_eq!(outcome.manifests().len(), 1);
assert_eq!(
outcome.manifests()[0].display_path,
PathBuf::from("Cargo.toml")
);
}
#[test]
fn test_walk_still_finds_github_workflows_alongside_excluded_dot_git() {
let dir = tempfile::tempdir().expect("create temp dir");
fs::create_dir(dir.path().join(".git")).expect("create .git marker");
fs::create_dir_all(dir.path().join(".github").join("workflows")).expect("mkdir");
fs::write(
dir.path().join(".github").join("workflows").join("ci.yml"),
"on: push\njobs:\n x:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n",
)
.expect("write workflow");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert_eq!(outcome.manifests().len(), 1);
assert_eq!(
outcome.manifests()[0].display_path,
PathBuf::from(".github").join("workflows").join("ci.yml")
);
assert_eq!(outcome.manifests()[0].ecosystem.id(), "github-actions");
}
#[test]
fn test_walk_descends_into_nested_dot_dir_under_github_sub_root() {
let dir = tempfile::tempdir().expect("create temp dir");
fs::create_dir_all(dir.path().join(".github").join(".hidden")).expect("mkdir");
fs::write(
dir.path()
.join(".github")
.join(".hidden")
.join("Cargo.toml"),
"[package]\n",
)
.expect("write nested manifest");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert_eq!(outcome.manifests().len(), 1);
assert_eq!(
outcome.manifests()[0].display_path,
PathBuf::from(".github").join(".hidden").join("Cargo.toml")
);
}
#[test]
fn test_walk_with_limit_truncates_on_explicit_path_list() {
let dir = tempfile::tempdir().expect("create temp dir");
let paths: Vec<PathBuf> = (0..5)
.map(|i| {
let subdir = dir.path().join(format!("pkg{i}"));
fs::create_dir(&subdir).expect("mkdir");
let path = subdir.join("Cargo.toml");
fs::write(&path, "[package]\n").expect("write manifest");
path
})
.collect();
let outcome = walk_with_limit(
&paths,
&test_registry(),
2,
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert!(
outcome.truncated,
"a 2-entry limit against 5 explicit paths must truncate"
);
assert_eq!(outcome.manifests().len(), 2);
}
#[cfg(unix)]
#[test]
fn test_walk_default_detects_symlinked_manifest_without_following() {
let dir = tempfile::tempdir().expect("create temp dir");
let real = dir.path().join("real").join("manifest-data");
fs::create_dir(dir.path().join("real")).expect("mkdir real");
fs::write(&real, "[package]\n").expect("write real manifest");
std::os::unix::fs::symlink(&real, dir.path().join("Cargo.toml")).expect("create symlink");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert!(outcome.manifests().is_empty());
assert_eq!(
outcome.ignored_manifests(),
vec![PathBuf::from("Cargo.toml")]
);
}
#[cfg(unix)]
#[test]
fn test_walk_default_detects_broken_symlinked_manifest() {
let dir = tempfile::tempdir().expect("create temp dir");
std::os::unix::fs::symlink(
dir.path().join("does-not-exist"),
dir.path().join("Cargo.toml"),
)
.expect("create broken symlink");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert!(outcome.manifests().is_empty());
assert!(outcome.ignored_manifests().is_empty());
assert_eq!(
outcome.broken_manifest_symlinks(),
vec![PathBuf::from("Cargo.toml")]
);
}
#[cfg(unix)]
#[test]
fn test_walk_broken_symlink_not_manifest_shaped_is_not_reported() {
let dir = tempfile::tempdir().expect("create temp dir");
std::os::unix::fs::symlink(
dir.path().join("does-not-exist"),
dir.path().join("notes.txt"),
)
.expect("create broken, non-manifest-shaped symlink");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert!(outcome.manifests().is_empty());
assert!(outcome.ignored_manifests().is_empty());
assert!(outcome.broken_manifest_symlinks().is_empty());
}
#[cfg(unix)]
#[test]
fn test_walk_follow_symlinks_still_detects_broken_symlinked_manifest() {
let dir = tempfile::tempdir().expect("create temp dir");
std::os::unix::fs::symlink(
dir.path().join("does-not-exist"),
dir.path().join("Cargo.toml"),
)
.expect("create broken symlink");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Follow,
);
assert!(outcome.manifests().is_empty());
assert!(outcome.ignored_manifests().is_empty());
assert_eq!(
outcome.broken_manifest_symlinks(),
vec![PathBuf::from("Cargo.toml")]
);
assert!(
outcome.walk_errors().is_empty(),
"Bug 3 (background code review): a mid-walk broken symlink already classified must \
not also emit a duplicate generic IO walk error: {:?}",
outcome.walk_errors()
);
}
#[cfg(unix)]
#[test]
fn test_walk_broken_symlink_chain_is_detected() {
let dir = tempfile::tempdir().expect("create temp dir");
let missing = dir.path().join("does-not-exist");
let intermediate = dir.path().join("intermediate-link");
std::os::unix::fs::symlink(&missing, &intermediate)
.expect("create intermediate broken symlink");
std::os::unix::fs::symlink(&intermediate, dir.path().join("Cargo.toml"))
.expect("create Cargo.toml -> intermediate-link -> does-not-exist");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert!(outcome.manifests().is_empty());
assert!(outcome.ignored_manifests().is_empty());
assert_eq!(
outcome.broken_manifest_symlinks(),
vec![PathBuf::from("Cargo.toml")]
);
}
#[cfg(unix)]
#[test]
fn test_walk_symlink_target_permission_denied_is_detected_as_broken() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("create temp dir");
let blocked = dir.path().join("blocked");
fs::create_dir(&blocked).expect("mkdir blocked");
let target = blocked.join("manifest-data");
fs::write(&target, "[package]\n").expect("write target");
fs::set_permissions(&blocked, fs::Permissions::from_mode(0o000)).expect("chmod 000");
std::os::unix::fs::symlink(&target, dir.path().join("Cargo.toml"))
.expect("create symlink into a permission-denied directory");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
let permission_check_effective = std::fs::metadata(&target).is_err();
fs::set_permissions(&blocked, fs::Permissions::from_mode(0o755)).expect("restore perms");
if !permission_check_effective {
eprintln!(
"skipping: directory permissions did not block metadata (likely running as root)"
);
return;
}
assert!(outcome.manifests().is_empty());
assert!(outcome.ignored_manifests().is_empty());
assert_eq!(
outcome.broken_manifest_symlinks(),
vec![PathBuf::from("Cargo.toml")]
);
}
#[cfg(unix)]
#[test]
fn test_walk_pruned_directory_broken_symlinked_manifest_is_reported_as_broken() {
let dir = tempfile::tempdir().expect("create temp dir");
fs::create_dir(dir.path().join("vendor")).expect("mkdir vendor");
std::os::unix::fs::symlink(
dir.path().join("vendor").join("does-not-exist"),
dir.path().join("vendor").join("Cargo.toml"),
)
.expect("create broken symlink inside pruned directory");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert!(
outcome.manifests().is_empty(),
"still pruned from the primary scan"
);
assert!(outcome.ignored_manifests().is_empty());
assert_eq!(
outcome.broken_manifest_symlinks(),
vec![PathBuf::from("vendor").join("Cargo.toml")]
);
}
#[cfg(unix)]
#[test]
fn test_walk_respect_gitignore_detects_gitignored_broken_symlinked_manifest() {
let dir = tempfile::tempdir().expect("create temp dir");
fs::create_dir(dir.path().join(".git")).expect("create .git marker");
fs::write(dir.path().join(".gitignore"), "Cargo.toml\n").expect("write gitignore");
std::os::unix::fs::symlink(
dir.path().join("does-not-exist"),
dir.path().join("Cargo.toml"),
)
.expect("create broken symlink");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Respect,
SymlinkPolicy::Skip,
);
assert!(outcome.manifests().is_empty());
assert!(outcome.ignored_manifests().is_empty());
assert_eq!(
outcome.broken_manifest_symlinks(),
vec![PathBuf::from("Cargo.toml")]
);
}
#[cfg(unix)]
#[test]
fn test_walk_respect_gitignore_does_not_duplicate_broken_symlink_warning() {
let dir = tempfile::tempdir().expect("create temp dir");
fs::create_dir(dir.path().join(".git")).expect("create .git marker");
fs::write(dir.path().join(".gitignore"), "*.log\n").expect("write gitignore");
std::os::unix::fs::symlink(
dir.path().join("does-not-exist"),
dir.path().join("Cargo.toml"),
)
.expect("create broken symlink");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Respect,
SymlinkPolicy::Skip,
);
assert_eq!(
outcome.broken_manifest_symlinks(),
vec![PathBuf::from("Cargo.toml")],
"a non-gitignored broken symlinked manifest must be reported exactly once"
);
}
#[cfg(unix)]
#[test]
fn test_walk_symlink_to_directory_is_not_reported_as_manifest() {
let dir = tempfile::tempdir().expect("create temp dir");
fs::create_dir(dir.path().join("real_dir")).expect("mkdir real_dir");
std::os::unix::fs::symlink(dir.path().join("real_dir"), dir.path().join("link_dir"))
.expect("create symlink to directory");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert!(outcome.manifests().is_empty());
assert!(outcome.ignored_manifests().is_empty());
assert!(outcome.broken_manifest_symlinks().is_empty());
}
#[cfg(unix)]
#[test]
fn test_walk_symlink_to_directory_with_manifest_shaped_name_is_reported_as_broken() {
let dir = tempfile::tempdir().expect("create temp dir");
fs::create_dir(dir.path().join("real_dir")).expect("mkdir real_dir");
std::os::unix::fs::symlink(dir.path().join("real_dir"), dir.path().join("Cargo.toml"))
.expect("create manifest-shaped symlink to a directory");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert!(outcome.manifests().is_empty());
assert!(outcome.ignored_manifests().is_empty());
assert_eq!(
outcome.broken_manifest_symlinks(),
vec![PathBuf::from("Cargo.toml")]
);
}
#[cfg(unix)]
#[test]
fn test_walk_symlink_to_fifo_with_manifest_shaped_name_is_reported_as_broken() {
let dir = tempfile::tempdir().expect("create temp dir");
let fifo = dir.path().join("a-fifo");
let mkfifo_ok = std::process::Command::new("mkfifo")
.arg(&fifo)
.status()
.is_ok_and(|status| status.success());
if !mkfifo_ok {
eprintln!("skipping: `mkfifo` binary not available on PATH");
return;
}
std::os::unix::fs::symlink(&fifo, dir.path().join("Cargo.toml"))
.expect("create manifest-shaped symlink to a fifo");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert!(outcome.manifests().is_empty());
assert!(outcome.ignored_manifests().is_empty());
assert_eq!(
outcome.broken_manifest_symlinks(),
vec![PathBuf::from("Cargo.toml")]
);
}
#[cfg(unix)]
#[test]
fn test_walk_permission_denied_non_symlink_manifest_named_directory_is_not_reported() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("create temp dir");
let blocked = dir.path().join("app.csproj");
fs::create_dir(&blocked).expect("mkdir app.csproj");
fs::set_permissions(&blocked, fs::Permissions::from_mode(0o000)).expect("chmod 000");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
let permission_check_effective = std::fs::read_dir(&blocked).is_err();
fs::set_permissions(&blocked, fs::Permissions::from_mode(0o755)).expect("restore perms");
if !permission_check_effective {
eprintln!(
"skipping: directory permissions did not block read_dir (likely running as root)"
);
return;
}
assert!(
!outcome.walk_errors().is_empty(),
"sanity check: the walker's own descent into `blocked` must have failed for this \
test to exercise anything"
);
assert!(outcome.ignored_manifests().is_empty());
assert!(
outcome.broken_manifest_symlinks().is_empty(),
"a non-symlink, permission-denied directory must never be reported as symlink \
tampering: {:?}",
outcome.broken_manifest_symlinks()
);
}
#[cfg(unix)]
#[test]
fn test_walk_pruned_directory_symlinked_manifest_is_still_warned() {
let dir = tempfile::tempdir().expect("create temp dir");
let real = dir.path().join("real-cargo.toml");
fs::write(&real, "[package]\n").expect("write real manifest");
fs::create_dir(dir.path().join("vendor")).expect("mkdir vendor");
std::os::unix::fs::symlink(&real, dir.path().join("vendor").join("Cargo.toml"))
.expect("create symlink inside pruned directory");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert!(
outcome.manifests().is_empty(),
"still pruned from the primary scan"
);
assert_eq!(
outcome.ignored_manifests(),
vec![PathBuf::from("vendor").join("Cargo.toml")]
);
}
#[cfg(unix)]
#[test]
fn test_walk_follow_symlinks_resolves_and_routes_manifest() {
let dir = tempfile::tempdir().expect("create temp dir");
let real = dir.path().join("real").join("manifest-data");
fs::create_dir(dir.path().join("real")).expect("mkdir real");
fs::write(&real, "[package]\n").expect("write real manifest");
std::os::unix::fs::symlink(&real, dir.path().join("Cargo.toml")).expect("create symlink");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Follow,
);
assert_eq!(outcome.manifests().len(), 1);
assert!(outcome.ignored_manifests().is_empty());
assert_eq!(outcome.manifests()[0].ecosystem.id(), "cargo");
assert_eq!(
outcome.manifests()[0]
.path
.canonicalize()
.expect("canonicalize actual path"),
real.canonicalize()
.expect("canonicalize expected real path")
);
assert_ne!(
outcome.manifests()[0].path,
dir.path().join("Cargo.toml"),
"path must be the resolved real path, not the symlink's own raw path"
);
}
#[cfg(unix)]
#[test]
fn test_walk_follow_symlinks_toggles_between_ignored_and_routed() {
let dir = tempfile::tempdir().expect("create temp dir");
let real = dir.path().join("real").join("manifest-data");
fs::create_dir(dir.path().join("real")).expect("mkdir real");
fs::write(&real, "[package]\n").expect("write real manifest");
std::os::unix::fs::symlink(&real, dir.path().join("Cargo.toml")).expect("create symlink");
let disabled = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert!(disabled.manifests().is_empty());
assert_eq!(
disabled.ignored_manifests(),
vec![PathBuf::from("Cargo.toml")]
);
let enabled = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Follow,
);
assert_eq!(enabled.manifests().len(), 1);
assert!(enabled.ignored_manifests().is_empty());
}
#[cfg(unix)]
#[test]
fn test_walk_follow_symlinks_display_path_is_symlink_path_not_target() {
let dir = tempfile::tempdir().expect("create temp dir");
let real = dir.path().join("real").join("manifest-data");
fs::create_dir(dir.path().join("real")).expect("mkdir real");
fs::write(&real, "[package]\n").expect("write real manifest");
std::os::unix::fs::symlink(&real, dir.path().join("Cargo.toml")).expect("create symlink");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Follow,
);
assert_eq!(outcome.manifests().len(), 1);
assert_eq!(
outcome.manifests()[0].display_path,
PathBuf::from("Cargo.toml")
);
}
#[cfg(unix)]
#[test]
fn test_walk_follow_symlinks_still_prunes_node_modules() {
let dir = tempfile::tempdir().expect("create temp dir");
fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
let real_vendored = dir.path().join("real-vendored-manifest");
fs::write(&real_vendored, "{}").expect("write real vendored manifest");
fs::create_dir_all(dir.path().join("node_modules").join("left-pad"))
.expect("mkdir node_modules/left-pad");
std::os::unix::fs::symlink(
&real_vendored,
dir.path()
.join("node_modules")
.join("left-pad")
.join("package.json"),
)
.expect("symlink vendored manifest inside pruned directory");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Follow,
);
assert_eq!(
outcome.manifests().len(),
1,
"a symlinked manifest inside a pruned directory must still be pruned, not routed"
);
assert_eq!(
outcome.manifests()[0].display_path,
PathBuf::from("Cargo.toml")
);
}
#[cfg(unix)]
#[test]
fn test_walk_follow_symlinks_still_enforces_max_walked_files() {
let dir = tempfile::tempdir().expect("create temp dir");
let noise_target = dir.path().join(".noise-source");
fs::create_dir(&noise_target).expect("mkdir .noise-source");
for i in 0..5 {
fs::write(noise_target.join(format!("noise-{i}.txt")), "").expect("write noise file");
}
std::os::unix::fs::symlink(&noise_target, dir.path().join("noise-link"))
.expect("symlink noise directory");
fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
let outcome = walk_with_limit(
&[dir.path().to_path_buf()],
&test_registry(),
4,
GitignorePolicy::Ignore,
SymlinkPolicy::Follow,
);
assert!(
outcome.truncated,
"a 4-entry limit against a tree inflated by a symlinked directory must truncate"
);
}
#[cfg(unix)]
#[test]
fn test_walk_follow_symlinks_rejects_target_outside_root() {
let outside = tempfile::tempdir().expect("create outside temp dir");
let outside_manifest = outside.path().join("Cargo.toml");
fs::write(&outside_manifest, "[package]\n").expect("write outside manifest");
let root = tempfile::tempdir().expect("create walked root");
std::os::unix::fs::symlink(&outside_manifest, root.path().join("Cargo.toml"))
.expect("create symlink escaping the walked root");
let outcome = walk(
&[root.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Follow,
);
assert!(
outcome.manifests().is_empty(),
"must never route a symlink target outside the walked root"
);
assert_eq!(
outcome.ignored_manifests(),
vec![PathBuf::from("Cargo.toml")]
);
}
#[cfg(unix)]
#[test]
fn test_walk_follow_symlinks_reports_symlink_loop_via_walk_errors() {
let dir = tempfile::tempdir().expect("create temp dir");
fs::create_dir_all(dir.path().join("a")).expect("mkdir a");
fs::create_dir_all(dir.path().join("b")).expect("mkdir b");
std::os::unix::fs::symlink(dir.path().join("b"), dir.path().join("a").join("loop"))
.expect("create a/loop -> b");
std::os::unix::fs::symlink(dir.path().join("a"), dir.path().join("b").join("loop"))
.expect("create b/loop -> a");
fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Follow,
);
assert!(
outcome
.walk_errors()
.iter()
.any(|error| error.to_lowercase().contains("loop")),
"a symlink loop must be reported via walk_errors naming the loop, not just any \
error, and must not hang or crash: {:?}",
outcome.walk_errors()
);
assert!(
outcome
.manifests()
.iter()
.any(|m| m.display_path == Path::new("Cargo.toml")),
"other manifests in the same tree must still be found"
);
}
#[cfg(unix)]
#[test]
fn test_walk_follow_symlinks_self_referential_manifest_symlink_is_reported_as_broken() {
let dir = tempfile::tempdir().expect("create temp dir");
std::os::unix::fs::symlink(dir.path().join("Cargo.toml"), dir.path().join("Cargo.toml"))
.expect("create self-referential Cargo.toml -> Cargo.toml");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Follow,
);
assert_eq!(
outcome.broken_manifest_symlinks(),
vec![PathBuf::from("Cargo.toml")]
);
assert!(
outcome.walk_errors().is_empty(),
"must not duplicate the specific broken-manifest classification with a generic \
IO walk error: {:?}",
outcome.walk_errors()
);
}
#[cfg(unix)]
#[test]
fn test_walk_respect_gitignore_and_follow_symlinks_together_detect_broken_manifest() {
let dir = tempfile::tempdir().expect("create temp dir");
fs::create_dir(dir.path().join(".git")).expect("create .git marker");
fs::write(dir.path().join(".gitignore"), "Cargo.toml\n").expect("write gitignore");
std::os::unix::fs::symlink(
dir.path().join("does-not-exist"),
dir.path().join("Cargo.toml"),
)
.expect("create broken symlink");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Respect,
SymlinkPolicy::Follow,
);
assert!(outcome.manifests().is_empty());
assert!(outcome.ignored_manifests().is_empty());
assert_eq!(
outcome.broken_manifest_symlinks(),
vec![PathBuf::from("Cargo.toml")]
);
}
#[cfg(unix)]
#[test]
fn test_walk_follow_symlinks_and_respect_gitignore_together_still_honors_gitignore() {
let dir = tempfile::tempdir().expect("create temp dir");
fs::create_dir(dir.path().join(".git")).expect("create .git marker");
fs::write(dir.path().join(".gitignore"), "Cargo.toml\n").expect("write gitignore");
let real = dir.path().join("real").join("manifest-data");
fs::create_dir(dir.path().join("real")).expect("mkdir real");
fs::write(&real, "[package]\n").expect("write real manifest");
std::os::unix::fs::symlink(&real, dir.path().join("Cargo.toml")).expect("create symlink");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Respect,
SymlinkPolicy::Follow,
);
assert!(
outcome.manifests().is_empty(),
"a .gitignore-excluded symlinked manifest must not be routed even under \
--follow-symlinks"
);
assert_eq!(
outcome.ignored_manifests(),
vec![PathBuf::from("Cargo.toml")]
);
}
#[cfg(unix)]
#[test]
fn test_walk_follow_symlinks_rejects_directory_symlink_escaping_root() {
let outside = tempfile::tempdir().expect("create outside temp dir");
fs::write(outside.path().join("Cargo.toml"), "[package]\n")
.expect("write outside manifest");
let root = tempfile::tempdir().expect("create walked root");
fs::write(root.path().join("Cargo.toml"), "[package]\n").expect("write root manifest");
std::os::unix::fs::symlink(outside.path(), root.path().join("evil"))
.expect("symlink a directory escaping the walked root");
let outcome = walk(
&[root.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Follow,
);
assert!(
outcome
.manifests()
.iter()
.all(|m| m.display_path != PathBuf::from("evil").join("Cargo.toml")),
"a manifest reached only by descending into a symlinked directory outside the \
walked root must never be routed: {:?}",
outcome
.manifests()
.iter()
.map(|m| &m.display_path)
.collect::<Vec<_>>()
);
assert_eq!(
outcome.manifests().len(),
1,
"the root's own, non-escaping Cargo.toml must still be found"
);
}
#[cfg(unix)]
#[test]
fn test_walk_follow_symlinks_escaping_directory_does_not_exhaust_the_budget() {
let outside = tempfile::tempdir().expect("create outside temp dir");
for i in 0..50 {
fs::write(outside.path().join(format!("noise-{i}.txt")), "")
.expect("write outside noise file");
}
let root = tempfile::tempdir().expect("create walked root");
fs::write(root.path().join("Cargo.toml"), "[package]\n").expect("write root manifest");
std::os::unix::fs::symlink(outside.path(), root.path().join("evil"))
.expect("symlink a directory escaping the walked root");
let outcome = walk_with_limit(
&[root.path().to_path_buf()],
&test_registry(),
5,
GitignorePolicy::Ignore,
SymlinkPolicy::Follow,
);
assert!(
!outcome.truncated,
"pruning the escaping directory before descent must keep the walk well under the \
budget, not exhaust it on external content"
);
assert_eq!(
outcome.manifests().len(),
1,
"the root's own manifest must still be found"
);
}
#[cfg(unix)]
#[test]
fn test_walk_respect_gitignore_does_not_duplicate_symlinked_manifest_warning() {
let dir = tempfile::tempdir().expect("create temp dir");
fs::create_dir(dir.path().join(".git")).expect("create .git marker");
fs::write(dir.path().join(".gitignore"), "*.log\n").expect("write gitignore");
let real = dir.path().join("real").join("manifest-data");
fs::create_dir(dir.path().join("real")).expect("mkdir real");
fs::write(&real, "[package]\n").expect("write real manifest");
std::os::unix::fs::symlink(&real, dir.path().join("Cargo.toml")).expect("create symlink");
let outcome = walk(
&[dir.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Respect,
SymlinkPolicy::Skip,
);
assert_eq!(
outcome.ignored_manifests(),
vec![PathBuf::from("Cargo.toml")],
"a non-gitignored symlinked manifest must be reported exactly once"
);
}
#[cfg(unix)]
#[test]
fn test_walk_default_rejects_symlinked_hidden_ecosystem_directory_escaping_root() {
let outside = tempfile::tempdir().expect("create outside temp dir");
fs::create_dir_all(outside.path().join("workflows")).expect("mkdir workflows");
fs::write(
outside.path().join("workflows").join("ci.yml"),
"on: push\njobs:\n x:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n",
)
.expect("write workflow");
let root = tempfile::tempdir().expect("create walked root");
std::os::unix::fs::symlink(outside.path(), root.path().join(".github"))
.expect("symlink .github escaping the walked root");
let outcome = walk(
&[root.path().to_path_buf()],
&test_registry(),
GitignorePolicy::Ignore,
SymlinkPolicy::Skip,
);
assert!(
outcome.manifests().is_empty(),
"a symlinked .github escaping the walked root must never be scanned, even in the \
default mode: {:?}",
outcome
.manifests()
.iter()
.map(|m| &m.display_path)
.collect::<Vec<_>>()
);
}
}