use std::collections::HashSet;
use std::fs;
use std::io;
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use crate::entry::{Entry, EntryKind};
const S_IFMT: u32 = 0o170_000;
const S_IFSOCK: u32 = 0o140_000;
const S_IFLNK: u32 = 0o120_000;
const S_IFREG: u32 = 0o100_000;
const S_IFBLK: u32 = 0o060_000;
const S_IFDIR: u32 = 0o040_000;
const S_IFCHR: u32 = 0o020_000;
const S_IFIFO: u32 = 0o010_000;
#[derive(Debug, Default)]
pub struct DirListing {
pub entries: Vec<Entry>,
pub errors: Vec<(PathBuf, io::Error)>,
pub owner_uid: Option<u32>,
}
pub fn collect_directory(path: &Path) -> io::Result<DirListing> {
let mut iter = fs::read_dir(path)?.map(|r| r.map(|de| de.path()));
let mut listing = process_paths(&mut iter, path);
listing.owner_uid = fs::metadata(path).ok().map(|m| m.uid());
Ok(listing)
}
fn process_paths(iter: &mut dyn Iterator<Item = io::Result<PathBuf>>, parent: &Path) -> DirListing {
let mut listing = DirListing::default();
for r in iter {
match r {
Ok(child) => match entry_for_path(&child) {
Ok(e) => listing.entries.push(e),
Err(source) => listing.errors.push((child, source)),
},
Err(source) => listing.errors.push((parent.to_path_buf(), source)),
}
}
listing
}
pub fn entry_for_path(path: &Path) -> io::Result<Entry> {
let lmeta = fs::symlink_metadata(path)?;
let lkind = classify(lmeta.mode());
if lkind != EntryKind::Symlink {
return Ok(make_entry(path, &lmeta, lkind));
}
let (meta, kind) = fs::metadata(path).map_or((lmeta, lkind), |tmeta| {
let tkind = classify(tmeta.mode());
(tmeta, tkind)
});
let mut entry = make_entry(path, &meta, kind);
entry.follow_chain = build_follow_chain(path);
Ok(entry)
}
fn build_follow_chain(start: &Path) -> Vec<PathBuf> {
const MAX_HOPS: usize = 40;
let mut chain = Vec::new();
let mut visited = HashSet::new();
let mut current = start.to_path_buf();
for _ in 0..MAX_HOPS {
let Ok(target) = fs::read_link(¤t) else {
break;
};
let next = if target.is_absolute() {
target.clone()
} else {
current.parent().unwrap_or(¤t).join(&target)
};
chain.push(target);
match fs::symlink_metadata(&next) {
Ok(m) if m.file_type().is_symlink() && visited.insert(next.clone()) => current = next,
_ => break,
}
}
chain
}
fn make_entry(path: &Path, meta: &fs::Metadata, kind: EntryKind) -> Entry {
let name = path.file_name().map_or_else(
|| path.as_os_str().to_os_string(),
std::ffi::OsStr::to_os_string,
);
Entry {
name,
path: path.to_path_buf(),
kind,
mode: meta.mode(),
nlink: meta.nlink(),
uid: meta.uid(),
gid: meta.gid(),
size: meta.size(),
rdev: meta.rdev(),
mtime: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH),
dev: meta.dev(),
ino: meta.ino(),
follow_chain: Vec::new(),
}
}
#[must_use]
pub const fn classify(mode: u32) -> EntryKind {
match mode & S_IFMT {
S_IFDIR => EntryKind::Directory,
S_IFLNK => EntryKind::Symlink,
S_IFCHR => EntryKind::CharDevice,
S_IFBLK => EntryKind::BlockDevice,
S_IFIFO => EntryKind::Fifo,
S_IFSOCK => EntryKind::Socket,
S_IFREG => EntryKind::RegularFile,
_ => EntryKind::Other,
}
}
#[cfg(test)]
mod tests {
use super::{
S_IFBLK, S_IFCHR, S_IFDIR, S_IFIFO, S_IFLNK, S_IFREG, S_IFSOCK, build_follow_chain,
classify, collect_directory, entry_for_path, process_paths,
};
use crate::entry::EntryKind;
use std::fs;
use std::os::unix::fs::{PermissionsExt, symlink};
use std::path::PathBuf;
use tempfile::tempdir;
#[test]
fn classify_recognises_every_posix_type() {
assert_eq!(classify(S_IFDIR | 0o755), EntryKind::Directory);
assert_eq!(classify(S_IFREG | 0o644), EntryKind::RegularFile);
assert_eq!(classify(S_IFLNK | 0o777), EntryKind::Symlink);
assert_eq!(classify(S_IFCHR | 0o666), EntryKind::CharDevice);
assert_eq!(classify(S_IFBLK | 0o660), EntryKind::BlockDevice);
assert_eq!(classify(S_IFIFO | 0o644), EntryKind::Fifo);
assert_eq!(classify(S_IFSOCK | 0o755), EntryKind::Socket);
assert_eq!(classify(0), EntryKind::Other);
}
#[test]
fn collect_lists_all_entries_including_hidden() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("a"), b"hello").unwrap();
fs::write(dir.path().join(".hidden"), b"hi").unwrap();
fs::create_dir(dir.path().join("sub")).unwrap();
let mut listing = collect_directory(dir.path()).unwrap();
listing.entries.sort_by(|x, y| x.name.cmp(&y.name));
let names: Vec<_> = listing
.entries
.iter()
.map(|e| e.name.to_string_lossy().into_owned())
.collect();
assert_eq!(names, vec![".hidden", "a", "sub"]);
assert!(listing.errors.is_empty());
let sub = listing.entries.iter().find(|e| e.name == "sub").unwrap();
assert_eq!(sub.kind, EntryKind::Directory);
let a = listing.entries.iter().find(|e| e.name == "a").unwrap();
assert_eq!(a.kind, EntryKind::RegularFile);
assert_eq!(a.size, 5);
}
#[test]
fn collect_records_per_child_stat_failure_without_aborting() {
let dir = tempdir().unwrap();
let inner = dir.path().join("inner");
fs::create_dir(&inner).unwrap();
fs::write(inner.join("a"), b"hi").unwrap();
let mut p = fs::metadata(&inner).unwrap().permissions();
p.set_mode(0o400);
fs::set_permissions(&inner, p).unwrap();
let listing = collect_directory(&inner);
let mut p = fs::metadata(&inner).unwrap().permissions();
p.set_mode(0o755);
fs::set_permissions(&inner, p).unwrap();
let listing = listing.unwrap();
assert!(listing.entries.is_empty());
assert_eq!(listing.errors.len(), 1);
assert_eq!(listing.errors[0].0, inner.join("a"));
}
#[test]
fn entry_for_path_broken_symlink_still_classifies() {
let dir = tempdir().unwrap();
let link = dir.path().join("dangling");
symlink(dir.path().join("nope"), &link).unwrap();
let entry = entry_for_path(&link).unwrap();
assert_eq!(entry.kind, EntryKind::Symlink);
assert_eq!(entry.follow_chain, vec![dir.path().join("nope")]);
}
#[test]
fn entry_for_path_symlink_cycle_falls_back_without_looping() {
let dir = tempdir().unwrap();
let a = dir.path().join("loop_a");
let b = dir.path().join("loop_b");
symlink(&b, &a).unwrap();
symlink(&a, &b).unwrap();
let entry = entry_for_path(&a).unwrap();
assert_eq!(entry.kind, EntryKind::Symlink);
let hops = entry.follow_chain.len();
assert!(
hops > 0 && hops < 10,
"cycle guard should truncate, got {hops} hops"
);
}
#[test]
fn entry_for_path_reports_target_metadata_for_file() {
let dir = tempdir().unwrap();
let target = dir.path().join("target");
fs::write(&target, b"contents").unwrap();
let link = dir.path().join("link");
symlink(&target, &link).unwrap();
let entry = entry_for_path(&link).unwrap();
assert_eq!(entry.kind, EntryKind::RegularFile);
assert!(!entry.is_broken_link());
assert_eq!(entry.size, b"contents".len() as u64);
}
#[test]
fn entry_for_path_reports_target_kind_for_directory() {
let dir = tempdir().unwrap();
let target = dir.path().join("target_dir");
fs::create_dir(&target).unwrap();
let link = dir.path().join("link_to_dir");
symlink(&target, &link).unwrap();
let entry = entry_for_path(&link).unwrap();
assert_eq!(entry.kind, EntryKind::Directory);
assert!(!entry.is_broken_link());
}
#[test]
fn entry_for_path_falls_back_on_broken_symlink() {
let dir = tempdir().unwrap();
let target = dir.path().join("nope");
let link = dir.path().join("dangling");
symlink(&target, &link).unwrap();
let entry = entry_for_path(&link).unwrap();
assert_eq!(entry.kind, EntryKind::Symlink);
assert_eq!(entry.size, target.as_os_str().len() as u64);
}
#[test]
fn entry_for_path_follow_chain_records_single_hop() {
let dir = tempdir().unwrap();
let target = dir.path().join("target");
fs::write(&target, b"x").unwrap();
let link = dir.path().join("link");
symlink(&target, &link).unwrap();
let entry = entry_for_path(&link).unwrap();
assert_eq!(entry.follow_chain, vec![target]);
}
#[test]
fn entry_for_path_follow_chain_records_each_hop() {
let dir = tempdir().unwrap();
let target = dir.path().join("target");
fs::write(&target, b"x").unwrap();
symlink("target", dir.path().join("mid")).unwrap();
symlink("mid", dir.path().join("top")).unwrap();
let entry = entry_for_path(&dir.path().join("top")).unwrap();
assert_eq!(
entry.follow_chain,
vec![PathBuf::from("mid"), PathBuf::from("target"),]
);
}
#[test]
fn entry_for_path_follow_chain_records_break_on_broken_link() {
let dir = tempdir().unwrap();
let link = dir.path().join("dangling");
symlink(dir.path().join("nope"), &link).unwrap();
let entry = entry_for_path(&link).unwrap();
assert_eq!(entry.follow_chain, vec![dir.path().join("nope")]);
}
#[test]
fn entry_for_path_follow_chain_records_multi_hop_break() {
let dir = tempdir().unwrap();
symlink("c", dir.path().join("b")).unwrap();
symlink("b", dir.path().join("a")).unwrap();
let entry = entry_for_path(&dir.path().join("a")).unwrap();
assert_eq!(entry.kind, EntryKind::Symlink);
assert_eq!(
entry.follow_chain,
vec![PathBuf::from("b"), PathBuf::from("c")]
);
}
#[test]
fn entry_for_path_follow_chain_empty_for_regular_file() {
let dir = tempdir().unwrap();
let file = dir.path().join("plain");
fs::write(&file, b"x").unwrap();
let entry = entry_for_path(&file).unwrap();
assert_eq!(entry.kind, EntryKind::RegularFile);
assert!(entry.follow_chain.is_empty());
}
#[test]
fn build_follow_chain_breaks_when_start_is_not_a_symlink() {
let dir = tempdir().unwrap();
let regular = dir.path().join("plain");
fs::write(®ular, b"").unwrap();
assert!(build_follow_chain(®ular).is_empty());
}
#[test]
fn collect_errors_on_missing_path() {
let dir = tempdir().unwrap();
let missing = dir.path().join("nope");
collect_directory(&missing).unwrap_err();
}
#[test]
fn entry_for_path_errors_on_missing() {
let dir = tempdir().unwrap();
entry_for_path(&dir.path().join("nope")).unwrap_err();
}
#[test]
fn root_path_has_a_name() {
let entry = entry_for_path(std::path::Path::new("/")).unwrap();
assert!(!entry.name.is_empty());
}
#[test]
fn process_paths_records_iter_errors_against_parent() {
use std::io;
use std::path::Path;
let synthetic: Vec<io::Result<std::path::PathBuf>> =
vec![Err(io::Error::other("synthetic"))];
let mut iter = synthetic.into_iter();
let listing = process_paths(&mut iter, Path::new("/synthetic-parent"));
assert!(listing.entries.is_empty());
assert_eq!(listing.errors.len(), 1);
assert_eq!(listing.errors[0].0, Path::new("/synthetic-parent"));
}
}