use std::{
collections::BTreeMap,
fs, io,
path::{Path, PathBuf},
sync::atomic::{AtomicUsize, Ordering},
time::{SystemTime, UNIX_EPOCH},
};
use crate::{
DirectoryTask, ErrorPolicy, IgnoreScope, StdBackend, WalkEntry, WalkError, WalkOptions,
WalkState, Walker,
};
static NEXT_FIXTURE: AtomicUsize = AtomicUsize::new(0);
struct Fixture {
root: PathBuf,
}
impl Fixture {
fn new(label: &str) -> Self {
let root = std::env::temp_dir().join(format!(
"ferralk-parity-{label}-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock is after epoch")
.as_nanos()
+ NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed) as u128
));
fs::create_dir_all(&root).expect("create parity fixture root");
Self { root }
}
fn directory(&self, relative: impl AsRef<Path>) -> PathBuf {
let path = self.root.join(relative);
fs::create_dir_all(&path).expect("create parity fixture directory");
path
}
fn write(&self, relative: impl AsRef<Path>) -> PathBuf {
let path = self.root.join(relative);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create parity fixture parent");
}
fs::write(&path, b"fixture").expect("write parity fixture file");
path
}
}
impl Drop for Fixture {
fn drop(&mut self) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(entries) = fs::read_dir(&self.root) {
for entry in entries.flatten() {
if entry.path().is_dir() {
let _ =
fs::set_permissions(entry.path(), fs::Permissions::from_mode(0o755));
}
}
}
}
let _ = fs::remove_dir_all(&self.root);
}
}
type DescribedEntry = (PathBuf, bool, bool, usize);
type DescribedError = (PathBuf, &'static str, io::ErrorKind);
fn describe_entries(entries: &[WalkEntry], root: &Path) -> Vec<DescribedEntry> {
let mut described: Vec<DescribedEntry> = entries
.iter()
.map(|entry| {
(
entry
.path()
.strip_prefix(root)
.expect("entry belongs to the fixture")
.to_path_buf(),
entry.is_dir(),
entry.is_symlink(),
entry.depth(),
)
})
.collect();
described.sort();
described
}
fn describe_errors(errors: &[WalkError], root: &Path) -> Vec<DescribedError> {
let mut described: Vec<DescribedError> = errors
.iter()
.map(|error| {
(
error
.path()
.strip_prefix(root)
.unwrap_or(error.path())
.to_path_buf(),
error.operation(),
error.source.kind(),
)
})
.collect();
described.sort();
described
}
fn walk_portable(walker: &Walker) -> (Vec<WalkEntry>, Vec<WalkError>) {
let mut state = WalkState::new(walker, &crate::keep_every_entry);
let root = walker
.roots()
.next()
.expect("a walk has a root")
.to_path_buf();
let (ignores, ignore_errors) = IgnoreScope::for_root(walker, &StdBackend, &root);
let task = DirectoryTask {
path: root.clone(),
open: crate::DirectoryOpen::default(),
depth: 0,
root: 0,
ancestors: crate::AncestorChain::default(),
ignores,
ignore_errors,
};
state
.walk_directory(&StdBackend, task)
.expect("the portable walk collects rather than aborts");
(state.entries, state.errors)
}
fn difference<T: Clone + Ord + std::fmt::Debug>(native: &[T], portable: &[T]) -> Option<String> {
let missing: Vec<&T> = portable
.iter()
.filter(|item| !native.contains(item))
.collect();
let extra: Vec<&T> = native
.iter()
.filter(|item| !portable.contains(item))
.collect();
if missing.is_empty() && extra.is_empty() {
return None;
}
const SHOWN: usize = 5;
let summarize = |label: &str, items: &[&T]| {
format!(
"\n {label} ({}): {:?}{}",
items.len(),
&items[..items.len().min(SHOWN)],
if items.len() > SHOWN { " ..." } else { "" }
)
};
Some(format!(
"{}{}",
summarize("only the portable backend produced", &missing),
summarize("only the native backend produced", &extra)
))
}
fn assert_parity(family: &str, root: &Path, walker: Walker) {
let native = walker.clone().collect().expect("the native walk succeeds");
let (portable_entries, portable_errors) = walk_portable(&walker);
let (native_entries, portable_entries) = (
describe_entries(native.entries(), root),
describe_entries(&portable_entries, root),
);
if native_entries != portable_entries {
let report = difference(&native_entries, &portable_entries)
.expect("unequal descriptions differ somewhere");
panic!("{family}: the backends disagree on the entries:{report}");
}
let (native_errors, portable_errors) = (
describe_errors(native.errors(), root),
describe_errors(&portable_errors, root),
);
if native_errors != portable_errors {
let report = difference(&native_errors, &portable_errors)
.expect("unequal descriptions differ somewhere");
panic!("{family}: the backends disagree on the error classes:{report}");
}
}
fn collecting_walker(root: &Path) -> Walker {
Walker::new(root)
.threads(1)
.error_policy(ErrorPolicy::Collect)
.options(WalkOptions::default().sort(true))
}
#[test]
fn parity_over_a_deep_tree() {
let fixture = Fixture::new("deep");
let mut directory = PathBuf::new();
for level in 0..14 {
directory = directory.join(format!("level-{level}"));
fixture.write(directory.join("file.txt"));
}
assert_parity("deep tree", &fixture.root, collecting_walker(&fixture.root));
}
#[cfg(target_os = "macos")]
#[test]
#[allow(unsafe_code)]
fn parity_beyond_path_max_holds_for_every_frontend() {
use std::{
ffi::CString,
os::unix::{
ffi::OsStrExt,
io::{AsRawFd, FromRawFd},
},
};
let fixture = Fixture::new("path-max");
let mut directory = fixture.root.clone();
let mut parent = fs::File::open(&fixture.root).expect("open fixture root");
let name = "d".repeat(200);
let c_name = CString::new(name.as_bytes()).expect("component contains no NUL");
while directory.as_os_str().as_bytes().len() <= libc::PATH_MAX as usize + 16 {
let created = unsafe { libc::mkdirat(parent.as_raw_fd(), c_name.as_ptr(), 0o755) };
assert_eq!(created, 0, "create one level of the long path");
let child_fd = unsafe {
libc::openat(
parent.as_raw_fd(),
c_name.as_ptr(),
libc::O_RDONLY | libc::O_CLOEXEC,
)
};
assert!(child_fd >= 0, "open one level of the long path");
parent = unsafe { fs::File::from_raw_fd(child_fd) };
directory.push(&name);
}
let walker = collecting_walker(&fixture.root);
let serial = walker
.clone()
.collect()
.expect("serial walk collects errors");
let parallel = walker
.clone()
.threads(4)
.collect()
.expect("parallel walk collects errors");
let mut streamed_entries = Vec::new();
let mut streamed_errors = Vec::new();
for item in walker.clone().stream() {
match item {
Ok(entry) => streamed_entries.push(entry),
Err(error) => streamed_errors.push(error),
}
}
let (_, portable_errors) = walk_portable(&walker);
let expected_errors = describe_errors(&portable_errors, &fixture.root);
for (frontend, entries, errors) in [
("serial", serial.entries(), serial.errors()),
("parallel", parallel.entries(), parallel.errors()),
(
"stream",
streamed_entries.as_slice(),
streamed_errors.as_slice(),
),
] {
assert!(
entries.iter().all(|entry| {
entry.path().as_os_str().as_bytes().len() < libc::PATH_MAX as usize
}),
"beyond PATH_MAX ({frontend}): emitted an unusable pathname"
);
assert_eq!(
describe_errors(errors, &fixture.root),
expected_errors,
"beyond PATH_MAX ({frontend}): error classes differ from portable"
);
}
}
#[test]
fn parity_over_a_directory_larger_than_one_read_batch() {
let fixture = Fixture::new("large");
let directory = fixture.directory("many");
for index in 0..3000 {
fs::write(
directory.join(format!("entry-{index:05}-with-a-name-of-some-length.txt")),
b"fixture",
)
.expect("write large-directory fixture");
}
assert_parity(
"large directory",
&fixture.root,
collecting_walker(&fixture.root),
);
}
#[test]
fn parity_over_empty_and_nearly_empty_directories() {
let fixture = Fixture::new("empty");
fixture.directory("empty");
fixture.directory("nested/empty");
fixture.write("nested/one.txt");
assert_parity(
"empty directories",
&fixture.root,
collecting_walker(&fixture.root),
);
}
#[cfg(unix)]
#[test]
fn parity_over_symlinks_including_broken_and_directory_links() {
use std::os::unix::fs::symlink;
let fixture = Fixture::new("symlinks");
fixture.write("real/inside.txt");
symlink("real", fixture.root.join("directory-link")).expect("create directory symlink");
symlink("real/inside.txt", fixture.root.join("file-link")).expect("create file symlink");
symlink("nowhere", fixture.root.join("broken-link")).expect("create broken symlink");
assert_parity(
"symlinks, not followed",
&fixture.root,
collecting_walker(&fixture.root),
);
assert_parity(
"symlinks, followed",
&fixture.root,
collecting_walker(&fixture.root)
.options(WalkOptions::default().sort(true).follow_symlinks(true)),
);
}
#[cfg(unix)]
#[test]
fn parity_over_a_symlink_cycle() {
use std::os::unix::fs::symlink;
let fixture = Fixture::new("cycle");
fixture.write("tree/leaf.txt");
symlink("..", fixture.root.join("tree/up")).expect("create cycle symlink");
assert_parity(
"symlink cycle",
&fixture.root,
collecting_walker(&fixture.root)
.options(WalkOptions::default().sort(true).follow_symlinks(true)),
);
}
#[cfg(unix)]
#[test]
fn parity_over_an_unreadable_directory() {
use std::os::unix::fs::PermissionsExt;
let fixture = Fixture::new("permissions");
fixture.write("readable/file.txt");
let closed = fixture.directory("closed");
fixture.write("closed/hidden.txt");
fs::set_permissions(&closed, fs::Permissions::from_mode(0o000))
.expect("close the directory's permissions");
if fs::read_dir(&closed).is_ok() {
eprintln!("skipping the unreadable-directory error check: this process can read it anyway");
}
assert_parity(
"unreadable directory",
&fixture.root,
collecting_walker(&fixture.root),
);
}
#[cfg(unix)]
#[test]
fn parity_over_a_named_pipe() {
let fixture = Fixture::new("fifo");
fixture.write("regular.txt");
let fifo = fixture.root.join("pipe");
let created = std::process::Command::new("mkfifo")
.arg(&fifo)
.status()
.is_ok_and(|status| status.success());
if !created {
eprintln!("skipping the named-pipe family: mkfifo is unavailable");
return;
}
assert_parity(
"named pipe",
&fixture.root,
collecting_walker(&fixture.root),
);
}
#[cfg(unix)]
#[test]
fn parity_over_names_at_the_length_limit() {
let fixture = Fixture::new("long-names");
for length in [1_usize, 254, 255] {
let name = "n".repeat(length);
fixture.write(format!("names/{name}"));
}
assert_parity(
"names at the length limit",
&fixture.root,
collecting_walker(&fixture.root),
);
}
#[cfg(target_os = "linux")]
#[test]
fn parity_over_non_utf8_names() {
use std::{ffi::OsString, os::unix::ffi::OsStringExt};
let fixture = Fixture::new("non-utf8");
for name in [
vec![b'n', 0xFF],
vec![0xC3, 0x28],
vec![0xFE, 0xFF, b'.', b'x'],
] {
fixture.write(PathBuf::from(OsString::from_vec(name)));
}
assert_parity(
"non-UTF-8 names",
&fixture.root,
collecting_walker(&fixture.root),
);
}
#[test]
fn parity_over_filters_hidden_paths_and_ignore_rules() {
let fixture = Fixture::new("filters");
fixture.write("src/lib.rs");
fixture.write("src/nested/mod.rs");
fixture.write("src/generated.tmp");
fixture.write("ignored/skip.rs");
fixture.write(".hidden/skip.rs");
fixture.write(".gitignore");
fs::write(fixture.root.join(".gitignore"), b"ignored/\n").expect("write the ignore file");
let walker = collecting_walker(&fixture.root)
.include("**/*")
.expect("valid include")
.exclude("**/*.tmp")
.expect("valid exclude")
.respect_git_ignore(true)
.options(
WalkOptions::default()
.sort(true)
.metadata(true)
.skip_hidden(true),
);
assert_parity("filters and ignore rules", &fixture.root, walker);
}
#[test]
fn parity_holds_through_the_parallel_scheduler() {
let fixture = Fixture::new("parallel");
for branch in 0..8 {
for level in 0..4 {
fixture.write(format!("branch-{branch}/level-{level}/file.txt"));
}
}
let root = &fixture.root;
let parallel = Walker::new(root)
.threads(4)
.error_policy(ErrorPolicy::Collect)
.options(WalkOptions::default().sort(true))
.collect()
.expect("the parallel native walk succeeds");
let (portable_entries, portable_errors) = walk_portable(&collecting_walker(root));
let (parallel_entries, portable_entries) = (
describe_entries(parallel.entries(), root),
describe_entries(&portable_entries, root),
);
if parallel_entries != portable_entries {
let report = difference(¶llel_entries, &portable_entries)
.expect("unequal descriptions differ somewhere");
panic!("parallel scheduler: the backends disagree on the entries:{report}");
}
assert_eq!(
describe_errors(parallel.errors(), root),
describe_errors(&portable_errors, root),
"parallel scheduler: the backends disagree on the error classes"
);
}
#[test]
fn the_family_matrix_matches_this_platform() {
let mut families: BTreeMap<&str, bool> = BTreeMap::new();
families.insert("deep tree", true);
families.insert("beyond PATH_MAX", cfg!(target_os = "macos"));
families.insert("large directory", true);
families.insert("empty directories", true);
families.insert("filters and ignore rules", true);
families.insert("parallel scheduler", true);
families.insert("symlinks", cfg!(unix));
families.insert("symlink cycle", cfg!(unix));
families.insert("unreadable directory", cfg!(unix));
families.insert("named pipe", cfg!(unix));
families.insert("names at the length limit", cfg!(unix));
families.insert("non-UTF-8 names", cfg!(target_os = "linux"));
assert_eq!(
families["non-UTF-8 names"],
cfg!(target_os = "linux"),
"the non-UTF-8 family belongs to Linux only; APFS rejects such names"
);
let running = families.values().filter(|present| **present).count();
assert_eq!(
running,
11,
"this platform runs {running} of {} parity families",
families.len()
);
}