#[cfg(unix)]
use std::ffi::OsString;
#[cfg(unix)]
use std::io;
#[cfg(unix)]
use std::os::fd::AsFd;
#[cfg(unix)]
use std::os::unix::ffi::OsStringExt;
#[cfg(unix)]
use std::path::Path;
#[cfg(not(unix))]
pub use unsupported::Dir;
#[cfg(unix)]
#[derive(Debug)]
pub struct Dir {
fd: std::os::fd::OwnedFd,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum SymlinkPolicy {
#[default]
NoFollow,
FollowFinal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct ListLimits {
pub max_entries: usize,
pub max_name_bytes: usize,
}
impl ListLimits {
#[must_use]
pub const fn new(max_entries: usize, max_name_bytes: usize) -> Self {
Self {
max_entries,
max_name_bytes,
}
}
}
impl Default for ListLimits {
fn default() -> Self {
Self::new(65_536, 16 << 20)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Listing {
names: Vec<String>,
unaddressable: usize,
truncated: bool,
}
impl Listing {
#[must_use]
pub fn names(&self) -> &[String] {
&self.names
}
#[must_use]
pub fn unaddressable(&self) -> usize {
self.unaddressable
}
#[must_use]
pub fn is_truncated(&self) -> bool {
self.truncated
}
#[must_use]
pub fn is_complete(&self) -> bool {
!self.truncated && self.unaddressable == 0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum FileKind {
File,
Directory,
Symlink,
Fifo,
CharDevice,
BlockDevice,
Socket,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub struct OpenFlags {
pub read: bool,
pub write: bool,
pub create: bool,
pub truncate: bool,
pub symlinks: SymlinkPolicy,
}
impl OpenFlags {
#[must_use]
pub fn read() -> Self {
Self {
read: true,
..Self::default()
}
}
#[must_use]
pub fn create_truncate() -> Self {
Self {
write: true,
create: true,
truncate: true,
..Self::default()
}
}
#[cfg(unix)]
fn access(self) -> rustix::fs::OFlags {
match (self.read, self.write) {
(true, true) => rustix::fs::OFlags::RDWR,
(false, true) => rustix::fs::OFlags::WRONLY,
(true, false) => rustix::fs::OFlags::RDONLY,
(false, false) => rustix::fs::OFlags::RDONLY,
}
}
}
#[cfg(unix)]
impl OpenFlags {
pub fn validate(&self) -> io::Result<()> {
if self.truncate && !self.write {
let refusal = Err(io::Error::new(
io::ErrorKind::InvalidInput,
"truncate requires write access: a read-only O_TRUNC empties the \
file on macOS and the BSDs, and Linux merely refuses it",
));
#[cfg(feature = "trace")]
crate::trace::debug!(error = ?refusal.as_ref().err(), "validate: returning an error to the caller");
return refusal;
}
if !self.read && !self.write {
let refusal = Err(io::Error::new(
io::ErrorKind::InvalidInput,
"open needs read, write, or both; neither asks for a descriptor \
that can do nothing",
));
#[cfg(feature = "trace")]
crate::trace::debug!(error = ?refusal.as_ref().err(), "validate: returning an error to the caller");
return refusal;
}
if self.create && self.symlinks == SymlinkPolicy::FollowFinal {
let refusal = Err(io::Error::new(
io::ErrorKind::InvalidInput,
"create cannot follow a final symlink: O_CREAT without O_EXCL \
writes through the link, and creates a dangling link's target. \
Use Dir::create_new, which pairs O_EXCL with O_NOFOLLOW",
));
#[cfg(feature = "trace")]
crate::trace::debug!(error = ?refusal.as_ref().err(), "validate: returning an error to the caller");
return refusal;
}
Ok(())
}
}
#[cfg(unix)]
impl Dir {
pub fn open(path: impl AsRef<Path>) -> io::Result<Self> {
let fd = rustix::fs::open(
path.as_ref(),
rustix::fs::OFlags::RDONLY
| rustix::fs::OFlags::DIRECTORY
| rustix::fs::OFlags::CLOEXEC,
rustix::fs::Mode::empty(),
)
.map_err(errno_to_io)?;
Ok(Self { fd })
}
pub fn open_subdir(&self, name: &str) -> io::Result<Self> {
let component = single_component(name)?;
let fd = rustix::fs::openat(
self.fd.as_fd(),
&component,
rustix::fs::OFlags::RDONLY
| rustix::fs::OFlags::DIRECTORY
| rustix::fs::OFlags::NOFOLLOW
| rustix::fs::OFlags::CLOEXEC,
rustix::fs::Mode::empty(),
)
.map_err(errno_to_io)?;
Ok(Self { fd })
}
pub fn open_entry(&self, name: &str, flags: OpenFlags) -> io::Result<std::fs::File> {
let component = single_component(name)?;
flags.validate()?;
let mut oflags = flags.access() | rustix::fs::OFlags::CLOEXEC;
if flags.create {
oflags |= rustix::fs::OFlags::CREATE;
}
if flags.truncate {
oflags |= rustix::fs::OFlags::TRUNC;
}
if flags.symlinks == SymlinkPolicy::NoFollow {
oflags |= rustix::fs::OFlags::NOFOLLOW;
}
let fd = rustix::fs::openat(self.fd.as_fd(), component, oflags, user_writable_mode())
.map_err(errno_to_io)?;
Ok(std::fs::File::from(fd))
}
pub fn kind(&self, name: &str) -> io::Result<FileKind> {
let component = single_component(name)?;
let stat = rustix::fs::statat(
self.fd.as_fd(),
&component,
rustix::fs::AtFlags::SYMLINK_NOFOLLOW,
)
.map_err(errno_to_io)?;
Ok(classify(stat.st_mode))
}
pub fn read_link(&self, name: &str) -> io::Result<std::ffi::OsString> {
let component = single_component(name)?;
let target =
rustix::fs::readlinkat(self.fd.as_fd(), &component, Vec::new()).map_err(errno_to_io)?;
Ok(OsString::from_vec(target.into_bytes()))
}
pub fn create_new(&self, name: &str) -> io::Result<std::fs::File> {
let component = single_component(name)?;
let fd = rustix::fs::openat(
self.fd.as_fd(),
&component,
rustix::fs::OFlags::WRONLY
| rustix::fs::OFlags::CREATE
| rustix::fs::OFlags::EXCL
| rustix::fs::OFlags::NOFOLLOW
| rustix::fs::OFlags::CLOEXEC,
user_writable_mode(),
)
.map_err(errno_to_io)?;
Ok(std::fs::File::from(fd))
}
pub fn create_dir(&self, name: &str) -> io::Result<()> {
let component = single_component(name)?;
rustix::fs::mkdirat(self.fd.as_fd(), &component, user_directory_mode()).map_err(errno_to_io)
}
pub fn remove_dir(&self, name: &str) -> io::Result<()> {
let component = single_component(name)?;
rustix::fs::unlinkat(self.fd.as_fd(), &component, rustix::fs::AtFlags::REMOVEDIR)
.map_err(errno_to_io)
}
pub fn remove_file(&self, name: &str) -> io::Result<()> {
let component = single_component(name)?;
rustix::fs::unlinkat(self.fd.as_fd(), &component, rustix::fs::AtFlags::empty())
.map_err(errno_to_io)
}
#[cfg(target_os = "linux")]
pub fn entry_names(&self, limits: ListLimits) -> io::Result<Listing> {
use std::mem::MaybeUninit;
const DIRENT_BUFFER_BYTES: usize = 4_096;
rustix::fs::seek(self.fd.as_fd(), rustix::fs::SeekFrom::Start(0)).map_err(errno_to_io)?;
let mut buffer = [MaybeUninit::<u8>::uninit(); DIRENT_BUFFER_BYTES];
let mut directory = rustix::fs::RawDir::new(self.fd.as_fd(), &mut buffer);
let mut listing = Listing {
names: Vec::new(),
unaddressable: 0,
truncated: false,
};
let mut entries = 0_usize;
let mut name_bytes = 0_usize;
while let Some(entry) = directory.next() {
let entry = entry.map_err(errno_to_io)?;
let name = entry.file_name().to_bytes();
if name == b"." || name == b".." {
continue;
}
if entries >= limits.max_entries {
listing.truncated = true;
break;
}
entries = entries.saturating_add(1);
match std::str::from_utf8(name) {
Ok(text) => {
let charged = name_bytes.saturating_add(text.len());
if charged > limits.max_name_bytes {
listing.truncated = true;
break;
}
name_bytes = charged;
listing.names.push(text.to_owned());
}
Err(_) => listing.unaddressable = listing.unaddressable.saturating_add(1),
}
}
listing.names.sort_unstable();
Ok(listing)
}
#[cfg(not(target_os = "linux"))]
pub fn entry_names(&self, _limits: ListLimits) -> io::Result<Listing> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"fs::capability::Dir::entry_names needs getdents64, which is Linux-only",
))
}
#[must_use]
pub fn as_fd(&self) -> std::os::fd::BorrowedFd<'_> {
self.fd.as_fd()
}
}
#[cfg(unix)]
impl Dir {
pub fn try_clone(&self) -> io::Result<Self> {
let fd = rustix::io::fcntl_dupfd_cloexec(self.fd.as_fd(), 0).map_err(errno_to_io)?;
Ok(Self { fd })
}
}
#[cfg(not(unix))]
mod unsupported {
use std::io;
use std::path::Path;
use super::{FileKind, OpenFlags};
#[derive(Debug)]
#[non_exhaustive]
pub struct Dir;
impl Dir {
pub fn open(_path: impl AsRef<Path>) -> io::Result<Self> {
Err(unsupported("Dir::open"))
}
pub fn open_subdir(&self, _name: &str) -> io::Result<Self> {
Err(unsupported("Dir::open_subdir"))
}
pub fn open_entry(&self, _name: &str, _flags: OpenFlags) -> io::Result<std::fs::File> {
Err(unsupported("Dir::open_entry"))
}
pub fn kind(&self, _name: &str) -> io::Result<FileKind> {
Err(unsupported("Dir::kind"))
}
pub fn read_link(&self, _name: &str) -> io::Result<std::ffi::OsString> {
Err(unsupported("Dir::read_link"))
}
pub fn create_new(&self, _name: &str) -> io::Result<std::fs::File> {
Err(unsupported("Dir::create_new"))
}
pub fn create_dir(&self, _name: &str) -> io::Result<()> {
Err(unsupported("Dir::create_dir"))
}
pub fn remove_dir(&self, _name: &str) -> io::Result<()> {
Err(unsupported("Dir::remove_dir"))
}
pub fn remove_file(&self, _name: &str) -> io::Result<()> {
Err(unsupported("Dir::remove_file"))
}
pub fn entry_names(&self, _limits: super::ListLimits) -> io::Result<super::Listing> {
Err(unsupported("Dir::entry_names"))
}
}
fn unsupported(name: &'static str) -> io::Error {
io::Error::new(
io::ErrorKind::Unsupported,
format!("fs::capability::Dir::{name} is Unix-only"),
)
}
}
#[cfg(unix)]
fn single_component(name: &str) -> io::Result<std::ffi::CString> {
if name.is_empty() || name == "." || name == ".." || name.contains('/') {
let refusal = Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("{name:?} is not a single path component"),
));
#[cfg(feature = "trace")]
crate::trace::debug!(error = ?refusal.as_ref().err(), "single_component: returning an error to the caller");
return refusal;
}
std::ffi::CString::new(name).map_err(|cause| {
let at = cause.nul_position();
io::Error::new(io::ErrorKind::InvalidInput, InteriorNul { cause, at })
})
}
#[cfg(unix)]
#[derive(Debug)]
struct InteriorNul {
cause: std::ffi::NulError,
at: usize,
}
#[cfg(unix)]
impl std::fmt::Display for InteriorNul {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
formatter,
"path component contains an interior NUL at offset {}",
self.at
)
}
}
#[cfg(unix)]
impl std::error::Error for InteriorNul {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.cause)
}
}
#[cfg(unix)]
fn classify(raw: rustix::fs::RawMode) -> FileKind {
use rustix::fs::FileType as Ft;
let file_type = Ft::from_raw_mode(raw);
if file_type == Ft::RegularFile {
FileKind::File
} else if file_type == Ft::Directory {
FileKind::Directory
} else if file_type == Ft::Symlink {
FileKind::Symlink
} else if file_type == Ft::Fifo {
FileKind::Fifo
} else if file_type == Ft::CharacterDevice {
FileKind::CharDevice
} else if file_type == Ft::BlockDevice {
FileKind::BlockDevice
} else if file_type == Ft::Socket {
FileKind::Socket
} else {
FileKind::Unknown
}
}
#[cfg(unix)]
fn user_writable_mode() -> rustix::fs::Mode {
rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR
}
#[cfg(unix)]
fn user_directory_mode() -> rustix::fs::Mode {
rustix::fs::Mode::RWXU
}
#[cfg(unix)]
fn errno_to_io(errno: rustix::io::Errno) -> io::Error {
io::Error::from_raw_os_error(errno.raw_os_error())
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::fs as stdfs;
use std::io::Read;
use std::io::Write;
use std::os::unix::fs::symlink;
fn read_all(file: &mut std::fs::File) -> io::Result<String> {
let mut text = String::new();
file.read_to_string(&mut text)?;
Ok(text)
}
#[test]
fn open_reads_the_named_directory() -> io::Result<()> {
let tmp = tempfile::tempdir()?;
stdfs::write(tmp.path().join("f.txt"), b"hello")?;
let dir = Dir::open(tmp.path())?;
let mut file = dir.open_entry("f.txt", OpenFlags::read())?;
assert_eq!(read_all(&mut file)?, "hello");
Ok(())
}
#[test]
fn open_refuses_a_file_as_a_directory() -> io::Result<()> {
let tmp = tempfile::tempdir()?;
stdfs::write(tmp.path().join("f.txt"), b"")?;
assert_ne!(
kind_of(Dir::open(tmp.path().join("f.txt"))),
io::ErrorKind::Unsupported,
"a real refusal, not an absent capability"
);
Ok(())
}
#[test]
fn open_subdir_descends_without_a_path() -> io::Result<()> {
let tmp = tempfile::tempdir()?;
stdfs::create_dir(tmp.path().join("a"))?;
stdfs::write(tmp.path().join("a/f.txt"), b"nested")?;
let root = Dir::open(tmp.path())?;
let child = root.open_subdir("a")?;
let mut file = child.open_entry("f.txt", OpenFlags::read())?;
assert_eq!(read_all(&mut file)?, "nested");
Ok(())
}
#[test]
fn an_opened_subdir_survives_its_path_being_repointed() -> io::Result<()> {
let tmp = tempfile::tempdir()?;
stdfs::create_dir(tmp.path().join("victim"))?;
stdfs::create_dir(tmp.path().join("attacker"))?;
stdfs::write(tmp.path().join("victim/secret.txt"), b"victim bytes")?;
stdfs::write(tmp.path().join("attacker/secret.txt"), b"attacker bytes")?;
let root = Dir::open(tmp.path())?;
let admitted = root.open_subdir("victim")?;
stdfs::rename(
tmp.path().join("attacker/secret.txt"),
tmp.path().join("victim/secret.txt"),
)?;
stdfs::remove_file(tmp.path().join("victim/secret.txt"))?;
stdfs::write(tmp.path().join("victim/secret.txt"), b"attacker bytes")?;
let mut file = admitted.open_entry("secret.txt", OpenFlags::read())?;
assert_eq!(
read_all(&mut file)?,
"attacker bytes",
"a descriptor observes the bytes written to the directory it holds; \
what it never does is re-resolve the *name* to a different directory"
);
Ok(())
}
#[test]
fn a_name_that_is_not_one_component_is_refused() -> io::Result<()> {
let tmp = tempfile::tempdir()?;
let dir = Dir::open(tmp.path())?;
for name in ["", ".", "..", "a/b", "../escape"] {
assert_eq!(
kind_of(dir.open_entry(name, OpenFlags::read()).map(|_| ())),
io::ErrorKind::InvalidInput,
"{name:?} must be refused as a bad component"
);
}
Ok(())
}
#[test]
fn dotdot_cannot_walk_out_of_the_admitted_directory() -> io::Result<()> {
let tmp = tempfile::tempdir()?;
stdfs::write(tmp.path().join("outside.txt"), b"outside")?;
stdfs::create_dir(tmp.path().join("inside"))?;
let root = Dir::open(tmp.path())?;
let inside = root.open_subdir("inside")?;
assert_eq!(
kind_of(inside.open_entry("..", OpenFlags::read()).map(|_| ())),
io::ErrorKind::InvalidInput,
"`..` is refused before the kernel sees it"
);
assert!(
inside
.open_entry("../outside.txt", OpenFlags::read())
.is_err()
);
Ok(())
}
#[test]
fn create_new_refuses_an_existing_name() -> io::Result<()> {
let tmp = tempfile::tempdir()?;
let dir = Dir::open(tmp.path())?;
dir.create_new("f.txt")?.write_all(b"first")?;
assert_eq!(
kind_of(dir.create_new("f.txt").map(|_| ())),
io::ErrorKind::AlreadyExists,
"the refusal must be EEXIST, not a silent overwrite"
);
let mut file = dir.open_entry("f.txt", OpenFlags::read())?;
assert_eq!(read_all(&mut file)?, "first", "the original survived");
Ok(())
}
#[test]
fn a_created_file_is_not_writable_by_group_or_world() -> io::Result<()> {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir()?;
let dir = Dir::open(tmp.path())?;
dir.create_new("f.txt")?;
let mode = stdfs::metadata(tmp.path().join("f.txt"))?
.permissions()
.mode()
& 0o7777;
assert_eq!(
mode & 0o077,
0,
"created file is group/world accessible: {mode:o} grants {:o} to others",
mode & 0o077
);
assert_eq!(
mode & 0o4000,
0,
"created file carries the setuid bit: {mode:o}"
);
assert_eq!(
mode & 0o777,
0o600 & !current_umask(),
"created file is not user read/write: {mode:o}"
);
Ok(())
}
#[test]
fn a_created_directory_is_not_accessible_by_group_or_world() -> io::Result<()> {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir()?;
let dir = Dir::open(tmp.path())?;
dir.create_dir("d")?;
let mode = stdfs::metadata(tmp.path().join("d"))?.permissions().mode() & 0o7777;
assert_eq!(
mode & 0o077,
0,
"created directory is group/world accessible: {mode:o}"
);
assert_eq!(
mode & 0o777,
0o700 & !current_umask(),
"created directory is not user rwx: {mode:o}"
);
Ok(())
}
fn current_umask() -> u32 {
let Ok(tmp) = tempfile::tempdir() else {
return 0;
};
if stdfs::write(tmp.path().join("probe"), b"").is_err() {
return 0;
}
use std::os::unix::fs::PermissionsExt;
let mode = stdfs::metadata(tmp.path().join("probe"))
.map_or(0o600, |meta| meta.permissions().mode());
0o600 & !mode
}
#[test]
fn a_readless_write_request_is_refused_rather_than_truncating() -> io::Result<()> {
let tmp = tempfile::tempdir()?;
let dir = Dir::open(tmp.path())?;
stdfs::write(tmp.path().join("keep.txt"), b"ORIGINAL")?;
let flags = OpenFlags {
read: false,
write: false,
create: false,
truncate: true,
symlinks: SymlinkPolicy::NoFollow,
};
let result = dir.open_entry("keep.txt", flags);
let contents = stdfs::read(tmp.path().join("keep.txt"))?;
assert_eq!(
contents, b"ORIGINAL",
"a truncate request without write access emptied the file"
);
assert!(
result.is_err() || contents == b"ORIGINAL",
"a refused truncate must also leave the file intact"
);
Ok(())
}
#[test]
fn create_dir_and_remove_dir_round_trip() -> io::Result<()> {
let tmp = tempfile::tempdir()?;
let dir = Dir::open(tmp.path())?;
dir.create_dir("d")?;
assert_eq!(dir.kind("d")?, FileKind::Directory);
dir.remove_dir("d")?;
assert_eq!(
kind_of(dir.kind("d").map(|_| ())),
io::ErrorKind::NotFound,
"a removed directory must not still report its kind"
);
Ok(())
}
#[test]
fn remove_dir_refuses_a_non_empty_directory() -> io::Result<()> {
let tmp = tempfile::tempdir()?;
let dir = Dir::open(tmp.path())?;
dir.create_dir("d")?;
let child = dir.open_subdir("d")?;
child.create_new("keep.txt")?;
assert!(
dir.remove_dir("d").is_err(),
"rmdir must not delete a subtree; callers remove entries themselves"
);
assert_eq!(
dir.kind("d")?,
FileKind::Directory,
"the directory survived"
);
Ok(())
}
#[test]
fn remove_file_unlinks_a_symlink_not_its_target() -> io::Result<()> {
let tmp = tempfile::tempdir()?;
stdfs::write(tmp.path().join("target.txt"), b"target")?;
symlink("target.txt", tmp.path().join("link"))?;
let dir = Dir::open(tmp.path())?;
assert_eq!(dir.kind("link")?, FileKind::Symlink);
dir.remove_file("link")?;
assert_eq!(
dir.kind("target.txt")?,
FileKind::File,
"removing a link must not touch what it pointed at"
);
assert!(dir.kind("link").is_err(), "the link itself is gone");
Ok(())
}
#[test]
fn kind_reports_a_symlink_without_following_it() -> io::Result<()> {
let tmp = tempfile::tempdir()?;
stdfs::create_dir(tmp.path().join("real"))?;
symlink("real", tmp.path().join("link"))?;
let dir = Dir::open(tmp.path())?;
assert_eq!(dir.kind("link")?, FileKind::Symlink);
assert_eq!(dir.kind("real")?, FileKind::Directory);
Ok(())
}
#[test]
fn read_link_returns_the_target_unresolved() -> io::Result<()> {
let tmp = tempfile::tempdir()?;
symlink("../elsewhere/thing", tmp.path().join("link"))?;
let dir = Dir::open(tmp.path())?;
assert_eq!(
dir.read_link("link")?,
OsString::from("../elsewhere/thing"),
"the target is reported as stored, for the caller to judge"
);
Ok(())
}
#[test]
fn open_entry_does_not_follow_a_symlink_by_default() -> io::Result<()> {
let tmp = tempfile::tempdir()?;
stdfs::write(tmp.path().join("secret.txt"), b"secret")?;
symlink("secret.txt", tmp.path().join("link"))?;
let dir = Dir::open(tmp.path())?;
assert!(
dir.open_entry("link", OpenFlags::read()).is_err(),
"the default must refuse to open through a link"
);
let flags = OpenFlags {
symlinks: SymlinkPolicy::FollowFinal,
..OpenFlags::read()
};
let mut file = dir.open_entry("link", flags)?;
assert_eq!(
read_all(&mut file)?,
"secret",
"following is a stated choice"
);
Ok(())
}
#[test]
fn open_subdir_refuses_to_follow_a_symlinked_directory() -> io::Result<()> {
let tmp = tempfile::tempdir()?;
stdfs::create_dir(tmp.path().join("real"))?;
symlink("real", tmp.path().join("link"))?;
let dir = Dir::open(tmp.path())?;
assert!(
dir.open_subdir("link").is_err(),
"descending through a link must be an explicit, separate decision"
);
Ok(())
}
#[test]
#[cfg(target_os = "linux")]
fn entry_names_lists_what_the_directory_holds() -> io::Result<()> {
let tmp = tempfile::tempdir()?;
let dir = Dir::open(tmp.path())?;
dir.create_dir("a")?;
dir.create_new("b.txt")?;
let listing = dir.entry_names(ListLimits::default())?;
assert_eq!(listing.names(), ["a", "b.txt"]);
assert!(listing.is_complete());
assert!(
!listing
.names()
.iter()
.any(|name| name == "." || name == ".."),
"`.` and `..` are not entries a caller can act on"
);
Ok(())
}
#[test]
#[cfg(target_os = "linux")]
fn listing_a_dir_that_already_created_a_child_is_not_empty() -> io::Result<()> {
let tmp = tempfile::tempdir()?;
let dir = Dir::open(tmp.path())?;
dir.create_dir("a")?;
dir.create_new("b.txt")?;
let names = dir.entry_names(ListLimits::default())?;
assert_eq!(
names.names(),
["a", "b.txt"],
"a Dir that created entries must still list them"
);
let again = dir.entry_names(ListLimits::default())?;
assert_eq!(
again, names,
"a second listing must be identical, not a continuation"
);
Ok(())
}
#[test]
#[cfg(target_os = "linux")]
fn an_empty_directory_lists_nothing() -> io::Result<()> {
let tmp = tempfile::tempdir()?;
let dir = Dir::open(tmp.path())?;
let listing = dir.entry_names(ListLimits::default())?;
assert!(listing.names().is_empty() && listing.is_complete());
Ok(())
}
#[test]
#[cfg(target_os = "linux")]
fn a_listing_is_bounded_by_entries_and_name_bytes() -> io::Result<()> {
let tmp = tempfile::tempdir()?;
let dir = Dir::open(tmp.path())?;
for index in 0..10 {
dir.create_new(&format!("entry-{index}"))?;
}
let by_count = dir.entry_names(ListLimits::new(4, usize::MAX))?;
assert_eq!(by_count.names().len(), 4);
assert!(by_count.is_truncated() && !by_count.is_complete());
let by_bytes = dir.entry_names(ListLimits::new(usize::MAX, 20))?;
assert_eq!(by_bytes.names().len(), 2);
assert!(by_bytes.is_truncated());
let exact = dir.entry_names(ListLimits::new(10, 70))?;
assert_eq!(exact.names().len(), 10, "exact ceilings admit everything");
assert!(exact.is_complete());
Ok(())
}
#[test]
#[cfg(target_os = "linux")]
fn a_non_utf8_name_is_counted_not_returned() -> io::Result<()> {
use std::os::unix::ffi::OsStrExt;
let tmp = tempfile::tempdir()?;
std::fs::write(
tmp.path().join(std::ffi::OsStr::from_bytes(b"bad-\xff")),
b"",
)?;
std::fs::write(tmp.path().join("good"), b"")?;
let dir = Dir::open(tmp.path())?;
let listing = dir.entry_names(ListLimits::default())?;
assert_eq!(listing.names(), ["good"]);
assert_eq!(listing.unaddressable(), 1);
assert!(!listing.is_truncated() && !listing.is_complete());
Ok(())
}
#[test]
#[cfg(unix)]
fn following_a_relative_link_is_not_contained() -> io::Result<()> {
let root = tempfile::tempdir()?;
stdfs::create_dir(root.path().join("inside"))?;
stdfs::write(root.path().join("outside.txt"), b"OUTSIDE")?;
symlink("../outside.txt", root.path().join("inside/escape"))?;
let dir = Dir::open(root.path().join("inside"))?;
let flags = OpenFlags {
symlinks: SymlinkPolicy::FollowFinal,
..OpenFlags::read()
};
let mut file = dir.open_entry("escape", flags)?;
assert_eq!(
read_all(&mut file)?,
"OUTSIDE",
"following leaves the admitted directory; that is documented now"
);
assert_eq!(dir.kind("escape")?, FileKind::Symlink);
Ok(())
}
#[test]
fn create_refuses_to_follow_a_final_symlink() -> io::Result<()> {
let tmp = tempfile::tempdir()?;
stdfs::write(tmp.path().join("target.txt"), b"INTACT")?;
symlink("target.txt", tmp.path().join("link"))?;
let dir = Dir::open(tmp.path())?;
let flags = OpenFlags {
write: true,
create: true,
truncate: true,
symlinks: SymlinkPolicy::FollowFinal,
..OpenFlags::default()
};
assert_eq!(
kind_of(dir.open_entry("link", flags).map(|_| ())),
io::ErrorKind::InvalidInput,
"create+FollowFinal must be refused before the syscall"
);
assert_eq!(
stdfs::read(tmp.path().join("target.txt"))?,
b"INTACT",
"the link's target must be untouched"
);
Ok(())
}
#[test]
#[cfg(unix)]
fn a_cloned_dir_is_closed_across_exec() -> io::Result<()> {
use std::os::fd::AsRawFd;
let tmp = tempfile::tempdir()?;
let dir = Dir::open(tmp.path())?;
let clone = dir.try_clone()?;
let fd = clone.as_fd().as_raw_fd();
let flags = rustix::io::fcntl_getfd(clone.as_fd()).map_err(errno_to_io)?;
assert!(
flags.contains(rustix::io::FdFlags::CLOEXEC),
"cloned fd {fd} lacks FD_CLOEXEC and would leak into a child"
);
assert!(
rustix::io::fcntl_getfd(dir.as_fd())
.map_err(errno_to_io)?
.contains(rustix::io::FdFlags::CLOEXEC),
"the original descriptor must still be close-on-exec"
);
Ok(())
}
#[test]
fn a_missing_name_is_not_found_not_a_silent_zero() -> io::Result<()> {
let tmp = tempfile::tempdir()?;
let dir = Dir::open(tmp.path())?;
assert_eq!(
kind_of(dir.kind("nope").map(|_| ())),
io::ErrorKind::NotFound,
"an absent name must not report a kind"
);
assert!(dir.open_entry("nope", OpenFlags::read()).is_err());
Ok(())
}
#[test]
fn a_clone_is_the_same_directory() -> io::Result<()> {
let tmp = tempfile::tempdir()?;
stdfs::write(tmp.path().join("f.txt"), b"x")?;
let dir = Dir::open(tmp.path())?;
let clone = dir.try_clone()?;
let mut file = clone.open_entry("f.txt", OpenFlags::read())?;
assert_eq!(read_all(&mut file)?, "x");
drop(clone);
let mut file = dir.open_entry("f.txt", OpenFlags::read())?;
assert_eq!(read_all(&mut file)?, "x", "the original outlives its clone");
Ok(())
}
#[test]
fn a_deleted_entry_is_reported_rather_than_silently_absent() -> io::Result<()> {
let tmp = tempfile::tempdir()?;
let dir = Dir::open(tmp.path())?;
dir.create_new("doomed.txt")?;
dir.remove_file("doomed.txt")?;
assert_eq!(
kind_of(dir.kind("doomed.txt").map(|_| ())),
io::ErrorKind::NotFound,
"a concurrent delete surfaces as ENOENT for the caller to record"
);
Ok(())
}
fn kind_of<T>(result: io::Result<T>) -> io::ErrorKind {
match result {
Ok(_) => io::ErrorKind::UnexpectedEof,
Err(error) => error.kind(),
}
}
}