fs: support pruning hidden directories during walks (#31570)

Why

Filtering hidden directories after a walk is too late: their descendants
consume traversal limits, and canonical directory deduplication can let
a hidden path claim a target before a visible symlink reaches it.

What this changes

- Add an optional pruneHiddenDirectories walk option. It defaults to
false and is omitted from the wire when disabled.
- Return hidden directory entries, but do not traverse them or add their
canonical identities to the visited set.
- Cover the visible-symlink-to-hidden-directory case through both local
and remote filesystem implementations.

This is the small filesystem prerequisite for #31566. Skill-specific
behavior remains in that PR.
This commit is contained in:
jif
2026-07-08 13:49:05 +01:00
committed by GitHub
parent f1affbac5e
commit a52b35fcf6
4 changed files with 76 additions and 0 deletions

View File

@@ -160,6 +160,7 @@ pub async fn load_environment_skills_from_root(
max_directories: MAX_SKILLS_DIRS_PER_ROOT,
max_entries: MAX_SKILLS_ENTRIES_PER_ROOT,
follow_directory_symlinks: true,
prune_hidden_directories: false,
},
/*sandbox*/ None,
)

View File

@@ -402,6 +402,7 @@ async fn file_system_walk_returns_a_bounded_tree(
max_directories: 10,
max_entries: 10,
follow_directory_symlinks: false,
prune_hidden_directories: false,
},
/*sandbox*/ None,
)
@@ -447,6 +448,7 @@ async fn file_system_walk_returns_a_bounded_tree(
max_directories: 10,
max_entries: 10,
follow_directory_symlinks: false,
prune_hidden_directories: false,
},
/*sandbox*/ None,
)
@@ -469,6 +471,7 @@ async fn file_system_walk_returns_a_bounded_tree(
max_directories: 1,
max_entries: 10,
follow_directory_symlinks: false,
prune_hidden_directories: false,
},
/*sandbox*/ None,
)
@@ -491,6 +494,7 @@ async fn file_system_walk_returns_a_bounded_tree(
max_directories: 10,
max_entries: 1,
follow_directory_symlinks: false,
prune_hidden_directories: false,
},
/*sandbox*/ None,
)
@@ -535,6 +539,7 @@ async fn file_system_walk_honors_read_sandbox(
max_directories: 2,
max_entries: 2,
follow_directory_symlinks: false,
prune_hidden_directories: false,
},
Some(&sandbox),
)

View File

@@ -299,6 +299,7 @@ async fn file_system_walk_handles_directory_symlinks(
max_directories: 4,
max_entries: 8,
follow_directory_symlinks: false,
prune_hidden_directories: false,
},
/*sandbox*/ None,
)
@@ -321,6 +322,7 @@ async fn file_system_walk_handles_directory_symlinks(
max_directories: 4,
max_entries: 8,
follow_directory_symlinks: true,
prune_hidden_directories: false,
},
/*sandbox*/ None,
)
@@ -351,6 +353,68 @@ async fn file_system_walk_handles_directory_symlinks(
Ok(())
}
#[test_case(FileSystemImplementation::Local ; "local")]
#[test_case(FileSystemImplementation::Remote ; "remote")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn file_system_walk_prunes_hidden_directories_without_claiming_visible_aliases(
implementation: FileSystemImplementation,
) -> Result<()> {
let context = create_file_system_context(implementation).await?;
let file_system = context.file_system;
let tmp = TempDir::new()?;
let root = tmp.path().join("root");
let hidden = root.join(".hidden");
let hidden_nested = hidden.join("nested");
let visible = root.join("visible");
std::fs::create_dir_all(&hidden_nested)?;
std::fs::write(hidden_nested.join("note.txt"), "visible through alias")?;
symlink(&hidden, &visible)?;
let outcome = file_system
.walk(
&PathUri::from_host_native_path(&root)?,
WalkOptions {
max_depth: 3,
max_directories: 3,
max_entries: 6,
follow_directory_symlinks: true,
prune_hidden_directories: true,
},
/*sandbox*/ None,
)
.await
.with_context(|| format!("mode={implementation}"))?;
assert_eq!(
outcome,
WalkOutcome {
entries: vec![
WalkEntry {
path: PathUri::from_host_native_path(hidden)?,
kind: WalkEntryKind::Directory,
},
WalkEntry {
path: PathUri::from_host_native_path(&visible)?,
kind: WalkEntryKind::Directory,
},
WalkEntry {
path: PathUri::from_host_native_path(visible.join("nested"))?,
kind: WalkEntryKind::Directory,
},
WalkEntry {
path: PathUri::from_host_native_path(visible.join("nested/note.txt"))?,
kind: WalkEntryKind::File,
},
],
errors: Vec::new(),
truncated: false,
}
);
Ok(())
}
#[test_case(FileSystemImplementation::Local ; "local")]
#[test_case(FileSystemImplementation::Remote ; "remote")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]

View File

@@ -81,6 +81,9 @@ pub struct WalkOptions {
pub max_entries: usize,
/// Whether directory symlinks should be followed.
pub follow_directory_symlinks: bool,
/// Whether directories whose names start with `.` should be returned but not traversed.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub prune_hidden_directories: bool,
}
/// Type of a filesystem entry returned by a walk.
@@ -463,6 +466,9 @@ async fn walk_via_directory_reads<F: ExecutorFileSystem + ?Sized>(
});
if kind == WalkEntryKind::Directory && depth < options.max_depth {
if options.prune_hidden_directories && entry.file_name.starts_with('.') {
continue;
}
let directory_identity = if options.follow_directory_symlinks {
match file_system.canonicalize(&path, sandbox).await {
Ok(path) => path,