#[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)]
#[non_exhaustive]
pub struct WalkReport {
entries: Vec<PathBuf>,
omissions: Vec<WalkOmission>,
policy: WalkPolicy,
budget_exhausted: bool,
}
impl WalkReport {
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,
}
}
#[must_use]
pub fn entries(&self) -> &[PathBuf] {
&self.entries
}
#[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<PathBuf>, 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,
}
struct WalkContext<'a> {
canonical_root: &'a Path,
options: &'a WalkOptions,
mode: WalkMode,
out: &'a mut Vec<PathBuf>,
omissions: &'a mut Vec<WalkOmission>,
visited: &'a mut HashSet<PathBuf>,
limits: Option<&'a WalkLimits>,
entries_seen: usize,
path_bytes_seen: usize,
budget_exhausted: 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)?;
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)
}
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))?;
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))
}
fn run_walk(
root: &Path,
options: &WalkOptions,
mode: WalkMode,
limits: Option<&WalkLimits>,
) -> io::Result<WalkReport> {
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,
options,
mode,
out: &mut report.entries,
omissions: &mut report.omissions,
visited: &mut visited,
limits,
entries_seen: 0,
path_bytes_seen: 0,
budget_exhausted: false,
};
walk_recursive(root, &canonical_root, 0, &mut ctx)?;
report.budget_exhausted = ctx.budget_exhausted;
Ok(report)
}
fn omit(ctx: &mut WalkContext<'_>, omission: WalkOmission) -> io::Result<()> {
if ctx.mode == WalkMode::Strict {
let kind = omission.error.kind();
return Err(io::Error::new(
kind,
WalkFailure {
path: omission.path,
stage: omission.stage,
source: omission.error,
},
));
}
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(
ctx: &mut WalkContext<'_>,
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(
dir: &Path,
logical_dir: &Path,
sort_alphabetically: bool,
ctx: &mut WalkContext<'_>,
) -> 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(
path: &Path,
logical_path: &Path,
depth: usize,
ctx: &mut WalkContext<'_>,
) -> io::Result<()> {
walk_recursive(path, logical_path, depth, ctx)
}
fn handle_symlink_entry(
target_dir: Option<PathBuf>,
logical_path: &Path,
depth: usize,
ctx: &mut WalkContext<'_>,
) -> io::Result<()> {
if ctx.options.follow_symlinks
&& let Some(target_dir) = target_dir
{
walk_recursive(&target_dir, logical_path, depth, ctx)?;
}
Ok(())
}
fn process_entry(
entry: DirEntry,
dir: &Path,
logical_dir: &Path,
current_depth: usize,
ctx: &mut WalkContext<'_>,
) -> io::Result<()> {
let path = entry.path();
let logical_path = logical_dir.join(entry.file_name());
let file_type = match entry.file_type() {
Ok(file_type) => file_type,
Err(error) => {
return omit(
ctx,
WalkOmission {
path: logical_path,
stage: OmissionStage::EntryType,
error,
},
);
}
};
let next_depth = current_depth.saturating_add(1);
if file_type.is_dir() {
ctx.out.push(logical_path.clone());
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 {
ctx.out.push(logical_path.clone());
}
handle_symlink_entry(resolution.directory, &logical_path, next_depth, ctx)?;
} else {
ctx.out.push(logical_path);
}
Ok(())
}
fn check_directory_path(dir: &Path, canonical_root: &Path) -> io::Result<PathBuf> {
let canon = dir.canonicalize()?;
if !canon.starts_with(canonical_root) {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!(
"walked directory {} resolves outside the root",
dir.display()
),
));
}
Ok(canon)
}
fn walk_recursive(
dir: &Path,
logical_dir: &Path,
current_depth: usize,
ctx: &mut WalkContext<'_>,
) -> io::Result<()> {
if current_depth > ctx.options.max_depth || ctx.budget_exhausted {
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>())
{
return Err(error);
}
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.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(())
}
}