use std::ffi::OsStr;
use std::fs::File;
use std::io;
use std::path::{Path, PathBuf};
#[cfg(unix)]
mod unix;
#[cfg(unix)]
pub use unix::UnixDirHandle;
#[cfg(unix)]
pub type ActiveDirHandle = UnixDirHandle;
#[cfg(windows)]
mod windows;
#[cfg(windows)]
pub use windows::WindowsDirHandle;
#[cfg(windows)]
pub type ActiveDirHandle = WindowsDirHandle;
pub trait DirHandle: Sized + Send + Sync + crate::sealed::Sealed {
type Attrs: Copy;
fn open_root(path: &Path) -> Result<Self, GuardIo>;
fn open_child_dir(&self, name: &OsStr) -> Result<Self, GuardIo>;
fn make_child_dir(&self, name: &OsStr) -> Result<(), GuardIo>;
fn open_child_file(&self, name: &OsStr, mode: OpenMode) -> Result<File, GuardIo>;
fn create_child_file(&self, name: &OsStr, excl: Excl) -> Result<File, GuardIo>;
fn rename_child(&self, from: &OsStr, to: &OsStr) -> Result<(), GuardIo>;
fn unlink_child(&self, name: &OsStr);
fn child_kind(&self, name: &OsStr) -> Option<NodeKind>;
fn child_attrs(&self, name: &OsStr) -> Option<Self::Attrs>;
fn apply_attrs(&self, file: &File, attrs: Self::Attrs) -> io::Result<()>;
fn identity(&self) -> Result<NodeId, GuardIo>;
fn resolve_beneath(&self, rel: &Path, mode: OpenMode) -> Result<Option<File>, GuardIo>;
fn into_file(self) -> File;
fn sync_name_durability(&self) -> Result<(), GuardIo>;
}
#[derive(Debug)]
pub struct GuardIo {
pub error: io::Error,
pub refused_a_link: bool,
}
impl GuardIo {
pub fn io(error: io::Error) -> Self {
Self {
error,
refused_a_link: false,
}
}
pub fn link(error: io::Error) -> Self {
Self {
error,
refused_a_link: true,
}
}
pub fn last_os_error() -> Self {
Self::io(io::Error::last_os_error())
}
}
impl From<io::Error> for GuardIo {
fn from(error: io::Error) -> Self {
Self::io(error)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum NodeKind {
Dir,
RegularFile,
NotFollowable {
tag: Option<u32>,
},
NotAFile,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NodeId {
pub volume: u64,
pub file: u128,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OpenMode {
File,
Dir,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Excl {
Truncate,
MustNotExist,
}
pub fn identity_of(file: &File) -> io::Result<NodeId> {
#[cfg(unix)]
{
unix::identity_of(file)
}
#[cfg(windows)]
{
windows::identity_of(file)
}
}
pub fn identity_at(path: &Path) -> io::Result<NodeId> {
#[cfg(unix)]
{
unix::identity_at(path)
}
#[cfg(windows)]
{
windows::identity_at(path)
}
}
pub fn normalized_name(file: &File) -> io::Result<Option<PathBuf>> {
#[cfg(unix)]
{
let _ = file;
Ok(None)
}
#[cfg(windows)]
{
windows::normalized_name(file)
}
}
pub fn unblock(file: &File) -> io::Result<()> {
#[cfg(unix)]
{
unix::clear_nonblock(file)
}
#[cfg(windows)]
{
let _ = file;
Ok(())
}
}
pub fn refuse_component(name: &OsStr) -> Option<&'static str> {
let Some(text) = name.to_str() else {
return Some("is not valid UTF-8");
};
if text.is_empty() {
return Some("is empty");
}
if text.contains(':') {
return Some("names an alternate data stream (`:`)");
}
if text.ends_with('.') || text.ends_with(' ') {
return Some("ends with a dot or space, which Windows silently strips");
}
if is_reserved_device_name(text) {
return Some("is a reserved device name");
}
None
}
fn is_reserved_device_name(text: &str) -> bool {
let stem = text
.split('.')
.next()
.unwrap_or(text)
.trim_end_matches([' ', '.']);
if matches!(
stem.to_ascii_uppercase().as_str(),
"CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$"
) {
return true;
}
let upper = stem.to_ascii_uppercase();
for prefix in ["COM", "LPT"] {
if let Some(rest) = upper.strip_prefix(prefix) {
if rest.len() == 1 && matches!(rest.as_bytes()[0], b'1'..=b'9') {
return true;
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_lexical_refusals_hold_on_every_platform() {
for bad in [
"AGENTS.md:evil",
"AGENTS.md.",
"AGENTS.md ",
"CON",
"con.txt",
"NUL",
"COM1",
"lpt9.log",
"",
] {
assert!(
refuse_component(OsStr::new(bad)).is_some(),
"`{bad}` must be refused"
);
}
for ok in [
"AGENTS.md",
"console.log",
"COM0",
"COM10",
"nulls.rs",
"a.b.c",
"LPTX",
] {
assert_eq!(
refuse_component(OsStr::new(ok)),
None,
"`{ok}` must be allowed"
);
}
}
#[test]
fn active_adapter_upholds_the_contract() {
use std::io::{Read, Write};
let scratch = std::env::temp_dir().join(format!("hotl-dirhandle-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&scratch);
std::fs::create_dir_all(scratch.join("sub")).unwrap();
std::fs::write(scratch.join("sub").join("f.txt"), b"hello").unwrap();
let root = ActiveDirHandle::open_root(&scratch).unwrap();
assert_eq!(root.child_kind(OsStr::new("sub")), Some(NodeKind::Dir));
assert_eq!(root.child_kind(OsStr::new("nope")), None);
let sub = root.open_child_dir(OsStr::new("sub")).unwrap();
assert_eq!(
sub.child_kind(OsStr::new("f.txt")),
Some(NodeKind::RegularFile)
);
let mut f = sub
.open_child_file(OsStr::new("f.txt"), OpenMode::File)
.unwrap();
unblock(&f).unwrap();
let mut s = String::new();
f.read_to_string(&mut s).unwrap();
assert_eq!(s, "hello");
assert_ne!(root.identity().unwrap(), sub.identity().unwrap());
assert!(sub
.create_child_file(OsStr::new("f.txt"), Excl::MustNotExist)
.is_err());
let mut new = sub
.create_child_file(OsStr::new("g.txt"), Excl::MustNotExist)
.unwrap();
new.write_all(b"g").unwrap();
drop(new);
sub.rename_child(OsStr::new("g.txt"), OsStr::new("h.txt"))
.unwrap();
assert_eq!(sub.child_kind(OsStr::new("g.txt")), None);
sub.sync_name_durability().unwrap();
sub.unlink_child(OsStr::new("h.txt"));
assert_eq!(sub.child_kind(OsStr::new("h.txt")), None);
let _ = std::fs::remove_dir_all(&scratch);
}
}