use std::{
cell::RefCell,
ffi::{OsString, c_long, c_void},
fs::{self, File},
io,
os::unix::{ffi::OsStringExt, io::AsRawFd},
path::Path,
};
use super::BackendEntry;
const BUFFER_SIZE: usize = 32 * 1024;
const RECORD_LENGTH_OFFSET: usize = 16;
const TYPE_OFFSET: usize = 18;
const NAME_OFFSET: usize = 19;
const DT_DIR: u8 = 4;
const DT_REG: u8 = 8;
const DT_LNK: u8 = 10;
std::thread_local! {
static DIRECTORY_BUFFER: RefCell<Box<[u8; BUFFER_SIZE]>> = RefCell::new(Box::new([0; BUFFER_SIZE]));
}
#[cfg(all(target_arch = "x86_64", target_pointer_width = "64"))]
const SYS_GETDENTS64: c_long = 217;
#[cfg(any(target_arch = "aarch64", target_arch = "riscv64"))]
const SYS_GETDENTS64: c_long = 61;
#[cfg(target_arch = "x86")]
const SYS_GETDENTS64: c_long = 220;
#[cfg(target_arch = "arm")]
const SYS_GETDENTS64: c_long = 217;
unsafe extern "C" {
fn syscall(number: c_long, ...) -> c_long;
}
pub(super) fn read_directory(path: &Path) -> io::Result<Vec<BackendEntry>> {
DIRECTORY_BUFFER.with(|buffer| {
let mut buffer = buffer.borrow_mut();
read_directory_with_buffer(path, &mut buffer[..])
})
}
fn read_directory_with_buffer(path: &Path, buffer: &mut [u8]) -> io::Result<Vec<BackendEntry>> {
let directory = File::open(path)?;
let mut entries = Vec::new();
loop {
let byte_count = read_batch(&directory, buffer)?;
if byte_count == 0 {
return Ok(entries);
}
parse_records(path, &buffer[..byte_count], &mut entries)?;
}
}
fn read_batch(directory: &File, buffer: &mut [u8]) -> io::Result<usize> {
#[cfg(any(
all(target_arch = "x86_64", target_pointer_width = "64"),
target_arch = "aarch64",
target_arch = "riscv64",
target_arch = "x86",
target_arch = "arm"
))]
loop {
let byte_count = unsafe {
syscall(
SYS_GETDENTS64,
directory.as_raw_fd(),
buffer.as_mut_ptr().cast::<c_void>(),
buffer.len(),
)
};
if byte_count >= 0 {
return Ok(byte_count as usize);
}
let error = io::Error::last_os_error();
if error.kind() == io::ErrorKind::Interrupted {
continue;
}
if error.raw_os_error() == Some(38) {
return Err(io::Error::new(io::ErrorKind::Unsupported, error));
}
return Err(error);
}
#[cfg(not(any(
all(target_arch = "x86_64", target_pointer_width = "64"),
target_arch = "aarch64",
target_arch = "riscv64",
target_arch = "x86",
target_arch = "arm"
)))]
{
let _ = (directory, buffer);
Err(io::Error::new(
io::ErrorKind::Unsupported,
"getdents64 has no reviewed syscall number for this architecture",
))
}
}
fn parse_records(
directory: &Path,
records: &[u8],
entries: &mut Vec<BackendEntry>,
) -> io::Result<()> {
for_each_record(records, |name, directory_type| {
let path = directory.join(OsString::from_vec(name.to_vec()));
let (is_dir, is_symlink) = entry_kind(&path, directory_type)?;
entries.push(BackendEntry {
path,
is_dir,
is_symlink,
});
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 + 1 {
return Err(malformed_record());
}
let record_length = u16::from_ne_bytes(
record[RECORD_LENGTH_OFFSET..RECORD_LENGTH_OFFSET + std::mem::size_of::<u16>()]
.try_into()
.expect("record length slice has fixed width"),
) as usize;
if record_length < NAME_OFFSET + 1 || record_length > record.len() {
return Err(malformed_record());
}
let name_and_padding = &record[NAME_OFFSET..record_length];
let Some(name_length) = name_and_padding.iter().position(|&byte| byte == 0) else {
return Err(malformed_record());
};
let name = &name_and_padding[..name_length];
offset = offset
.checked_add(record_length)
.ok_or_else(malformed_record)?;
if name.is_empty() || name == b"." || name == b".." {
continue;
}
if name.contains(&b'/') {
return Err(malformed_record());
}
visit(name, record[TYPE_OFFSET])?;
}
Ok(())
}
fn entry_kind(path: &Path, directory_type: u8) -> io::Result<(bool, bool)> {
match directory_type {
DT_DIR => Ok((true, false)),
DT_REG => Ok((false, false)),
DT_LNK => Ok((false, true)),
_ => {
let file_type = fs::symlink_metadata(path)?.file_type();
Ok((file_type.is_dir(), file_type.is_symlink()))
}
}
}
fn malformed_record() -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, "malformed getdents64 record")
}
#[cfg(test)]
mod tests {
use std::{
fs,
os::unix::fs::symlink,
path::{Path, PathBuf},
sync::atomic::{AtomicUsize, Ordering},
time::{SystemTime, UNIX_EPOCH},
};
use crate::{DirectoryBackend, ErrorPolicy, StdBackend, WalkEntry, WalkOptions, Walker};
use super::{
BackendEntry, DT_DIR, DT_REG, NAME_OFFSET, TYPE_OFFSET, parse_records, read_directory,
};
static NEXT_FIXTURE: AtomicUsize = AtomicUsize::new(0);
fn record(name: &[u8], directory_type: u8) -> Vec<u8> {
let length = (NAME_OFFSET + name.len() + 1 + 7) & !7;
let mut record = vec![0_u8; length];
record[16..18].copy_from_slice(&(length as u16).to_ne_bytes());
record[NAME_OFFSET..NAME_OFFSET + name.len()].copy_from_slice(name);
record[TYPE_OFFSET] = directory_type;
record
}
#[test]
fn parser_skips_dot_entries_and_rejects_malformed_records() {
let mut records = record(b".", DT_DIR);
records.extend(record(b"..", DT_DIR));
records.extend(record(b"regular", DT_REG));
let mut entries: Vec<BackendEntry> = Vec::new();
parse_records(Path::new("/tmp"), &records, &mut entries).expect("dot records parse");
assert_eq!(entries.len(), 1);
assert!(!entries[0].is_dir);
assert!(!entries[0].is_symlink);
assert!(parse_records(Path::new("/tmp"), &[0_u8; NAME_OFFSET], &mut entries).is_err());
let mut zero_length = vec![0_u8; NAME_OFFSET + 1];
zero_length[NAME_OFFSET] = DT_REG;
assert!(parse_records(Path::new("/tmp"), &zero_length, &mut entries).is_err());
let mut missing_nul = record(b"name", DT_REG);
for byte in &mut missing_nul[NAME_OFFSET..] {
*byte = b'x';
}
assert!(parse_records(Path::new("/tmp"), &missing_nul, &mut entries).is_err());
}
#[test]
fn native_reader_matches_the_portable_reader() {
let root = std::env::temp_dir().join(format!(
"ferralk-linux-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");
let mut native = read_directory(&root).expect("native reader succeeds");
let mut portable = StdBackend
.read_directory(&root)
.expect("portable reader succeeds");
native.sort_by(|left, right| left.path.cmp(&right.path));
portable.sort_by(|left, right| left.path.cmp(&right.path));
let describe = |entries: Vec<BackendEntry>| {
entries
.into_iter()
.map(|entry| {
(
entry
.path
.strip_prefix(&root)
.expect("entry belongs to fixture")
.to_path_buf(),
entry.is_dir,
entry.is_symlink,
)
})
.collect::<Vec<(PathBuf, bool, bool)>>()
};
assert_eq!(describe(native), describe(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");
}
type DescribedWalkEntry = (PathBuf, bool, bool, usize, Option<(u64, bool, bool)>);
fn fixture_root(label: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"ferralk-linux-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);
state
.walk_directory(&StdBackend, walker.root.clone())
.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()
}
}