#[cfg(feature = "fs-raw")]
pub mod capability;
use std::collections::HashSet;
use std::fs::{self, DirEntry};
use std::io;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct WalkOptions {
pub max_depth: usize,
pub follow_symlinks: bool,
pub sort_alphabetically: bool,
}
impl Default for WalkOptions {
fn default() -> Self {
Self {
max_depth: 32,
follow_symlinks: false,
sort_alphabetically: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum OmissionStage {
ReadEntries,
EntryType,
SymlinkTarget,
DirectoryResolution,
RootResolution,
ResourceBudget,
}
#[derive(Debug)]
#[non_exhaustive]
pub struct WalkOmission {
pub path: PathBuf,
pub stage: OmissionStage,
pub error: io::Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum FileKind {
File,
Directory,
Symlink,
Fifo,
CharDevice,
BlockDevice,
Socket,
Unknown,
}
impl FileKind {
fn of(file_type: fs::FileType) -> Self {
if file_type.is_symlink() {
return Self::Symlink;
}
if file_type.is_dir() {
return Self::Directory;
}
if file_type.is_file() {
return Self::File;
}
#[cfg(unix)]
{
use std::os::unix::fs::FileTypeExt as _;
if file_type.is_fifo() {
return Self::Fifo;
}
if file_type.is_char_device() {
return Self::CharDevice;
}
if file_type.is_block_device() {
return Self::BlockDevice;
}
if file_type.is_socket() {
return Self::Socket;
}
}
Self::Unknown
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Descend {
Enter,
Skip,
Stop,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct WalkEntry {
path: PathBuf,
kind: FileKind,
metadata: fs::Metadata,
root_components: usize,
}
impl WalkEntry {
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
#[must_use]
pub fn relative_path(&self) -> &Path {
let mut below_root = self.path.components();
for _ in 0..self.root_components {
below_root.next();
}
below_root.as_path()
}
#[must_use]
pub fn kind(&self) -> FileKind {
self.kind
}
#[must_use]
pub fn metadata(&self) -> &fs::Metadata {
&self.metadata
}
#[must_use]
pub fn into_path(self) -> PathBuf {
self.path
}
}
#[derive(Debug)]
#[non_exhaustive]
pub struct WalkReport<E = PathBuf> {
entries: Vec<E>,
omissions: Vec<WalkOmission>,
policy: WalkPolicy,
budget_exhausted: bool,
stopped: bool,
}
impl<E> WalkReport<E> {
fn new(options: &WalkOptions) -> Self {
Self {
entries: Vec::new(),
omissions: Vec::new(),
policy: WalkPolicy {
max_depth: options.max_depth,
follow_symlinks: options.follow_symlinks,
sort_alphabetically: options.sort_alphabetically,
},
budget_exhausted: false,
stopped: false,
}
}
#[must_use]
pub fn entries(&self) -> &[E] {
&self.entries
}
#[must_use]
pub fn stopped(&self) -> bool {
self.stopped
}
#[must_use]
pub fn omissions(&self) -> &[WalkOmission] {
&self.omissions
}
#[must_use]
pub fn is_complete(&self) -> bool {
self.is_complete_within_policy()
}
#[must_use]
pub fn is_complete_within_policy(&self) -> bool {
self.omissions.is_empty() && !self.budget_exhausted
}
#[must_use]
pub fn policy(&self) -> WalkPolicy {
self.policy
}
#[must_use]
pub fn budget_exhausted(&self) -> bool {
self.budget_exhausted
}
#[must_use]
pub fn into_parts(self) -> (Vec<E>, Vec<WalkOmission>) {
(self.entries, self.omissions)
}
}
impl std::fmt::Display for WalkFailure {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
formatter,
"filesystem walk failed at {:?} for {}: {}",
self.stage,
self.path.display(),
self.source
)
}
}
impl std::error::Error for WalkFailure {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.source)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct WalkPolicy {
pub max_depth: usize,
pub follow_symlinks: bool,
pub sort_alphabetically: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct WalkLimits {
pub max_entries: usize,
pub max_directory_entries: usize,
pub max_path_bytes: usize,
pub max_omissions: usize,
}
impl WalkLimits {
#[must_use]
pub const fn new(
max_entries: usize,
max_directory_entries: usize,
max_path_bytes: usize,
max_omissions: usize,
) -> Self {
Self {
max_entries,
max_directory_entries,
max_path_bytes,
max_omissions,
}
}
}
#[derive(Debug)]
#[non_exhaustive]
pub struct WalkFailure {
path: PathBuf,
stage: OmissionStage,
source: io::Error,
}
impl WalkFailure {
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
#[must_use]
pub fn stage(&self) -> OmissionStage {
self.stage
}
#[must_use]
pub fn source_error(&self) -> &io::Error {
&self.source
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WalkMode {
Strict,
Tolerant,
}
trait Record: Sized {
type Stat;
fn stat(entry: &DirEntry) -> io::Result<Self::Stat>;
fn file_type(stat: &Self::Stat) -> fs::FileType;
fn record(path: PathBuf, kind: FileKind, stat: Self::Stat, root_components: usize) -> Self;
}
impl Record for PathBuf {
type Stat = fs::FileType;
fn stat(entry: &DirEntry) -> io::Result<fs::FileType> {
entry.file_type()
}
fn file_type(stat: &fs::FileType) -> fs::FileType {
*stat
}
fn record(path: PathBuf, _kind: FileKind, _stat: fs::FileType, _root: usize) -> Self {
path
}
}
impl Record for WalkEntry {
type Stat = fs::Metadata;
fn stat(entry: &DirEntry) -> io::Result<fs::Metadata> {
entry.metadata()
}
fn file_type(stat: &fs::Metadata) -> fs::FileType {
stat.file_type()
}
fn record(path: PathBuf, kind: FileKind, stat: fs::Metadata, root_components: usize) -> Self {
Self {
path,
kind,
metadata: stat,
root_components,
}
}
}
fn enter_everything(_path: &Path, _kind: FileKind) -> Descend {
Descend::Enter
}
struct WalkContext<'a, E, P> {
canonical_root: &'a Path,
root_components: usize,
options: &'a WalkOptions,
mode: WalkMode,
out: &'a mut Vec<E>,
omissions: &'a mut Vec<WalkOmission>,
visited: &'a mut HashSet<PathBuf>,
limits: Option<&'a WalkLimits>,
prune: P,
entries_seen: usize,
path_bytes_seen: usize,
budget_exhausted: bool,
stopped: bool,
}
pub fn walk_dir(root: impl AsRef<Path>, options: &WalkOptions) -> io::Result<Vec<PathBuf>> {
let report = run_walk(
root.as_ref(),
options,
WalkMode::Strict,
None,
enter_everything,
)?;
Ok(report.into_parts().0)
}
pub fn walk_dir_tolerant(root: impl AsRef<Path>, options: &WalkOptions) -> io::Result<WalkReport> {
run_walk(
root.as_ref(),
options,
WalkMode::Tolerant,
None,
enter_everything,
)
}
pub fn walk_dir_bounded(
root: impl AsRef<Path>,
options: &WalkOptions,
limits: &WalkLimits,
) -> io::Result<Vec<PathBuf>> {
let report = run_walk(
root.as_ref(),
options,
WalkMode::Strict,
Some(limits),
enter_everything,
)?;
Ok(report.into_parts().0)
}
pub fn walk_dir_tolerant_bounded(
root: impl AsRef<Path>,
options: &WalkOptions,
limits: &WalkLimits,
) -> io::Result<WalkReport> {
run_walk(
root.as_ref(),
options,
WalkMode::Tolerant,
Some(limits),
enter_everything,
)
}
pub fn walk_dir_entries<P>(
root: impl AsRef<Path>,
options: &WalkOptions,
limits: Option<&WalkLimits>,
prune: P,
) -> io::Result<Vec<WalkEntry>>
where
P: FnMut(&Path, FileKind) -> Descend,
{
let report = run_walk(root.as_ref(), options, WalkMode::Strict, limits, prune)?;
Ok(report.into_parts().0)
}
pub fn walk_dir_entries_tolerant<P>(
root: impl AsRef<Path>,
options: &WalkOptions,
limits: Option<&WalkLimits>,
prune: P,
) -> io::Result<WalkReport<WalkEntry>>
where
P: FnMut(&Path, FileKind) -> Descend,
{
run_walk(root.as_ref(), options, WalkMode::Tolerant, limits, prune)
}
fn run_walk<E, P>(
root: &Path,
options: &WalkOptions,
mode: WalkMode,
limits: Option<&WalkLimits>,
prune: P,
) -> io::Result<WalkReport<E>>
where
E: Record,
P: FnMut(&Path, FileKind) -> Descend,
{
let canonical_root = root.canonicalize().map_err(|source| {
let kind = source.kind();
io::Error::new(
kind,
WalkFailure {
path: root.to_path_buf(),
stage: OmissionStage::RootResolution,
source,
},
)
})?;
let mut report = WalkReport::new(options);
let mut visited = HashSet::new();
let mut ctx = WalkContext {
canonical_root: &canonical_root,
root_components: canonical_root.components().count(),
options,
mode,
out: &mut report.entries,
omissions: &mut report.omissions,
visited: &mut visited,
limits,
prune,
entries_seen: 0,
path_bytes_seen: 0,
budget_exhausted: false,
stopped: false,
};
walk_recursive(root, &canonical_root, 0, &mut ctx)?;
report.budget_exhausted = ctx.budget_exhausted;
report.stopped = ctx.stopped;
Ok(report)
}
fn omit<E, P>(ctx: &mut WalkContext<'_, E, P>, omission: WalkOmission) -> io::Result<()> {
if ctx.mode == WalkMode::Strict {
let kind = omission.error.kind();
let refusal = Err(io::Error::new(
kind,
WalkFailure {
path: omission.path,
stage: omission.stage,
source: omission.error,
},
));
#[cfg(feature = "trace")]
crate::trace::debug!(error = ?refusal.as_ref().err(), "omit: returning an error to the caller");
return refusal;
}
if let Some(limits) = ctx.limits
&& ctx.omissions.len() >= limits.max_omissions
{
ctx.budget_exhausted = true;
return Ok(());
}
ctx.omissions.push(omission);
Ok(())
}
fn charge_entry<E, P>(
ctx: &mut WalkContext<'_, E, P>,
path: &Path,
directory_entries: usize,
) -> io::Result<bool> {
let Some(limits) = ctx.limits else {
return Ok(true);
};
let next_count = ctx.entries_seen.saturating_add(1);
let next_path_bytes = ctx.path_bytes_seen.saturating_add(path.as_os_str().len());
let exceeded = next_count > limits.max_entries
|| directory_entries >= limits.max_directory_entries
|| next_path_bytes > limits.max_path_bytes;
if exceeded {
if ctx.mode == WalkMode::Strict {
omit(
ctx,
WalkOmission {
path: path.to_path_buf(),
stage: OmissionStage::ResourceBudget,
error: io::Error::new(
io::ErrorKind::OutOfMemory,
"filesystem walk budget reached",
),
},
)?;
} else {
ctx.budget_exhausted = true;
}
return Ok(false);
}
ctx.entries_seen = next_count;
ctx.path_bytes_seen = next_path_bytes;
Ok(true)
}
fn track_canonical_visit(dir: &Path, visited_canonical: &mut HashSet<PathBuf>) -> bool {
if let Ok(canonical) = dir.canonicalize()
&& !visited_canonical.insert(canonical)
{
return false;
}
true
}
fn read_sorted_entries<E, P>(
dir: &Path,
logical_dir: &Path,
sort_alphabetically: bool,
ctx: &mut WalkContext<'_, E, P>,
) -> io::Result<Vec<DirEntry>> {
let mut entries = Vec::new();
for item in fs::read_dir(dir)? {
match item {
Ok(entry) => {
let output_path = logical_dir.join(entry.file_name());
if !charge_entry(ctx, &output_path, entries.len())? {
break;
}
entries.push(entry);
}
Err(error) => {
omit(
ctx,
WalkOmission {
path: logical_dir.to_path_buf(),
stage: OmissionStage::ReadEntries,
error,
},
)?;
if ctx.budget_exhausted {
break;
}
}
}
}
if sort_alphabetically {
entries.sort_by_key(|entry| entry.file_name());
}
Ok(entries)
}
fn resolve_symlink_path(dir: &Path, path: &Path) -> io::Result<PathBuf> {
let target = fs::read_link(path)?;
if target.is_relative() {
Ok(dir.join(target))
} else {
Ok(target)
}
}
struct SymlinkResolution {
within_root: bool,
directory: Option<PathBuf>,
}
fn resolve_symlink(
dir: &Path,
path: &Path,
canonical_root: &Path,
) -> io::Result<SymlinkResolution> {
let resolved = resolve_symlink_path(dir, path)?;
let canon = resolved.canonicalize()?;
let within_root = canon.starts_with(canonical_root);
let directory = (within_root && canon.is_dir()).then_some(canon);
Ok(SymlinkResolution {
within_root,
directory,
})
}
fn handle_directory_entry<E, P>(
path: &Path,
logical_path: &Path,
depth: usize,
ctx: &mut WalkContext<'_, E, P>,
) -> io::Result<()>
where
E: Record,
P: FnMut(&Path, FileKind) -> Descend,
{
walk_recursive(path, logical_path, depth, ctx)
}
fn handle_symlink_entry<E, P>(
target_dir: Option<PathBuf>,
logical_path: &Path,
depth: usize,
ctx: &mut WalkContext<'_, E, P>,
) -> io::Result<()>
where
E: Record,
P: FnMut(&Path, FileKind) -> Descend,
{
if ctx.options.follow_symlinks
&& let Some(target_dir) = target_dir
{
walk_recursive(&target_dir, logical_path, depth, ctx)?;
}
Ok(())
}
fn admit<E, P>(
ctx: &mut WalkContext<'_, E, P>,
logical_path: PathBuf,
kind: FileKind,
stat: E::Stat,
) -> bool
where
E: Record,
P: FnMut(&Path, FileKind) -> Descend,
{
let decision = (ctx.prune)(&logical_path, kind);
ctx.out
.push(E::record(logical_path, kind, stat, ctx.root_components));
match decision {
Descend::Enter => true,
Descend::Skip => false,
Descend::Stop => {
ctx.stopped = true;
false
}
}
}
fn process_entry<E, P>(
entry: DirEntry,
dir: &Path,
logical_dir: &Path,
current_depth: usize,
ctx: &mut WalkContext<'_, E, P>,
) -> io::Result<()>
where
E: Record,
P: FnMut(&Path, FileKind) -> Descend,
{
let path = entry.path();
let logical_path = logical_dir.join(entry.file_name());
let stat = match E::stat(&entry) {
Ok(stat) => stat,
Err(error) => {
return omit(
ctx,
WalkOmission {
path: logical_path,
stage: OmissionStage::EntryType,
error,
},
);
}
};
let file_type = E::file_type(&stat);
let kind = FileKind::of(file_type);
let next_depth = current_depth.saturating_add(1);
if file_type.is_dir() {
if admit(ctx, logical_path.clone(), kind, stat) {
handle_directory_entry(&path, &logical_path, next_depth, ctx)?;
}
} else if file_type.is_symlink() {
let resolution = match resolve_symlink(dir, &path, ctx.canonical_root) {
Ok(resolution) => resolution,
Err(error) => {
omit(
ctx,
WalkOmission {
path: logical_path.clone(),
stage: OmissionStage::SymlinkTarget,
error,
},
)?;
return Ok(());
}
};
if resolution.within_root && admit(ctx, logical_path.clone(), kind, stat) {
handle_symlink_entry(resolution.directory, &logical_path, next_depth, ctx)?;
}
} else {
admit(ctx, logical_path, kind, stat);
}
Ok(())
}
fn check_directory_path(dir: &Path, canonical_root: &Path) -> io::Result<PathBuf> {
let canon = dir.canonicalize()?;
if !canon.starts_with(canonical_root) {
let refusal = Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!(
"walked directory {} resolves outside the root",
dir.display()
),
));
#[cfg(feature = "trace")]
crate::trace::debug!(error = ?refusal.as_ref().err(), "check_directory_path: returning an error to the caller");
return refusal;
}
Ok(canon)
}
fn walk_recursive<E, P>(
dir: &Path,
logical_dir: &Path,
current_depth: usize,
ctx: &mut WalkContext<'_, E, P>,
) -> io::Result<()>
where
E: Record,
P: FnMut(&Path, FileKind) -> Descend,
{
if current_depth > ctx.options.max_depth || ctx.budget_exhausted || ctx.stopped {
return Ok(());
}
if !track_canonical_visit(dir, ctx.visited) {
return Ok(());
}
let canonical_path = match check_directory_path(dir, ctx.canonical_root) {
Ok(canonical_path) => canonical_path,
Err(error) => {
return omit(
ctx,
WalkOmission {
path: logical_dir.to_path_buf(),
stage: OmissionStage::DirectoryResolution,
error,
},
);
}
};
let out_len = ctx.out.len();
let omissions_len = ctx.omissions.len();
let listed = match read_sorted_entries(dir, logical_dir, ctx.options.sort_alphabetically, ctx) {
Ok(entries) => entries,
Err(error) => {
if error
.get_ref()
.is_some_and(|source| source.is::<WalkFailure>())
{
let refusal = Err(error);
#[cfg(feature = "trace")]
crate::trace::debug!(error = ?refusal.as_ref().err(), "walk_recursive: returning an error to the caller");
return refusal;
}
return omit(
ctx,
WalkOmission {
path: logical_dir.to_path_buf(),
stage: OmissionStage::ReadEntries,
error,
},
);
}
};
let budget_reached_during_listing = ctx.budget_exhausted;
for entry in listed {
if ctx.stopped || (ctx.budget_exhausted && !budget_reached_during_listing) {
break;
}
process_entry(entry, dir, logical_dir, current_depth, ctx)?;
}
match check_directory_path(dir, ctx.canonical_root) {
Ok(again) if again == canonical_path => Ok(()),
changed => {
ctx.out.truncate(out_len);
ctx.omissions.truncate(omissions_len);
let error = match changed {
Ok(_) => io::Error::new(
io::ErrorKind::InvalidData,
"walked directory changed canonical path during the read",
),
Err(error) => error,
};
omit(
ctx,
WalkOmission {
path: logical_dir.to_path_buf(),
stage: OmissionStage::DirectoryResolution,
error,
},
)
}
}
}
#[cfg(all(unix, feature = "fs-raw"))]
pub fn available_space(path: impl AsRef<Path>) -> io::Result<u64> {
let stat = rustix::fs::statvfs(path.as_ref())?;
let block = if stat.f_frsize == 0 {
stat.f_bsize
} else {
stat.f_frsize
};
Ok(stat.f_bavail.saturating_mul(block))
}
#[cfg(all(not(unix), feature = "fs-raw"))]
pub fn available_space(path: impl AsRef<Path>) -> io::Result<u64> {
let _ = path;
Err(io::Error::new(
io::ErrorKind::Unsupported,
"fs-raw available_space is Unix-only",
))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs as stdfs;
fn tmp_tree() -> std::io::Result<tempfile::TempDir> {
let tmp = tempfile::tempdir()?;
let root = tmp.path();
stdfs::create_dir_all(root.join("a/b/c"))?;
stdfs::write(root.join("a/f.txt"), b"")?;
stdfs::write(root.join("a/b/g.txt"), b"")?;
stdfs::write(root.join("a/b/c/h.txt"), b"")?;
Ok(tmp)
}
#[test]
fn walks_directory_deterministically() -> std::io::Result<()> {
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let src_dir = manifest_dir.join("src");
let entries1 = walk_dir(&src_dir, &WalkOptions::default())?;
let entries2 = walk_dir(&src_dir, &WalkOptions::default())?;
assert!(!entries1.is_empty());
assert_eq!(entries1, entries2);
Ok(())
}
#[test]
fn max_depth_zero_returns_only_immediate_children() -> std::io::Result<()> {
let tmp = tmp_tree()?;
let opts = WalkOptions {
max_depth: 0,
..Default::default()
};
let root = tmp.path().join("a").canonicalize()?;
let entries = walk_dir(&root, &opts)?;
for entry in &entries {
assert_eq!(
entry.parent(),
Some(root.as_path()),
"depth-zero paths remain under the resolved root"
);
}
let report = walk_dir_tolerant(&root, &opts)?;
assert!(
report.is_complete_within_policy(),
"depth exclusion is complete within the selected policy"
);
assert_eq!(
report.policy().max_depth,
0,
"the report states its depth boundary"
);
Ok(())
}
#[test]
#[cfg(unix)]
fn alias_order_keeps_one_absolute_logical_path_basis() -> std::io::Result<()> {
let cwd = std::env::current_dir()?;
let temp = tempfile::tempdir_in(&cwd)?;
let root = temp.path().join("before");
stdfs::create_dir_all(root.join("z-target"))?;
stdfs::write(root.join("z-target/file"), b"content")?;
std::os::unix::fs::symlink("z-target", root.join("b-middle"))?;
std::os::unix::fs::symlink("b-middle", root.join("a-alias"))?;
let options = WalkOptions {
follow_symlinks: true,
..Default::default()
};
let absolute = walk_dir(&root, &options)?;
let relative_root = root.strip_prefix(&cwd).map_err(io::Error::other)?;
let relative = walk_dir(relative_root, &options)?;
let tolerant = walk_dir_tolerant(&root, &options)?;
assert_eq!(
absolute, relative,
"relative and absolute roots share one output basis"
);
assert_eq!(
absolute,
tolerant.entries(),
"strict and tolerant clean walks agree"
);
assert!(
absolute.iter().all(|path| path.is_absolute()),
"every emitted path is absolute"
);
assert!(
absolute.iter().any(|path| path.ends_with("a-alias/file")),
"alias provenance remains visible"
);
assert!(
!absolute.iter().any(|path| path.ends_with("z-target/file")),
"canonical target is deduplicated after the earlier alias"
);
let after = temp.path().join("after");
stdfs::create_dir_all(after.join("a-target"))?;
stdfs::write(after.join("a-target/file"), b"content")?;
std::os::unix::fs::symlink("a-target", after.join("z-alias"))?;
let entries = walk_dir(&after, &options)?;
assert!(
entries.iter().any(|path| path.ends_with("a-target/file")),
"earlier target traversal keeps its logical path"
);
assert!(
!entries.iter().any(|path| path.ends_with("z-alias/file")),
"later alias does not duplicate a visited target"
);
let depth_one = WalkOptions {
max_depth: 1,
follow_symlinks: options.follow_symlinks,
sort_alphabetically: options.sort_alphabetically,
};
let limited = walk_dir_tolerant(&after, &depth_one)?;
assert!(
limited.is_complete_within_policy(),
"depth exclusion is complete within its selected policy"
);
assert_eq!(
limited.policy().max_depth,
1,
"the report exposes the applied depth"
);
Ok(())
}
#[test]
fn bounded_walk_marks_prefix_and_strict_refusal() -> std::io::Result<()> {
let temp = tempfile::tempdir()?;
for name in ["a", "b", "c"] {
stdfs::write(temp.path().join(name), b"x")?;
}
let options = WalkOptions::default();
let limits = WalkLimits::new(2, 2, 1024, 1);
let report = walk_dir_tolerant_bounded(temp.path(), &options, &limits)?;
assert_eq!(
report.entries().len(),
2,
"entry ceiling bounds the retained prefix"
);
assert!(
report.budget_exhausted(),
"budget exhaustion is visible in the report"
);
assert!(
!report.is_complete_within_policy(),
"a budget-limited prefix is incomplete"
);
let error = walk_dir_bounded(temp.path(), &options, &limits)
.err()
.ok_or_else(|| io::Error::other("strict bounded walk accepted an incomplete tree"))?;
let failure = error
.get_ref()
.and_then(|source| source.downcast_ref::<WalkFailure>())
.ok_or_else(|| io::Error::other("strict error did not retain WalkFailure"))?;
assert_eq!(
failure.stage(),
OmissionStage::ResourceBudget,
"strict budget refusal preserves its stage"
);
assert_eq!(
failure.source_error().kind(),
io::ErrorKind::OutOfMemory,
"budget source remains typed as an io error"
);
let path_limits = WalkLimits::new(100, 100, 1, 10);
let path_report = walk_dir_tolerant_bounded(temp.path(), &options, &path_limits)?;
assert!(
path_report.entries().is_empty(),
"path-byte ceiling is charged before entry retention"
);
assert!(
path_report.budget_exhausted(),
"path-byte exhaustion is visible"
);
let directory_limits = WalkLimits::new(100, 1, 1024, 10);
let directory_report = walk_dir_tolerant_bounded(temp.path(), &options, &directory_limits)?;
assert_eq!(
directory_report.entries().len(),
1,
"per-directory sorting storage is independently bounded"
);
assert!(
directory_report.budget_exhausted(),
"per-directory overflow is reported"
);
let long_path_root = tempfile::tempdir()?;
let long_name = "x".repeat(180);
stdfs::write(long_path_root.path().join(long_name), b"x")?;
let long_path_limits = WalkLimits::new(100, 100, 128, 10);
let long_path_report =
walk_dir_tolerant_bounded(long_path_root.path(), &options, &long_path_limits)?;
assert!(
long_path_report.entries().is_empty(),
"long entry paths are refused before materialization"
);
assert!(
long_path_report.budget_exhausted(),
"long-path budget refusal is visible"
);
Ok(())
}
#[test]
#[cfg(unix)]
fn omission_budget_marks_report_incomplete() -> std::io::Result<()> {
let temp = tempfile::tempdir()?;
for index in 0..5 {
let name = format!("broken-{index}");
std::os::unix::fs::symlink(temp.path().join("missing"), temp.path().join(name))?;
}
let limits = WalkLimits::new(10, 10, 1024, 1);
let report = walk_dir_tolerant_bounded(temp.path(), &WalkOptions::default(), &limits)?;
assert_eq!(
report.omissions().len(),
1,
"omission flood is capped before growth"
);
assert!(
report.budget_exhausted(),
"dropped omission is reported as budget exhaustion"
);
assert!(
!report.is_complete_within_policy(),
"budget omission cannot look complete"
);
Ok(())
}
#[test]
fn max_depth_bounds_traversal() -> std::io::Result<()> {
let tmp = tmp_tree()?;
let opts = WalkOptions {
max_depth: 1,
..Default::default()
};
let entries = walk_dir(tmp.path().join("a"), &opts)?;
assert!(
!entries.iter().any(|path| path.ends_with("h.txt")),
"depth-2 file h.txt should be excluded"
);
let deep = tempfile::tempdir()?;
let mut current = deep.path().to_path_buf();
for _ in 0..24 {
current.push("d");
stdfs::create_dir(¤t)?;
}
stdfs::write(current.join("leaf"), b"")?;
let deep_entries = walk_dir(deep.path(), &opts)?;
assert!(
!deep_entries.iter().any(|path| path.ends_with("leaf")),
"depth policy excludes leaves on a deep chain"
);
Ok(())
}
#[test]
#[cfg(unix)]
fn symlink_loop_terminates() -> std::io::Result<()> {
let tmp = tempfile::tempdir()?;
let root = tmp.path();
stdfs::create_dir(root.join("d"))?;
std::os::unix::fs::symlink(root.join("d"), root.join("d/loop"))?;
let opts = WalkOptions {
follow_symlinks: true,
..Default::default()
};
let entries = walk_dir(root, &opts)?;
assert!(!entries.is_empty());
Ok(())
}
#[test]
#[cfg(unix)]
fn symlink_outside_root_is_rejected() -> std::io::Result<()> {
let inner = tempfile::tempdir()?;
let outer = tempfile::tempdir()?;
stdfs::write(outer.path().join("secret.txt"), b"secret")?;
std::os::unix::fs::symlink(outer.path(), inner.path().join("escape"))?;
let opts = WalkOptions {
follow_symlinks: true,
..Default::default()
};
let entries = walk_dir(inner.path(), &opts)?;
assert!(
!entries
.iter()
.any(|path| path.to_string_lossy().contains("secret")),
"a symlink outside the root policy must be rejected"
);
let report = walk_dir_tolerant(inner.path(), &opts)?;
assert!(
report.is_complete_within_policy(),
"outside-root symlink exclusion is complete within the declared policy"
);
Ok(())
}
#[test]
#[cfg(unix)]
fn symlink_outside_root_excluded_from_output() -> std::io::Result<()> {
let inner = tempfile::tempdir()?;
let outer = tempfile::tempdir()?;
stdfs::write(outer.path().join("secret.txt"), b"secret")?;
std::os::unix::fs::symlink(outer.path(), inner.path().join("escape"))?;
let opts = WalkOptions {
follow_symlinks: false,
..Default::default()
};
let entries = walk_dir(inner.path(), &opts)?;
assert!(
!entries
.iter()
.any(|path| path.to_string_lossy().contains("escape")),
"symlink pointing outside the root must not appear in output"
);
let report = walk_dir_tolerant(inner.path(), &opts)?;
assert!(
report.is_complete_within_policy(),
"disabled symlink following is complete within the declared policy"
);
Ok(())
}
#[test]
fn nonexistent_directory_returns_error() {
let result = walk_dir(
"/nonexistent-path-that-does-not-exist",
&WalkOptions::default(),
);
assert!(result.is_err());
}
#[test]
#[cfg(all(unix, feature = "fs-raw"))]
fn available_space_reports_positive_bytes() -> std::io::Result<()> {
let tmp = tempfile::tempdir()?;
let free = available_space(tmp.path())?;
assert!(free > 0, "available space must be positive, got {free}");
Ok(())
}
#[test]
#[cfg(feature = "fs-raw")]
fn available_space_on_missing_path_is_an_error() {
let result = available_space("/nonexistent-path-that-does-not-exist");
assert!(result.is_err());
}
#[cfg(unix)]
fn locked_parent(locked: &Path) -> std::io::Result<PathBuf> {
locked.parent().map(Path::to_path_buf).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"locked dir must have a parent",
)
})
}
#[cfg(unix)]
fn locked_tree() -> std::io::Result<Option<(tempfile::TempDir, PathBuf)>> {
use std::os::unix::fs::PermissionsExt as _;
let tmp = tempfile::tempdir()?;
let root = tmp.path();
stdfs::write(root.join("ok.txt"), b"")?;
let locked = root.join("locked");
stdfs::create_dir(&locked)?;
stdfs::write(locked.join("secret.txt"), b"")?;
stdfs::set_permissions(&locked, stdfs::Permissions::from_mode(0o000))?;
if stdfs::read_dir(&locked).is_ok() {
stdfs::set_permissions(&locked, stdfs::Permissions::from_mode(0o755))?;
return Ok(None);
}
Ok(Some((tmp, locked)))
}
#[cfg(unix)]
fn unlock(locked: &Path) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt as _;
stdfs::set_permissions(locked, stdfs::Permissions::from_mode(0o755))
}
#[test]
#[cfg(unix)]
fn strict_refuses_a_directory_it_cannot_list() -> std::io::Result<()> {
let Some((_tmp, locked)) = locked_tree()? else {
return Ok(());
};
let result = walk_dir(locked_parent(&locked)?, &WalkOptions::default());
unlock(&locked)?;
assert!(result.is_err(), "strict walk must refuse partial coverage");
Ok(())
}
#[test]
#[cfg(unix)]
fn tolerant_reports_an_unlistable_subdirectory() -> std::io::Result<()> {
let Some((_tmp, locked)) = locked_tree()? else {
return Ok(());
};
let report = walk_dir_tolerant(locked_parent(&locked)?, &WalkOptions::default())?;
unlock(&locked)?;
assert!(
report.entries().iter().any(|path| path.ends_with("ok.txt")),
"readable entries must survive an unlistable sibling"
);
assert!(
!report.is_complete(),
"one omission must mark the report incomplete"
);
assert_eq!(report.omissions().len(), 1);
assert_eq!(report.omissions()[0].stage, OmissionStage::ReadEntries);
assert_eq!(
report.omissions()[0].path,
locked.canonicalize()?,
"omission paths use the canonical-root coordinate basis"
);
Ok(())
}
#[test]
fn tolerant_entries_match_strict_on_a_clean_tree() -> std::io::Result<()> {
let tmp = tmp_tree()?;
let strict = walk_dir(tmp.path().join("a"), &WalkOptions::default())?;
let report = walk_dir_tolerant(tmp.path().join("a"), &WalkOptions::default())?;
assert_eq!(report.entries(), strict);
assert!(report.is_complete());
assert!(
report.is_complete_within_policy(),
"clean tree is complete within its policy"
);
assert_eq!(
report.policy().max_depth,
WalkOptions::default().max_depth,
"report carries applied depth policy"
);
Ok(())
}
#[test]
fn unresolvable_root_is_refused() -> std::io::Result<()> {
let tmp = tempfile::tempdir()?;
let dangling = tmp.path().join("dangling");
#[cfg(unix)]
std::os::unix::fs::symlink(tmp.path().join("gone"), &dangling)?;
#[cfg(windows)]
std::os::windows::fs::symlink_dir(tmp.path().join("gone"), &dangling)?;
let error = walk_dir(&dangling, &WalkOptions::default())
.err()
.ok_or_else(|| io::Error::other("strict walk accepted an unresolved root"))?;
let failure = error
.get_ref()
.and_then(|source| source.downcast_ref::<WalkFailure>())
.ok_or_else(|| io::Error::other("root refusal did not retain typed context"))?;
assert_eq!(
failure.path(),
dangling,
"root failure retains the requested path"
);
assert_eq!(
failure.stage(),
OmissionStage::RootResolution,
"root failure has its own stage"
);
assert!(walk_dir_tolerant(&dangling, &WalkOptions::default()).is_err());
Ok(())
}
#[test]
#[cfg(unix)]
fn broken_symlink_is_a_strict_refusal_or_a_tolerant_omission() -> std::io::Result<()> {
let tmp = tempfile::tempdir()?;
let link = tmp.path().join("broken-link");
std::os::unix::fs::symlink(tmp.path().join("missing-target"), &link)?;
let strict = walk_dir(tmp.path(), &WalkOptions::default());
let error = strict.err().ok_or_else(|| {
std::io::Error::other("strict walk silently accepted an unresolved symlink")
})?;
assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
let failure = error
.get_ref()
.and_then(|source| source.downcast_ref::<WalkFailure>())
.ok_or_else(|| io::Error::other("strict error did not retain typed context"))?;
let canonical_link = tmp.path().canonicalize()?.join("broken-link");
assert_eq!(
failure.path(),
canonical_link,
"strict error retains the canonical-root logical link path"
);
assert_eq!(
failure.stage(),
OmissionStage::SymlinkTarget,
"strict error retains the failure stage"
);
assert_eq!(
failure.source_error().kind(),
io::ErrorKind::NotFound,
"strict error preserves the original source"
);
let report = walk_dir_tolerant(tmp.path(), &WalkOptions::default())?;
assert_eq!(
report.entries().len(),
0,
"the unproved target is not reported"
);
assert_eq!(
report.omissions().len(),
1,
"the unread target is visible once"
);
assert_eq!(
report.omissions()[0].path,
canonical_link,
"tolerant omission uses the same canonical-root path basis"
);
assert_eq!(report.omissions()[0].stage, OmissionStage::SymlinkTarget);
assert_eq!(
report.omissions()[0].error.kind(),
std::io::ErrorKind::NotFound
);
assert!(!report.is_complete());
Ok(())
}
fn enter(_path: &Path, _kind: FileKind) -> Descend {
Descend::Enter
}
fn tree_root(tmp: &tempfile::TempDir) -> std::io::Result<PathBuf> {
tmp.path().join("a").canonicalize()
}
#[test]
fn entering_everything_admits_exactly_the_path_walk() -> std::io::Result<()> {
let tmp = tmp_tree()?;
let root = tree_root(&tmp)?;
let paths = walk_dir(&root, &WalkOptions::default())?;
let entries = walk_dir_entries(&root, &WalkOptions::default(), None, enter)?;
let entry_paths: Vec<PathBuf> = entries.into_iter().map(WalkEntry::into_path).collect();
assert_eq!(
entry_paths, paths,
"an always-enter predicate must admit the path walk's entries in its order"
);
let report = walk_dir_entries_tolerant(&root, &WalkOptions::default(), None, enter)?;
assert!(
report.is_complete() && !report.stopped(),
"a clean always-enter walk is complete and was not stopped"
);
Ok(())
}
#[test]
fn a_skipped_directory_is_listed_but_never_read() -> std::io::Result<()> {
let tmp = tmp_tree()?;
let root = tree_root(&tmp)?;
let skip_b = |path: &Path, _kind: FileKind| {
if path.ends_with("b") {
Descend::Skip
} else {
Descend::Enter
}
};
let entries = walk_dir_entries(&root, &WalkOptions::default(), None, skip_b)?;
let relative: Vec<&Path> = entries.iter().map(WalkEntry::relative_path).collect();
assert_eq!(
relative,
[Path::new("b"), Path::new("f.txt")],
"the skipped directory is recorded and nothing below it is"
);
let limits = WalkLimits::new(2, 2, 4_096, 0);
let bounded = walk_dir_entries(&root, &WalkOptions::default(), Some(&limits), skip_b)?;
assert_eq!(
bounded.len(),
2,
"the skipped subtree charged nothing to the budget"
);
assert!(
walk_dir_entries(&root, &WalkOptions::default(), Some(&limits), enter).is_err(),
"the same budget refuses the unpruned walk, so the bound is tight"
);
Ok(())
}
#[test]
fn stop_records_its_entry_and_ends_the_walk() -> std::io::Result<()> {
let tmp = tmp_tree()?;
let root = tree_root(&tmp)?;
let mut offered = 0_usize;
let report =
walk_dir_entries_tolerant(&root, &WalkOptions::default(), None, |_path, _kind| {
offered = offered.saturating_add(1);
Descend::Stop
})?;
assert_eq!(offered, 1, "nothing is offered after a stop");
let relative: Vec<&Path> = report
.entries()
.iter()
.map(WalkEntry::relative_path)
.collect();
assert_eq!(
relative,
[Path::new("b")],
"the stopping entry is recorded and nothing after it"
);
assert!(report.stopped(), "the report says the predicate stopped it");
assert!(
report.is_complete_within_policy(),
"a requested stop is the caller's policy, not an omission"
);
Ok(())
}
#[test]
fn relative_path_joins_back_onto_the_canonical_root() -> std::io::Result<()> {
let tmp = tmp_tree()?;
let root = tree_root(&tmp)?;
let entries = walk_dir_entries(tmp.path().join("a"), &WalkOptions::default(), None, enter)?;
assert_eq!(entries.len(), 5, "a holds five entries");
for entry in &entries {
let relative = entry.relative_path();
assert!(
relative.is_relative() && relative.components().count() >= 1,
"{} is not a non-empty relative path",
relative.display()
);
assert_eq!(
root.join(relative),
entry.path(),
"the relative path joins back onto the canonical root"
);
}
Ok(())
}
#[test]
fn the_metadata_is_the_walks_snapshot_not_a_later_stat() -> std::io::Result<()> {
use std::io::Write as _;
let tmp = tempfile::tempdir()?;
let file = tmp.path().join("grows");
stdfs::write(&file, b"abc")?;
let entries = walk_dir_entries(tmp.path(), &WalkOptions::default(), None, enter)?;
stdfs::OpenOptions::new()
.append(true)
.open(&file)?
.write_all(b"defgh")?;
let entry = entries
.first()
.ok_or_else(|| io::Error::other("the walk lost the one file"))?;
assert_eq!(
entry.metadata().len(),
3,
"the entry carries the size the walk read, not one read on access"
);
assert_eq!(
stdfs::symlink_metadata(&file)?.len(),
8,
"the file really did grow afterwards"
);
Ok(())
}
#[test]
#[cfg(unix)]
fn an_entry_carries_the_lstat_of_a_symlink_not_its_target() -> std::io::Result<()> {
use std::os::unix::fs::MetadataExt as _;
let tmp = tempfile::tempdir()?;
stdfs::write(tmp.path().join("target.txt"), b"target bytes")?;
std::os::unix::fs::symlink("target.txt", tmp.path().join("link"))?;
let entries = walk_dir_entries(tmp.path(), &WalkOptions::default(), None, enter)?;
let link = entries
.iter()
.find(|entry| entry.relative_path() == Path::new("link"))
.ok_or_else(|| io::Error::other("the in-root link was not admitted"))?;
assert_eq!(
link.kind(),
FileKind::Symlink,
"a link is classified as one"
);
assert!(
link.metadata().file_type().is_symlink(),
"the metadata is the link's own lstat"
);
assert_eq!(
link.metadata().ino(),
stdfs::symlink_metadata(tmp.path().join("link"))?.ino(),
"the inode is the link's"
);
assert_ne!(
link.metadata().ino(),
stdfs::metadata(tmp.path().join("target.txt"))?.ino(),
"the inode is not the target's"
);
Ok(())
}
#[test]
#[cfg(unix)]
fn a_socket_is_classified_as_a_socket() -> std::io::Result<()> {
let tmp = tempfile::tempdir()?;
let _listener = std::os::unix::net::UnixListener::bind(tmp.path().join("sock"))?;
let entries = walk_dir_entries(tmp.path(), &WalkOptions::default(), None, enter)?;
let kinds: Vec<FileKind> = entries.iter().map(WalkEntry::kind).collect();
assert_eq!(
kinds,
[FileKind::Socket],
"a socket is named, not guessed as a file"
);
Ok(())
}
#[test]
#[cfg(unix)]
fn an_entry_the_walk_cannot_lstat_is_an_entry_type_omission() -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt as _;
let tmp = tempfile::tempdir()?;
let listed = tmp.path().join("listed");
stdfs::create_dir(&listed)?;
stdfs::write(listed.join("inside"), b"")?;
stdfs::set_permissions(&listed, stdfs::Permissions::from_mode(0o444))?;
let inert = stdfs::symlink_metadata(listed.join("inside")).is_ok();
let report = walk_dir_entries_tolerant(tmp.path(), &WalkOptions::default(), None, enter);
let strict = walk_dir_entries(tmp.path(), &WalkOptions::default(), None, enter);
stdfs::set_permissions(&listed, stdfs::Permissions::from_mode(0o755))?;
if inert {
return Ok(());
}
let report = report?;
let omitted: Vec<(&Path, OmissionStage)> = report
.omissions()
.iter()
.map(|omission| (omission.path.as_path(), omission.stage))
.collect();
let inside = tmp.path().canonicalize()?.join("listed/inside");
assert_eq!(
omitted,
[(inside.as_path(), OmissionStage::EntryType)],
"a name the walk listed but could not lstat is an omission, never dropped"
);
let error = strict
.err()
.ok_or_else(|| io::Error::other("strict accepted an entry it could not lstat"))?;
assert_eq!(
error
.get_ref()
.and_then(|source| source.downcast_ref::<WalkFailure>())
.map(WalkFailure::stage),
Some(OmissionStage::EntryType),
"strict refuses at the entry-type stage"
);
Ok(())
}
}