use std::{
cell::RefCell,
ffi::{CString, OsStr, OsString, c_int, c_void},
fs::{self, File, OpenOptions},
io,
ops::Range,
os::unix::{
ffi::OsStrExt,
fs::OpenOptionsExt,
io::{AsRawFd, FromRawFd, IntoRawFd},
},
path::Path,
sync::{
Arc, OnceLock,
atomic::{AtomicBool, Ordering},
},
};
#[cfg(test)]
use std::path::PathBuf;
#[cfg(test)]
use std::sync::{Mutex, MutexGuard};
use super::{Listing, defer_entry_stat_error};
const BUFFER_SIZE: usize = 32 * 1024;
const GETDIRENTRIES64_EXTENDED_BUFFER_MINIMUM: usize = 1024;
const GETDIRENTRIES64_EOF: u32 = 0x1;
const RECORD_LENGTH_OFFSET: usize = 16;
const NAME_LENGTH_OFFSET: usize = 18;
const TYPE_OFFSET: usize = 20;
const NAME_OFFSET: usize = 21;
const DT_DIR: u8 = 4;
const DT_REG: u8 = 8;
const DT_LNK: u8 = 10;
const DT_FIFO: u8 = 1;
const DT_CHR: u8 = 2;
const DT_BLK: u8 = 6;
const DT_SOCK: u8 = 12;
const ATTR_BIT_MAP_COUNT: u16 = 5;
const ATTR_CMN_NAME: u32 = 0x0000_0001;
const ATTR_CMN_OBJTYPE: u32 = 0x0000_0008;
const ATTR_CMN_ERROR: u32 = 0x2000_0000;
const ATTR_CMN_RETURNED_ATTRS: u32 = 0x8000_0000;
const FSOPT_NOFOLLOW: u64 = 0x0000_0001;
const VREG: u32 = 1;
const VDIR: u32 = 2;
const VBLK: u32 = 3;
const VCHR: u32 = 4;
const VLNK: u32 = 5;
const VSOCK: u32 = 6;
const VFIFO: u32 = 7;
const ATTRIBUTE_SET_SIZE: usize = 5 * std::mem::size_of::<u32>();
const ATTRIBUTE_RECORD_HEADER_SIZE: usize = std::mem::size_of::<u32>() + ATTRIBUTE_SET_SIZE;
static NATIVE_UNSUPPORTED: AtomicBool = AtomicBool::new(false);
#[cfg(test)]
static NATIVE_LATCH_TEST_LOCK: Mutex<()> = Mutex::new(());
#[cfg(test)]
static FORCED_UNSUPPORTED_ROOT: Mutex<Option<PathBuf>> = Mutex::new(None);
#[cfg(test)]
pub(super) struct UnsupportedLatchGuard {
previous: Option<PathBuf>,
_lock: MutexGuard<'static, ()>,
}
#[cfg(test)]
impl Drop for UnsupportedLatchGuard {
fn drop(&mut self) {
*FORCED_UNSUPPORTED_ROOT
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = self.previous.take();
}
}
#[cfg(test)]
pub(super) fn force_unsupported_latch_for_test(root: &Path) -> UnsupportedLatchGuard {
let lock = NATIVE_LATCH_TEST_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let previous = FORCED_UNSUPPORTED_ROOT
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.replace(root.to_path_buf());
UnsupportedLatchGuard {
previous,
_lock: lock,
}
}
fn unsupported_is_latched_for(_path: &Path) -> bool {
if NATIVE_UNSUPPORTED.load(Ordering::Relaxed) {
return true;
}
#[cfg(test)]
return FORCED_UNSUPPORTED_ROOT
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.as_ref()
.is_some_and(|root| _path.starts_with(root));
#[cfg(not(test))]
false
}
const MAX_RETAINED_DIRECTORIES: usize = 256;
static RETAINED_DIRECTORIES: std::sync::atomic::AtomicUsize =
std::sync::atomic::AtomicUsize::new(0);
static RETAINED_DIRECTORY_LIMIT: OnceLock<usize> = OnceLock::new();
fn retained_directory_limit() -> usize {
*RETAINED_DIRECTORY_LIMIT.get_or_init(|| {
let mut limits = std::mem::MaybeUninit::<libc::rlimit>::uninit();
let status = unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, limits.as_mut_ptr()) };
if status != 0 {
return 64;
}
let soft_limit = unsafe { limits.assume_init() }.rlim_cur;
usize::try_from(soft_limit)
.unwrap_or(MAX_RETAINED_DIRECTORIES)
.saturating_div(4)
.min(MAX_RETAINED_DIRECTORIES)
})
}
std::thread_local! {
static DIRENTRIES_BUFFER: RefCell<Box<[u8; BUFFER_SIZE]>> = RefCell::new(Box::new([0; BUFFER_SIZE]));
}
#[derive(Debug)]
pub(super) enum NativeDirectoryReadError {
CapabilityUnavailable(io::Error),
Io(io::Error),
}
#[derive(Debug, Clone)]
pub(super) struct RelativeDirectoryOpen {
pub(super) parent: Arc<RetainedDirectory>,
pub(super) name: OsString,
}
#[derive(Debug)]
pub(super) struct RetainedDirectory(File);
impl RetainedDirectory {
fn retain(directory: File) -> Option<Arc<Self>> {
RETAINED_DIRECTORIES
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |retained| {
(retained < retained_directory_limit()).then_some(retained + 1)
})
.ok()
.map(|_| Arc::new(Self(directory)))
}
}
impl Drop for RetainedDirectory {
fn drop(&mut self) {
RETAINED_DIRECTORIES.fetch_sub(1, Ordering::AcqRel);
}
}
impl From<io::Error> for NativeDirectoryReadError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
impl NativeDirectoryReadError {
pub(super) fn into_io_error(self) -> io::Error {
match self {
Self::CapabilityUnavailable(error) | Self::Io(error) => error,
}
}
}
type NativeDirectoryReadResult = Result<(), NativeDirectoryReadError>;
#[repr(C)]
struct AttrList {
bitmap_count: u16,
reserved: u16,
common_attributes: u32,
volume_attributes: u32,
directory_attributes: u32,
file_attributes: u32,
fork_attributes: u32,
}
unsafe extern "C" {
fn getattrlistbulk(
fd: c_int,
attributes: *const AttrList,
buffer: *mut c_void,
buffer_size: usize,
options: u64,
) -> c_int;
fn __getdirentries64(
fd: c_int,
buffer: *mut c_void,
buffer_size: usize,
base: *mut u64,
) -> isize;
}
pub(super) fn read_directory(
path: &Path,
relative: Option<&RelativeDirectoryOpen>,
refuse_final_symlink: bool,
listing: &mut Listing,
) -> NativeDirectoryReadResult {
if unsupported_is_latched_for(path) {
read_portable_directory_from_path(path, relative, refuse_final_symlink, listing)?;
Ok(())
} else {
read_direntries_directory(path, relative, refuse_final_symlink, listing)
}
}
fn unsupported(message: &'static str) -> io::Error {
io::Error::new(io::ErrorKind::Unsupported, message)
}
fn open_directory(path: &Path, refuse_final_symlink: bool) -> io::Result<File> {
let flags = libc::O_DIRECTORY
| if refuse_final_symlink {
libc::O_NOFOLLOW
} else {
0
};
OpenOptions::new().read(true).custom_flags(flags).open(path)
}
fn open_scheduled_directory(
path: &Path,
relative: Option<&RelativeDirectoryOpen>,
refuse_final_symlink: bool,
) -> io::Result<File> {
if path.as_os_str().as_bytes().len() >= libc::PATH_MAX as usize {
return Err(io::Error::from_raw_os_error(libc::ENAMETOOLONG));
}
let Some(relative) = relative else {
return open_directory(path, refuse_final_symlink);
};
let name = CString::new(relative.name.as_bytes())
.map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?;
let flags = libc::O_RDONLY
| libc::O_CLOEXEC
| libc::O_DIRECTORY
| if refuse_final_symlink {
libc::O_NOFOLLOW
} else {
0
};
let descriptor = unsafe { libc::openat(relative.parent.0.as_raw_fd(), name.as_ptr(), flags) };
if descriptor < 0 {
return Err(io::Error::last_os_error());
}
Ok(unsafe { File::from_raw_fd(descriptor) })
}
#[cfg_attr(not(test), allow(dead_code))]
fn read_bulk_directory(path: &Path, listing: &mut Listing) -> io::Result<()> {
let mut buffer = Box::new([0_u8; BUFFER_SIZE]);
read_bulk_directory_with_buffer(path, &mut buffer[..], listing)
}
#[cfg_attr(not(test), allow(dead_code))]
fn read_bulk_directory_with_buffer(
path: &Path,
buffer: &mut [u8],
listing: &mut Listing,
) -> io::Result<()> {
let directory = open_directory(path, false)?;
listing.clear();
let attributes = AttrList {
bitmap_count: ATTR_BIT_MAP_COUNT,
reserved: 0,
common_attributes: ATTR_CMN_RETURNED_ATTRS
| ATTR_CMN_ERROR
| ATTR_CMN_NAME
| ATTR_CMN_OBJTYPE,
volume_attributes: 0,
directory_attributes: 0,
file_attributes: 0,
fork_attributes: 0,
};
let mut reader = BulkReader {
directory,
buffer,
attributes,
remaining: 0,
offset: 0,
primed: false,
};
while let Some((name, object_type)) = reader.next()? {
let name = OsStr::from_bytes(&reader.buffer[name]);
match bulk_entry_kind(path, name, object_type) {
Ok(Some((is_dir, is_symlink))) => listing.push(name, is_dir, is_symlink),
Ok(None) => {}
Err(error) => defer_entry_stat_error(listing, path.join(name), error)?,
}
}
Ok(())
}
struct BulkReader<'buffer> {
directory: File,
buffer: &'buffer mut [u8],
attributes: AttrList,
remaining: usize,
offset: usize,
primed: bool,
}
impl BulkReader<'_> {
fn next(&mut self) -> io::Result<Option<(Range<usize>, Option<u32>)>> {
loop {
if self.remaining == 0 && !self.refill()? {
return Ok(None);
}
let record_length = read_u32(self.buffer, self.offset)? as usize;
if record_length < ATTRIBUTE_RECORD_HEADER_SIZE
|| record_length > self.buffer.len().saturating_sub(self.offset)
{
return Err(malformed_bulk_record());
}
let record_end = self
.offset
.checked_add(record_length)
.ok_or_else(malformed_bulk_record)?;
let record = &self.buffer[self.offset..record_end];
self.offset = record_end;
self.remaining -= 1;
if let Some((name, object_type)) = parse_bulk_record(record)? {
let start = record_end - record_length;
return Ok(Some((start + name.start..start + name.end, object_type)));
}
}
}
fn refill(&mut self) -> io::Result<bool> {
loop {
let count = unsafe {
getattrlistbulk(
self.directory.as_raw_fd(),
&self.attributes,
self.buffer.as_mut_ptr().cast(),
self.buffer.len(),
FSOPT_NOFOLLOW,
)
};
if count >= 0 {
self.primed = true;
self.remaining = count as usize;
self.offset = 0;
return Ok(count != 0);
}
let error = io::Error::last_os_error();
if error.kind() == io::ErrorKind::Interrupted {
continue;
}
if is_unsupported_bulk_error(&error, self.primed) {
return Err(io::Error::new(io::ErrorKind::Unsupported, error));
}
return Err(error);
}
}
}
fn is_unsupported_bulk_error(error: &io::Error, primed: bool) -> bool {
!primed && matches!(error.raw_os_error(), Some(22 | 45))
}
fn parse_bulk_record(record: &[u8]) -> io::Result<Option<(Range<usize>, Option<u32>)>> {
if record.len() < ATTRIBUTE_RECORD_HEADER_SIZE || read_u32(record, 0)? as usize != record.len()
{
return Err(malformed_bulk_record());
}
let common_attributes = read_u32(record, std::mem::size_of::<u32>())?;
if common_attributes & ATTR_CMN_RETURNED_ATTRS == 0 {
return Err(malformed_bulk_record());
}
let mut offset = ATTRIBUTE_RECORD_HEADER_SIZE;
if common_attributes & ATTR_CMN_ERROR != 0 {
let error = read_u32(record, offset)?;
offset = offset
.checked_add(std::mem::size_of::<u32>())
.ok_or_else(malformed_bulk_record)?;
if error != 0 {
return Ok(None);
}
}
if common_attributes & ATTR_CMN_NAME == 0 {
return Err(unsupported(
"getattrlistbulk did not return entry names on this filesystem",
));
}
let reference_offset = read_i32(record, offset)?;
let name_length = read_u32(record, offset + std::mem::size_of::<i32>())? as usize;
let reference_position = offset;
offset = offset
.checked_add(std::mem::size_of::<i32>() + std::mem::size_of::<u32>())
.ok_or_else(malformed_bulk_record)?;
let name_start = if reference_offset >= 0 {
reference_position.checked_add(reference_offset as usize)
} else {
reference_position.checked_sub(reference_offset.unsigned_abs() as usize)
}
.ok_or_else(malformed_bulk_record)?;
let name_end = name_start
.checked_add(name_length)
.filter(|&end| end <= record.len())
.ok_or_else(malformed_bulk_record)?;
let name_with_nul = record
.get(name_start..name_end)
.ok_or_else(malformed_bulk_record)?;
let Some((&0, name)) = name_with_nul.split_last() else {
return Err(malformed_bulk_record());
};
if name.is_empty() || memchr::memchr2(0, b'/', name).is_some() {
return Err(malformed_bulk_record());
}
let name = name_start..name_end - 1;
if common_attributes & ATTR_CMN_OBJTYPE == 0 {
return Ok(Some((name, None)));
}
let object_type = read_u32(record, offset)?;
Ok(Some((name, Some(object_type))))
}
#[doc(hidden)]
pub fn fuzz_validate_bulk_record(record: &[u8]) {
let _ = parse_bulk_record(record);
}
fn read_u32(bytes: &[u8], offset: usize) -> io::Result<u32> {
let value = bytes
.get(offset..offset + std::mem::size_of::<u32>())
.ok_or_else(malformed_bulk_record)?;
Ok(u32::from_ne_bytes(
value
.try_into()
.expect("u32 slice has fixed width after bounds check"),
))
}
fn read_i32(bytes: &[u8], offset: usize) -> io::Result<i32> {
let value = bytes
.get(offset..offset + std::mem::size_of::<i32>())
.ok_or_else(malformed_bulk_record)?;
Ok(i32::from_ne_bytes(
value
.try_into()
.expect("i32 slice has fixed width after bounds check"),
))
}
fn bulk_entry_kind(
directory: &Path,
name: &OsStr,
object_type: Option<u32>,
) -> io::Result<Option<(bool, bool)>> {
match object_type {
Some(VDIR) => Ok(Some((true, false))),
Some(VREG | VBLK | VCHR | VSOCK | VFIFO) => Ok(Some((false, false))),
Some(VLNK) => Ok(Some((false, true))),
_ => stat_entry_kind(&directory.join(name)),
}
}
fn read_direntries_directory(
path: &Path,
relative: Option<&RelativeDirectoryOpen>,
refuse_final_symlink: bool,
listing: &mut Listing,
) -> NativeDirectoryReadResult {
DIRENTRIES_BUFFER.with(|buffer| {
let mut buffer = buffer.borrow_mut();
read_direntries_directory_with_buffer(
path,
relative,
refuse_final_symlink,
&mut buffer[..],
listing,
)
})
}
fn read_direntries_directory_with_buffer(
path: &Path,
relative: Option<&RelativeDirectoryOpen>,
refuse_final_symlink: bool,
buffer: &mut [u8],
listing: &mut Listing,
) -> NativeDirectoryReadResult {
let used_portable_fallback = read_open_directory_with_portable_fallback(
path,
relative,
refuse_final_symlink,
buffer,
listing,
read_direntries_from_open_directory,
)?;
if used_portable_fallback {
NATIVE_UNSUPPORTED.store(true, Ordering::Relaxed);
}
Ok(())
}
fn read_open_directory_with_portable_fallback(
path: &Path,
relative: Option<&RelativeDirectoryOpen>,
refuse_final_symlink: bool,
buffer: &mut [u8],
listing: &mut Listing,
native: impl FnOnce(&File, &Path, &mut [u8], &mut Listing) -> NativeDirectoryReadResult,
) -> Result<bool, NativeDirectoryReadError> {
let directory = open_scheduled_directory(path, relative, refuse_final_symlink)?;
match native(&directory, path, buffer, listing) {
Ok(()) => {
listing.native_directory = RetainedDirectory::retain(directory);
Ok(false)
}
Err(NativeDirectoryReadError::CapabilityUnavailable(_)) => {
read_portable_directory_from_open_file(directory, path, listing)?;
Ok(true)
}
Err(error) => Err(error),
}
}
fn read_portable_directory_from_path(
path: &Path,
relative: Option<&RelativeDirectoryOpen>,
refuse_final_symlink: bool,
listing: &mut Listing,
) -> io::Result<()> {
let directory = open_scheduled_directory(path, relative, refuse_final_symlink)?;
read_portable_directory_from_open_file(directory, path, listing)
}
fn read_portable_directory_from_open_file(
directory: File,
reported_path: &Path,
listing: &mut Listing,
) -> io::Result<()> {
let descriptor = directory.into_raw_fd();
let stream = unsafe { libc::fdopendir(descriptor) };
if stream.is_null() {
let error = io::Error::last_os_error();
unsafe { drop(File::from_raw_fd(descriptor)) };
return Err(error);
}
let stream = DirectoryStream(stream);
listing.clear();
loop {
unsafe { *libc::__error() = 0 };
let entry = unsafe { libc::readdir(stream.0) };
if entry.is_null() {
let error = io::Error::last_os_error();
return if error.raw_os_error() == Some(0) {
Ok(())
} else {
Err(error)
};
}
let entry = unsafe { &*entry };
let name = portable_dirent_name(entry)?;
if name.is_empty() || name == b"." || name == b".." {
continue;
}
let name = OsStr::from_bytes(name);
match descriptor_entry_kind(descriptor, name, entry.d_type) {
Ok(Some((is_dir, is_symlink))) => listing.push(name, is_dir, is_symlink),
Ok(None) => {}
Err(error) => defer_entry_stat_error(listing, reported_path.join(name), error)?,
}
}
}
fn portable_dirent_name(entry: &libc::dirent) -> io::Result<&[u8]> {
let length = entry.d_namlen as usize;
if length > entry.d_name.len() {
return Err(malformed_record());
}
Ok(unsafe { std::slice::from_raw_parts(entry.d_name.as_ptr().cast::<u8>(), length) })
}
struct DirectoryStream(*mut libc::DIR);
impl Drop for DirectoryStream {
fn drop(&mut self) {
unsafe { libc::closedir(self.0) };
}
}
fn descriptor_entry_kind(
directory: c_int,
name: &OsStr,
directory_type: u8,
) -> io::Result<Option<(bool, bool)>> {
match directory_type {
DT_DIR => Ok(Some((true, false))),
DT_REG | DT_FIFO | DT_CHR | DT_BLK | DT_SOCK => Ok(Some((false, false))),
DT_LNK => Ok(Some((false, true))),
_ => {
let name = CString::new(name.as_bytes()).map_err(|_| malformed_record())?;
let mut metadata = unsafe { std::mem::zeroed::<libc::stat>() };
let result = unsafe {
libc::fstatat(
directory,
name.as_ptr(),
&mut metadata,
libc::AT_SYMLINK_NOFOLLOW,
)
};
if result != 0 {
let error = io::Error::last_os_error();
return if is_vanished_entry(&error) {
Ok(None)
} else {
Err(error)
};
}
let kind = metadata.st_mode & libc::S_IFMT;
Ok(Some((kind == libc::S_IFDIR, kind == libc::S_IFLNK)))
}
}
}
#[cfg(test)]
pub(super) fn read_directory_with_unknown_types_for_test(
path: &Path,
refuse_final_symlink: bool,
listing: &mut Listing,
) -> io::Result<()> {
let directory = open_directory(path, refuse_final_symlink)?;
listing.clear();
for entry in fs::read_dir(path)? {
let entry = entry?;
let name = entry.file_name();
match descriptor_entry_kind(directory.as_raw_fd(), &name, 0) {
Ok(Some((is_dir, is_symlink))) => listing.push(&name, is_dir, is_symlink),
Ok(None) => {}
Err(error) => defer_entry_stat_error(listing, path.join(&name), error)?,
}
}
Ok(())
}
fn read_direntries_from_open_directory(
directory: &File,
path: &Path,
buffer: &mut [u8],
listing: &mut Listing,
) -> NativeDirectoryReadResult {
let mut base = 0_u64;
let mut primed = false;
listing.clear();
loop {
let byte_count = loop {
clear_direntries_eof_tail(buffer);
let byte_count = unsafe {
__getdirentries64(
directory.as_raw_fd(),
buffer.as_mut_ptr().cast(),
buffer.len(),
&mut base,
)
};
if byte_count >= 0 {
break byte_count as usize;
}
let error = io::Error::last_os_error();
if error.kind() != io::ErrorKind::Interrupted {
if is_unsupported_direntries_error(&error, primed) {
return Err(NativeDirectoryReadError::CapabilityUnavailable(error));
}
return Err(error.into());
}
};
if byte_count == 0 {
return Ok(());
}
primed = true;
parse_records_from_open_directory(directory, path, &buffer[..byte_count], listing)?;
if has_direntries_eof_flag(buffer, byte_count) {
return Ok(());
}
}
}
fn malformed_bulk_record() -> io::Error {
io::Error::new(
io::ErrorKind::InvalidData,
"malformed getattrlistbulk record",
)
}
#[cfg(test)]
fn parse_records(directory: &Path, records: &[u8], listing: &mut Listing) -> io::Result<()> {
parse_records_with_entry_kind(directory, records, listing, entry_kind)
}
fn parse_records_from_open_directory(
open_directory: &File,
reported_path: &Path,
records: &[u8],
listing: &mut Listing,
) -> io::Result<()> {
parse_records_with_entry_kind(
reported_path,
records,
listing,
|_, name, directory_type| {
descriptor_entry_kind(open_directory.as_raw_fd(), name, directory_type)
},
)
}
fn parse_records_with_entry_kind(
directory: &Path,
records: &[u8],
listing: &mut Listing,
mut classify: impl FnMut(&Path, &OsStr, u8) -> io::Result<Option<(bool, bool)>>,
) -> io::Result<()> {
for_each_record(records, |name, directory_type| {
let name = OsStr::from_bytes(name);
match classify(directory, name, directory_type) {
Ok(Some((is_dir, is_symlink))) => listing.push(name, is_dir, is_symlink),
Ok(None) => {}
Err(error) => defer_entry_stat_error(listing, directory.join(name), error)?,
}
Ok(())
})
}
#[doc(hidden)]
pub fn fuzz_validate_records(records: &[u8]) {
let _ = for_each_record(records, |_, _| Ok(()));
}
fn for_each_record(
records: &[u8],
mut visit: impl FnMut(&[u8], u8) -> io::Result<()>,
) -> io::Result<()> {
let mut offset = 0;
while offset < records.len() {
let record = records.get(offset..).ok_or_else(malformed_record)?;
if record.len() < NAME_OFFSET {
return Err(malformed_record());
}
let record_length = u16::from_ne_bytes(
record[RECORD_LENGTH_OFFSET..NAME_LENGTH_OFFSET]
.try_into()
.expect("record length slice has fixed width"),
) as usize;
let name_length = u16::from_ne_bytes(
record[NAME_LENGTH_OFFSET..TYPE_OFFSET]
.try_into()
.expect("name length slice has fixed width"),
) as usize;
if record_length < NAME_OFFSET || record_length > record.len() {
return Err(malformed_record());
}
let name_end = NAME_OFFSET
.checked_add(name_length)
.filter(|&end| end <= record_length)
.ok_or_else(malformed_record)?;
let name = &record[NAME_OFFSET..name_end];
offset = offset
.checked_add(record_length)
.ok_or_else(malformed_record)?;
if name.is_empty() || name == b"." || name == b".." {
continue;
}
if memchr::memchr2(0, b'/', name).is_some() {
return Err(malformed_record());
}
visit(name, record[TYPE_OFFSET])?;
}
Ok(())
}
#[cfg(test)]
fn entry_kind(
directory: &Path,
name: &OsStr,
directory_type: u8,
) -> io::Result<Option<(bool, bool)>> {
match directory_type {
DT_DIR => Ok(Some((true, false))),
DT_REG | DT_FIFO | DT_CHR | DT_BLK | DT_SOCK => Ok(Some((false, false))),
DT_LNK => Ok(Some((false, true))),
_ => stat_entry_kind(&directory.join(name)),
}
}
fn stat_entry_kind(path: &Path) -> io::Result<Option<(bool, bool)>> {
match fs::symlink_metadata(path) {
Ok(metadata) => {
let file_type = metadata.file_type();
Ok(Some((file_type.is_dir(), file_type.is_symlink())))
}
Err(error) if is_vanished_entry(&error) => Ok(None),
Err(error) => Err(error),
}
}
fn is_vanished_entry(error: &io::Error) -> bool {
matches!(
error.kind(),
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
)
}
fn is_unsupported_direntries_error(error: &io::Error, primed: bool) -> bool {
!primed && matches!(error.raw_os_error(), Some(22 | 45))
}
fn has_direntries_eof_flag(buffer: &[u8], byte_count: usize) -> bool {
if buffer.len() < GETDIRENTRIES64_EXTENDED_BUFFER_MINIMUM
|| byte_count.saturating_add(std::mem::size_of::<u32>()) > buffer.len()
{
return false;
}
let tail = &buffer[buffer.len() - std::mem::size_of::<u32>()..];
u32::from_ne_bytes(tail.try_into().expect("tail has u32 width")) & GETDIRENTRIES64_EOF != 0
}
fn clear_direntries_eof_tail(buffer: &mut [u8]) {
if buffer.len() < GETDIRENTRIES64_EXTENDED_BUFFER_MINIMUM {
return;
}
let tail_start = buffer.len() - std::mem::size_of::<u32>();
buffer[tail_start..].fill(0);
}
fn malformed_record() -> io::Error {
io::Error::new(
io::ErrorKind::InvalidData,
"malformed macOS directory record",
)
}
#[cfg(test)]
mod tests {
use std::{
ffi::OsString,
fs, io,
os::unix::fs::symlink,
path::{Path, PathBuf},
sync::atomic::{AtomicUsize, Ordering},
time::{SystemTime, UNIX_EPOCH},
};
use crate::{
DirectoryBackend, ErrorPolicy, StdBackend, SystemBackend, WalkEntry, WalkOptions, Walker,
};
use super::{
ATTR_CMN_ERROR, ATTR_CMN_NAME, ATTR_CMN_OBJTYPE, ATTR_CMN_RETURNED_ATTRS,
ATTRIBUTE_RECORD_HEADER_SIZE, BUFFER_SIZE, DT_BLK, DT_CHR, DT_DIR, DT_FIFO, DT_REG,
DT_SOCK, Listing, NAME_OFFSET, NativeDirectoryReadError, RelativeDirectoryOpen,
RetainedDirectory, VBLK, VCHR, VDIR, VFIFO, VSOCK, bulk_entry_kind,
clear_direntries_eof_tail, entry_kind, for_each_record, has_direntries_eof_flag,
is_unsupported_bulk_error, is_unsupported_direntries_error, open_directory,
parse_bulk_record, parse_records, parse_records_from_open_directory,
parse_records_with_entry_kind, read_bulk_directory, read_directory,
read_direntries_directory, read_open_directory_with_portable_fallback,
};
static NEXT_FIXTURE: AtomicUsize = AtomicUsize::new(0);
type DescribedWalkEntry = (PathBuf, bool, bool, usize, Option<(u64, bool, bool)>);
fn record(name: &[u8], directory_type: u8) -> Vec<u8> {
let length = (NAME_OFFSET + name.len() + 3) & !3;
let mut record = vec![0_u8; length];
record[16..18].copy_from_slice(&(length as u16).to_ne_bytes());
record[18..20].copy_from_slice(&(name.len() as u16).to_ne_bytes());
record[20] = directory_type;
record[NAME_OFFSET..NAME_OFFSET + name.len()].copy_from_slice(name);
record
}
fn bulk_record(name: &[u8], object_type: u32) -> Vec<u8> {
let attribute_error_offset = ATTRIBUTE_RECORD_HEADER_SIZE;
let name_reference_offset = attribute_error_offset + 4;
let object_type_offset = name_reference_offset + 8;
let name_offset = object_type_offset + 4;
let length = name_offset + name.len() + 1;
let mut record = vec![0_u8; length];
record[0..4].copy_from_slice(&(length as u32).to_ne_bytes());
record[4..8].copy_from_slice(
&(ATTR_CMN_RETURNED_ATTRS | ATTR_CMN_ERROR | ATTR_CMN_NAME | ATTR_CMN_OBJTYPE)
.to_ne_bytes(),
);
record[name_reference_offset..name_reference_offset + 4]
.copy_from_slice(&((name_offset - name_reference_offset) as i32).to_ne_bytes());
record[name_reference_offset + 4..object_type_offset]
.copy_from_slice(&((name.len() + 1) as u32).to_ne_bytes());
record[object_type_offset..name_offset].copy_from_slice(&object_type.to_ne_bytes());
record[name_offset..name_offset + name.len()].copy_from_slice(name);
record
}
#[test]
fn parser_skips_dot_entries_and_rejects_truncated_records() {
let mut records = record(b".", DT_DIR);
records.extend(record(b"..", DT_DIR));
records.extend(record(b"regular", DT_REG));
let mut listing = Listing::default();
parse_records(Path::new("/tmp"), &records, &mut listing).expect("dot records parse");
assert_eq!(listing.entries().len(), 1);
assert!(!listing.entries()[0].is_dir());
assert!(!listing.entries()[0].is_symlink());
assert!(parse_records(Path::new("/tmp"), &[0_u8; NAME_OFFSET - 1], &mut listing).is_err());
let mut zero_length = vec![0_u8; NAME_OFFSET];
zero_length[18..20].copy_from_slice(&1_u16.to_ne_bytes());
assert!(parse_records(Path::new("/tmp"), &zero_length, &mut listing).is_err());
let mut oversized_name = record(b"x", DT_REG);
let oversized_length = oversized_name.len() as u16;
oversized_name[18..20].copy_from_slice(&oversized_length.to_ne_bytes());
assert!(parse_records(Path::new("/tmp"), &oversized_name, &mut listing).is_err());
}
#[test]
fn bulk_parser_validates_attribute_references_and_entry_errors() {
let record = bulk_record(b"nested", VDIR);
let (name, object_type) = parse_bulk_record(&record)
.expect("valid bulk record")
.expect("entry has no error");
assert_eq!(&record[name], b"nested");
assert_eq!(object_type, Some(VDIR));
let mut missing_nul = bulk_record(b"file", VDIR);
*missing_nul
.last_mut()
.expect("bulk record has a terminator") = b'x';
assert!(parse_bulk_record(&missing_nul).is_err());
let mut invalid_reference = bulk_record(b"file", VDIR);
invalid_reference[ATTRIBUTE_RECORD_HEADER_SIZE + 4..ATTRIBUTE_RECORD_HEADER_SIZE + 8]
.copy_from_slice(&u32::MAX.to_ne_bytes());
assert!(parse_bulk_record(&invalid_reference).is_err());
let mut entry_error = bulk_record(b"file", VDIR);
entry_error[ATTRIBUTE_RECORD_HEADER_SIZE..ATTRIBUTE_RECORD_HEADER_SIZE + 4]
.copy_from_slice(&1_u32.to_ne_bytes());
assert!(
parse_bulk_record(&entry_error)
.expect("entry errors are skipped")
.is_none()
);
}
fn bulk_record_without(name: &[u8], missing: u32) -> Vec<u8> {
let attribute_error_offset = ATTRIBUTE_RECORD_HEADER_SIZE;
let name_reference_offset = attribute_error_offset + 4;
let name_offset = name_reference_offset + 8;
let length = name_offset + name.len() + 1;
let mut record = vec![0_u8; length];
record[0..4].copy_from_slice(&(length as u32).to_ne_bytes());
let mask = (ATTR_CMN_RETURNED_ATTRS | ATTR_CMN_ERROR | ATTR_CMN_NAME | ATTR_CMN_OBJTYPE)
& !missing;
record[4..8].copy_from_slice(&mask.to_ne_bytes());
record[name_reference_offset..name_reference_offset + 4]
.copy_from_slice(&((name_offset - name_reference_offset) as i32).to_ne_bytes());
record[name_reference_offset + 4..name_offset]
.copy_from_slice(&((name.len() + 1) as u32).to_ne_bytes());
record[name_offset..name_offset + name.len()].copy_from_slice(name);
record
}
#[test]
fn missing_object_type_defers_to_a_stat_instead_of_failing() {
let record = bulk_record_without(b"file", ATTR_CMN_OBJTYPE);
let (name, object_type) = parse_bulk_record(&record)
.expect("a record without an object type is usable")
.expect("entry has no error");
assert_eq!(&record[name], b"file");
assert_eq!(object_type, None, "the caller resolves the type by stat");
}
#[test]
fn missing_name_is_unsupported_rather_than_malformed() {
let record = bulk_record_without(b"file", ATTR_CMN_NAME);
let error = parse_bulk_record(&record).expect_err("a nameless record is unusable");
assert_eq!(error.kind(), std::io::ErrorKind::Unsupported);
}
#[test]
fn a_vanished_entry_costs_one_entry_and_not_the_listing() {
let missing = format!(
"ferralk-vanished-{}-{}",
std::process::id(),
NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed)
);
let mut records = record(missing.as_bytes(), 0);
records.extend(record(b"survivor", DT_REG));
let mut listing = Listing::default();
parse_records(Path::new("/tmp"), &records, &mut listing)
.expect("a vanished entry does not end the listing");
assert_eq!(
listing.entries().len(),
1,
"only the vanished entry is dropped"
);
assert_eq!(listing.entries()[0].name(), "survivor");
}
#[test]
fn unknown_records_stay_with_the_opened_directory_after_a_path_swap() {
let root = fixture_root("unknown-descriptor");
let listed = root.join("listed");
let moved = root.join("opened-before-swap");
let target = root.join("attacker");
fs::create_dir_all(&listed).expect("create listed directory");
fs::write(listed.join("probe"), b"fixture").expect("create original file");
fs::create_dir_all(target.join("probe")).expect("create swapped directory");
let open = open_directory(&listed, true).expect("open listed directory");
let records = record(b"probe", 0);
fs::rename(&listed, &moved).expect("move opened directory");
symlink(&target, &listed).expect("replace listed path with a link");
let mut listing = Listing::default();
parse_records_from_open_directory(&open, &listed, &records, &mut listing)
.expect("unknown record is classified through the open descriptor");
assert_eq!(listing.entries().len(), 1);
assert!(
!listing.entries()[0].is_dir(),
"the original file wins over the replacement directory"
);
fs::remove_dir_all(root).expect("remove descriptor fixture");
}
#[test]
fn special_file_types_are_classified_without_a_stat() {
let absent = Path::new("/ferralk-nonexistent-special");
for directory_type in [DT_FIFO, DT_CHR, DT_BLK, DT_SOCK] {
assert_eq!(
entry_kind(absent, "entry".as_ref(), directory_type).expect("no stat is attempted"),
Some((false, false))
);
}
for object_type in [VBLK, VCHR, VSOCK, VFIFO] {
assert_eq!(
bulk_entry_kind(absent, "entry".as_ref(), Some(object_type))
.expect("no stat is attempted"),
Some((false, false))
);
}
}
#[test]
fn opening_a_non_directory_fails_instead_of_blocking() {
let path = std::env::temp_dir().join(format!(
"ferralk-not-a-directory-{}-{}",
std::process::id(),
NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed)
));
fs::write(&path, b"fixture").expect("write fixture file");
let error = open_directory(&path, false).expect_err("a regular file is not a directory");
assert_eq!(error.kind(), std::io::ErrorKind::NotADirectory);
let _ = fs::remove_file(&path);
}
#[test]
fn no_follow_directory_open_rejects_a_swapped_symlink_but_follow_opens_it() {
let root = fixture_root("no-follow");
let target = root.join("target");
let link = root.join("scheduled");
fs::create_dir_all(&target).expect("create target directory");
fs::write(target.join("inside"), b"fixture").expect("write target entry");
symlink(&target, &link).expect("replace scheduled directory with a link");
let error = open_directory(&link, true).expect_err("no-follow rejects the replacement");
assert!(
matches!(
error.raw_os_error(),
Some(libc::ELOOP) | Some(libc::ENOTDIR)
),
"a no-follow directory open rejects the changed path: {error}"
);
let mut listing = Listing::default();
read_directory(&link, None, false, &mut listing)
.expect("follow mode still opens through a link");
assert!(listing.contains("inside"));
fs::remove_dir_all(root).expect("remove no-follow fixture");
}
#[test]
fn no_follow_fallback_preserves_the_boundary_and_roots_stay_compatible() {
let root = fixture_root("no-follow-fallback");
let target = root.join("target");
let link = root.join("scheduled");
fs::create_dir_all(&target).expect("create target directory");
fs::write(target.join("inside"), b"fixture").expect("write target entry");
symlink(&target, &link).expect("replace scheduled directory with a link");
let mut listing = Listing::default();
let error = crate::read_native_or_portable(
&mut listing,
|_| Err(std::io::Error::from(std::io::ErrorKind::Unsupported)),
|_| {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"portable fallback would lose no-follow",
))
},
)
.expect_err("a safe fallback never reopens the changed path");
assert_eq!(error.kind(), std::io::ErrorKind::Unsupported);
SystemBackend
.read_directory(&link, false, false, &mut listing)
.expect("a directory symlink remains a valid root");
assert!(listing.contains("inside"));
fs::remove_dir_all(root).expect("remove no-follow fallback fixture");
}
#[test]
fn relative_child_open_stays_with_the_parent_descriptor_after_path_replacement() {
let root = fixture_root("relative-open-parent-swap");
let parent = root.join("parent");
let moved = root.join("opened-parent");
fs::create_dir_all(parent.join("child")).expect("create original child");
fs::write(parent.join("child/original"), b"fixture").expect("write original marker");
let relative = RelativeDirectoryOpen {
parent: RetainedDirectory::retain(
open_directory(&parent, false).expect("open original parent"),
)
.expect("the test retains one descriptor"),
name: OsString::from("child"),
};
fs::rename(&parent, &moved).expect("move the opened parent");
fs::create_dir_all(parent.join("child")).expect("create replacement child");
fs::write(parent.join("child/escaped"), b"fixture").expect("write replacement marker");
let mut listing = Listing::default();
read_directory(&parent.join("child"), Some(&relative), true, &mut listing)
.expect("open child relative to retained parent");
assert!(listing.contains("original"));
assert!(!listing.contains("escaped"));
fs::remove_dir_all(root).expect("remove relative-open fixture");
}
#[test]
fn unsupported_no_follow_descendant_reuses_its_open_descriptor() {
let root = fixture_root("descriptor-fallback");
let descendant = root.join("scheduled");
let moved = root.join("opened-before-swap");
let target = root.join("target");
fs::create_dir_all(descendant.join("nested")).expect("create scheduled subtree");
fs::write(descendant.join("nested/inside"), b"fixture").expect("write nested entry");
fs::create_dir_all(&target).expect("create swapped target");
fs::write(target.join("escaped"), b"fixture").expect("write target entry");
let mut buffer = vec![0_u8; BUFFER_SIZE];
let mut listing = Listing::default();
let used_portable_fallback = read_open_directory_with_portable_fallback(
&descendant,
None,
true,
&mut buffer,
&mut listing,
|_, _, _, _| {
fs::rename(&descendant, &moved).expect("move opened directory");
symlink(&target, &descendant).expect("replace path with a link");
Err(NativeDirectoryReadError::CapabilityUnavailable(
std::io::Error::from_raw_os_error(45),
))
},
)
.expect("unsupported native read degrades through the descriptor");
assert!(used_portable_fallback);
assert!(listing.contains("nested"), "the descendant is not lost");
assert!(
!listing.contains("escaped"),
"the fallback never reopens the swapped path"
);
fs::remove_dir_all(root).expect("remove descriptor fallback fixture");
}
#[test]
fn entry_unsupported_after_a_batch_never_restarts_the_descriptor_reader() {
let root = fixture_root("entry-unsupported-after-batch");
fs::create_dir_all(&root).expect("create fixture directory");
fs::write(root.join("from-portable-fallback"), b"fixture")
.expect("write portable fallback marker");
let mut records = record(b"sibling", DT_REG);
records.extend(record(b"unknown", 0));
let mut buffer = vec![0_u8; BUFFER_SIZE];
let mut listing = Listing::default();
let error = read_open_directory_with_portable_fallback(
&root,
None,
true,
&mut buffer,
&mut listing,
|_, path, _, listing| {
listing.clear();
parse_records_with_entry_kind(path, &records, listing, |_, _, directory_type| {
if directory_type == 0 {
Err(std::io::Error::from(std::io::ErrorKind::Unsupported))
} else {
Ok(Some((false, false)))
}
})?;
Ok(())
},
)
.expect_err("an entry metadata error is not a capability fallback");
assert_eq!(
error.into_io_error().kind(),
std::io::ErrorKind::Unsupported,
"the entry failure still reaches the walker error policy"
);
assert!(
listing.contains("sibling"),
"the accepted batch is retained"
);
assert!(
!listing.contains("from-portable-fallback"),
"the advanced descriptor was never resumed through fdopendir"
);
fs::remove_dir_all(root).expect("remove fixture directory");
}
#[test]
fn checked_in_macos_native_fuzz_seeds_are_valid_and_reproducible() {
let long_name = [b'x'; 255];
let mut multi = record(b"nested", DT_DIR);
multi.extend(record(b"file", DT_REG));
let dirent_seeds: [(&[u8], Vec<u8>); 4] = [
(
include_bytes!("../../../fuzz/corpus/macos_dirent_parser/single-regular"),
record(b"one", DT_REG),
),
(
include_bytes!("../../../fuzz/corpus/macos_dirent_parser/minimal-name"),
record(b"a", DT_REG),
),
(
include_bytes!("../../../fuzz/corpus/macos_dirent_parser/multi-directory-regular"),
multi,
),
(
include_bytes!("../../../fuzz/corpus/macos_dirent_parser/long-name"),
record(&long_name, DT_REG),
),
];
for (seed, expected) in dirent_seeds {
assert_eq!(
seed,
expected.as_slice(),
"seed matches the generator record"
);
let mut records = 0;
for_each_record(seed, |_, _| {
records += 1;
Ok(())
})
.expect("dirent seed reaches the parser visitor");
assert!(records > 0);
}
let bulk_seeds: [(&[u8], Vec<u8>); 3] = [
(
include_bytes!("../../../fuzz/corpus/macos_bulk_record_parser/name-and-type"),
bulk_record(b"entry", VDIR),
),
(
include_bytes!("../../../fuzz/corpus/macos_bulk_record_parser/no-object-type"),
bulk_record_without(b"unknown", ATTR_CMN_OBJTYPE),
),
(
include_bytes!("../../../fuzz/corpus/macos_bulk_record_parser/long-name"),
bulk_record(&long_name, VDIR),
),
];
for (seed, expected) in bulk_seeds {
assert_eq!(
seed,
expected.as_slice(),
"seed matches the generator record"
);
assert!(
parse_bulk_record(seed)
.expect("bulk seed passes structural validation")
.is_some(),
"bulk seed reaches a usable parser record"
);
}
}
#[test]
fn bulk_capability_errors_fall_back_only_before_the_first_batch() {
for error in [
std::io::Error::from_raw_os_error(22),
std::io::Error::from_raw_os_error(45),
] {
assert!(is_unsupported_bulk_error(&error, false));
assert!(!is_unsupported_bulk_error(&error, true));
}
assert!(!is_unsupported_bulk_error(
&std::io::Error::from_raw_os_error(13),
false
));
}
#[test]
fn direntries_capability_errors_fall_back_only_before_the_first_batch() {
for error in [
std::io::Error::from_raw_os_error(22),
std::io::Error::from_raw_os_error(45),
] {
assert!(is_unsupported_direntries_error(&error, false));
assert!(!is_unsupported_direntries_error(&error, true));
}
assert!(!is_unsupported_direntries_error(
&std::io::Error::from_raw_os_error(13),
false
));
}
#[test]
fn unsupported_native_reader_uses_the_portable_system_fallback() {
let root = fixture_root("fallback");
fs::create_dir_all(&root).expect("create fallback fixture");
fs::write(root.join("survivor"), b"fixture").expect("write fallback entry");
let mut listing = Listing::default();
crate::read_native_or_portable(
&mut listing,
|_| Err(std::io::Error::from(std::io::ErrorKind::Unsupported)),
|listing| StdBackend.read_directory(&root, false, false, listing),
)
.expect("unsupported native read falls back portably");
assert!(listing.contains("survivor"));
fs::remove_dir_all(root).expect("remove fallback fixture");
}
#[test]
fn direntries_extended_tail_marks_the_final_data_batch_without_expanding_parse_bounds() {
let mut buffer = vec![0_u8; 1024];
let record = record(b"entry", DT_REG);
buffer[..record.len()].copy_from_slice(&record);
let tail_start = buffer.len() - 4;
buffer[tail_start..].copy_from_slice(&1_u32.to_ne_bytes());
let mut listing = Listing::default();
parse_records(Path::new("/tmp"), &buffer[..record.len()], &mut listing)
.expect("the parser sees only returned dirent bytes");
assert_eq!(listing.entries()[0].name(), "entry");
assert!(has_direntries_eof_flag(&buffer, record.len()));
assert!(!has_direntries_eof_flag(&buffer[..1023], record.len()));
}
#[test]
fn direntries_nonwriting_extended_tail_cannot_end_a_full_batch_early() {
let record = record(b"odd!", DT_REG);
let byte_count = record.len() * 40;
let mut buffer = vec![0_u8; byte_count];
assert_eq!(byte_count % record.len(), 0, "the mock fills a whole batch");
clear_direntries_eof_tail(&mut buffer);
for slot in buffer[..byte_count].chunks_exact_mut(record.len()) {
slot.copy_from_slice(&record);
}
let mut listing = Listing::default();
parse_records(Path::new("/tmp"), &buffer[..byte_count], &mut listing)
.expect("all returned records still parse");
assert_eq!(listing.entries().len(), byte_count / record.len());
assert!(
!has_direntries_eof_flag(&buffer, byte_count),
"a full batch overwrites the optional tail, however it happens to read"
);
}
#[test]
fn portable_readdir_rejects_a_name_beyond_its_fixed_array() {
let mut entry = unsafe { std::mem::zeroed::<libc::dirent>() };
entry.d_namlen = u16::MAX;
let error = super::portable_dirent_name(&entry).expect_err("oversized name is malformed");
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
}
#[test]
fn native_readers_match_the_portable_reader() {
let root = std::env::temp_dir().join(format!(
"ferralk-macos-native-{}-{}",
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.join("nested")).expect("create native fixture");
fs::write(root.join("file.txt"), b"fixture").expect("write native fixture");
symlink("file.txt", root.join("link.txt")).expect("create native fixture symlink");
let describe = |read: &dyn Fn(&mut Listing)| {
let mut listing = Listing::default();
read(&mut listing);
let mut described = listing
.entries()
.iter()
.map(|entry| {
(
PathBuf::from(entry.name()),
entry.is_dir(),
entry.is_symlink(),
)
})
.collect::<Vec<(PathBuf, bool, bool)>>();
described.sort();
described
};
let portable = describe(&|listing| {
StdBackend
.read_directory(&root, false, false, listing)
.expect("portable reader succeeds");
});
assert_eq!(
describe(&|listing| {
read_directory(&root, None, false, listing).expect("native reader succeeds");
}),
portable
);
assert_eq!(
describe(&|listing| {
read_direntries_directory(&root, None, false, listing)
.expect("direntries reader succeeds");
}),
portable
);
assert_eq!(
describe(&|listing| {
read_bulk_directory(&root, listing).expect("bulk reader succeeds");
}),
portable
);
fs::remove_dir_all(root).expect("remove native fixture");
}
#[test]
fn native_walker_matches_portable_filters_metadata_and_symlinks() {
let root = fixture_root("walker");
fs::create_dir_all(root.join("src/nested")).expect("create source fixture");
fs::create_dir_all(root.join("ignored")).expect("create ignored fixture");
fs::create_dir_all(root.join(".hidden")).expect("create hidden fixture");
fs::write(root.join("src/lib.rs"), b"library").expect("write source fixture");
fs::write(root.join("src/nested/mod.rs"), b"module").expect("write nested fixture");
fs::write(root.join("src/generated.tmp"), b"temporary").expect("write temp fixture");
fs::write(root.join("ignored/skip.rs"), b"ignored").expect("write ignored fixture");
fs::write(root.join(".hidden/skip.rs"), b"hidden").expect("write hidden fixture");
fs::write(root.join(".gitignore"), b"ignored/\n").expect("write gitignore fixture");
symlink("src", root.join("source-link")).expect("create directory symlink");
let walker = Walker::new(&root)
.threads(1)
.include("**/*")
.expect("valid include")
.exclude("**/*.tmp")
.expect("valid exclude")
.respect_git_ignore(true)
.error_policy(ErrorPolicy::Collect)
.options(
WalkOptions::default()
.sort(true)
.metadata(true)
.skip_hidden(true),
);
let native = walker.clone().collect().expect("native walk succeeds");
let (portable_entries, portable_errors) = collect_with_portable_backend(&walker);
assert!(native.errors().is_empty());
assert!(portable_errors.is_empty());
assert_eq!(
describe_walk_entries(native.entries(), &root),
describe_walk_entries(&portable_entries, &root)
);
fs::remove_dir_all(root).expect("remove walker fixture");
}
fn fixture_root(label: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"ferralk-macos-native-{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
))
}
fn collect_with_portable_backend(walker: &Walker) -> (Vec<WalkEntry>, Vec<crate::WalkError>) {
let mut state = crate::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) = crate::IgnoreScope::for_root(walker, &StdBackend, &root);
let task = crate::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("portable walk succeeds");
if walker.options.sort {
state
.entries
.sort_by(|left, right| left.path.cmp(&right.path));
}
(state.entries, state.errors)
}
fn describe_walk_entries(entries: &[WalkEntry], root: &Path) -> Vec<DescribedWalkEntry> {
entries
.iter()
.map(|entry| {
(
entry
.path()
.strip_prefix(root)
.expect("entry belongs to fixture")
.to_path_buf(),
entry.is_dir(),
entry.is_symlink(),
entry.depth(),
entry.metadata().map(|metadata| {
(
metadata.len(),
metadata.file_type().is_dir(),
metadata.file_type().is_symlink(),
)
}),
)
})
.collect()
}
}