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,
}
#[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>,
}
impl WalkReport {
#[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.omissions.is_empty()
}
}
#[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>,
}
pub fn walk_dir(root: impl AsRef<Path>, options: &WalkOptions) -> io::Result<Vec<PathBuf>> {
let canonical_root = root.as_ref().canonicalize()?;
let mut out = Vec::new();
let mut visited = HashSet::new();
let mut omissions = Vec::new();
let mut ctx = WalkContext {
canonical_root: &canonical_root,
options,
mode: WalkMode::Strict,
out: &mut out,
omissions: &mut omissions,
visited: &mut visited,
};
walk_recursive(root.as_ref(), 0, &mut ctx)?;
debug_assert!(
ctx.omissions.is_empty(),
"strict mode records no omissions: the first one returns"
);
Ok(out)
}
pub fn walk_dir_tolerant(root: impl AsRef<Path>, options: &WalkOptions) -> io::Result<WalkReport> {
let canonical_root = root.as_ref().canonicalize()?;
let mut report = WalkReport {
entries: Vec::new(),
omissions: Vec::new(),
};
let mut visited = HashSet::new();
let mut ctx = WalkContext {
canonical_root: &canonical_root,
options,
mode: WalkMode::Tolerant,
out: &mut report.entries,
omissions: &mut report.omissions,
visited: &mut visited,
};
walk_recursive(root.as_ref(), 0, &mut ctx)?;
Ok(report)
}
fn omit(
mode: WalkMode,
omissions: &mut Vec<WalkOmission>,
omission: WalkOmission,
) -> io::Result<()> {
if mode == WalkMode::Strict {
return Err(omission.error);
}
omissions.push(omission);
Ok(())
}
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,
sort_alphabetically: bool,
) -> io::Result<(Vec<DirEntry>, Vec<io::Error>)> {
let mut entries = Vec::new();
let mut item_errors = Vec::new();
for item in fs::read_dir(dir)? {
match item {
Ok(entry) => entries.push(entry),
Err(error) => item_errors.push(error),
}
}
if sort_alphabetically {
entries.sort_by_key(|entry| entry.file_name());
}
Ok((entries, item_errors))
}
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, depth: usize, ctx: &mut WalkContext<'_>) -> io::Result<()> {
walk_recursive(path, depth, ctx)
}
fn handle_symlink_entry(
target_dir: Option<PathBuf>,
depth: usize,
ctx: &mut WalkContext<'_>,
) -> io::Result<()> {
if ctx.options.follow_symlinks
&& let Some(target_dir) = target_dir
{
walk_recursive(&target_dir, depth, ctx)?;
}
Ok(())
}
fn process_entry(
entry: DirEntry,
dir: &Path,
current_depth: usize,
ctx: &mut WalkContext<'_>,
) -> io::Result<()> {
let path = entry.path();
let file_type = match entry.file_type() {
Ok(file_type) => file_type,
Err(error) => {
return omit(
ctx.mode,
ctx.omissions,
WalkOmission {
path,
stage: OmissionStage::EntryType,
error,
},
);
}
};
let next_depth = current_depth.saturating_add(1);
if file_type.is_dir() {
ctx.out.push(path.clone());
handle_directory_entry(&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.mode,
ctx.omissions,
WalkOmission {
path: path.clone(),
stage: OmissionStage::SymlinkTarget,
error,
},
)?;
return Ok(());
}
};
if resolution.within_root {
ctx.out.push(path.clone());
}
handle_symlink_entry(resolution.directory, next_depth, ctx)?;
} else {
ctx.out.push(path.clone());
}
Ok(())
}
fn check_directory_path(dir: &Path, canonical_root: &Path) -> io::Result<PathBuf> {
let canon = dir.canonicalize().map_err(|error| {
io::Error::new(
error.kind(),
format!("cannot resolve walked directory {}: {error}", dir.display()),
)
})?;
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, current_depth: usize, ctx: &mut WalkContext<'_>) -> io::Result<()> {
if current_depth > ctx.options.max_depth {
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.mode,
ctx.omissions,
WalkOmission {
path: 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, ctx.options.sort_alphabetically) {
Ok((entries, item_errors)) => {
for error in item_errors {
omit(
ctx.mode,
ctx.omissions,
WalkOmission {
path: dir.to_path_buf(),
stage: OmissionStage::ReadEntries,
error,
},
)?;
}
entries
}
Err(error) => {
return omit(
ctx.mode,
ctx.omissions,
WalkOmission {
path: dir.to_path_buf(),
stage: OmissionStage::ReadEntries,
error,
},
);
}
};
for entry in listed {
process_entry(entry, dir, current_depth, ctx)?;
}
match check_directory_path(dir, ctx.canonical_root) {
Ok(again) if again == canonical_path => Ok(()),
Ok(_) | Err(_) => {
ctx.out.truncate(out_len);
ctx.omissions.truncate(omissions_len);
omit(
ctx.mode,
ctx.omissions,
WalkOmission {
path: dir.to_path_buf(),
stage: OmissionStage::DirectoryResolution,
error: io::Error::new(
io::ErrorKind::InvalidData,
format!(
"walked directory {} changed canonical path during the read",
dir.display()
),
),
},
)
}
}
}
#[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 entries = walk_dir(tmp.path().join("a"), &opts)?;
for entry in &entries {
assert_eq!(entry.parent(), Some(tmp.path().join("a").as_path()));
}
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"
);
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"
);
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"
);
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());
}
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);
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());
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)?;
assert!(walk_dir(&dangling, &WalkOptions::default()).is_err());
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 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, link);
assert_eq!(report.omissions()[0].stage, OmissionStage::SymlinkTarget);
assert_eq!(
report.omissions()[0].error.kind(),
std::io::ErrorKind::NotFound
);
assert!(!report.is_complete());
Ok(())
}
}