use std::ffi::OsStr;
use std::fmt;
use std::fs::File;
use std::io::{Read, Write};
use std::os::fd::{AsFd, BorrowedFd, OwnedFd};
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use rustix::fs::{AtFlags, FileType, Mode, OFlags, RawMode};
use rustix::io::Errno;
use serde::Serialize;
pub const DEFAULT_DIR_MODE: u32 = 0o777;
pub const DEFAULT_FILE_MODE: u32 = 0o666;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkdirOperation {
Write,
Append,
Read,
CreateDir,
Remove,
}
impl WorkdirOperation {
fn verb(self) -> &'static str {
match self {
Self::Write => "write",
Self::Append => "append to",
Self::Read => "read",
Self::CreateDir => "create directory",
Self::Remove => "remove",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum RefusalReason {
Symlink,
HardLink,
NotARegularFile,
InvalidComponent,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct WorkdirRefusal {
pub path: String,
pub refused_at: String,
pub operation: WorkdirOperation,
pub reason: RefusalReason,
}
impl fmt::Display for WorkdirRefusal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let what = match self.reason {
RefusalReason::Symlink => "is a symbolic link inside the workdir",
RefusalReason::HardLink => {
"is a hard link with another name that may be outside the workdir"
}
RefusalReason::NotARegularFile => "is not a regular file",
RefusalReason::InvalidComponent => "is not a plain relative path inside the workdir",
};
write!(
f,
"refused to {} {}: {} {what}",
self.operation.verb(),
self.path,
self.refused_at
)
}
}
#[derive(Debug, thiserror::Error)]
pub enum WorkdirError {
#[error("{0}")]
Refused(WorkdirRefusal),
#[error("{path}: {source}")]
Io {
path: String,
#[source]
source: std::io::Error,
},
}
impl WorkdirError {
pub fn is_not_found(&self) -> bool {
matches!(self, Self::Io { source, .. } if source.kind() == std::io::ErrorKind::NotFound)
}
}
impl From<WorkdirError> for std::io::Error {
fn from(error: WorkdirError) -> Self {
match error {
WorkdirError::Refused(refusal) => {
std::io::Error::new(std::io::ErrorKind::PermissionDenied, refusal.to_string())
}
WorkdirError::Io { source, .. } => source,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum EntryKind {
File,
Directory,
Symlink,
Other,
}
impl EntryKind {
fn of(file_type: FileType) -> Self {
match file_type {
FileType::RegularFile => Self::File,
FileType::Directory => Self::Directory,
FileType::Symlink => Self::Symlink,
_ => Self::Other,
}
}
}
#[derive(Clone)]
pub struct WorkdirRoot {
anchor: Arc<Anchor>,
latch: Arc<Mutex<Vec<WorkdirRefusal>>>,
}
struct Anchor {
fd: Mutex<Arc<OwnedFd>>,
path: PathBuf,
prefix: String,
origin: Origin,
}
enum Origin {
Path,
Beneath { from: WorkdirRoot, rel: String },
}
impl fmt::Debug for WorkdirRoot {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("WorkdirRoot")
.field("path", &self.anchor.path)
.field("prefix", &self.anchor.prefix)
.finish()
}
}
enum Held {
Anchor(Arc<OwnedFd>),
Opened(OwnedFd),
}
impl AsFd for Held {
fn as_fd(&self) -> BorrowedFd<'_> {
match self {
Self::Anchor(fd) => fd.as_fd(),
Self::Opened(fd) => fd.as_fd(),
}
}
}
const DIR_FLAGS: OFlags = OFlags::RDONLY
.union(OFlags::DIRECTORY)
.union(OFlags::NOFOLLOW)
.union(OFlags::CLOEXEC)
.union(OFlags::NONBLOCK);
#[cfg(test)]
thread_local! {
static BEFORE_RENAME: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
const { std::cell::RefCell::new(None) };
}
#[cfg(test)]
thread_local! {
static NLINK_UNRELIABLE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
fn nlink_unreliable() -> bool {
#[cfg(test)]
{
NLINK_UNRELIABLE.with(std::cell::Cell::get)
}
#[cfg(not(test))]
{
false
}
}
#[cfg(test)]
pub(crate) fn before_next_rename(hook: impl FnOnce() + 'static) {
BEFORE_RENAME.with(|slot| *slot.borrow_mut() = Some(Box::new(hook)));
}
fn open_by_path(path: &Path) -> Result<OwnedFd, WorkdirError> {
rustix::fs::open(path, DIR_FLAGS.difference(OFlags::NOFOLLOW), Mode::empty()).map_err(|errno| {
WorkdirError::Io {
path: path.display().to_string(),
source: errno.into(),
}
})
}
fn mode_of(mode: u32) -> Mode {
Mode::from_raw_mode(mode as RawMode)
}
impl WorkdirRoot {
pub fn open(path: &Path) -> Result<Self, WorkdirError> {
Ok(Self {
anchor: Arc::new(Anchor {
fd: Mutex::new(Arc::new(open_by_path(path)?)),
path: path.to_path_buf(),
prefix: String::new(),
origin: Origin::Path,
}),
latch: Arc::new(Mutex::new(Vec::new())),
})
}
pub fn open_beneath(&self, rel: &str) -> Result<Option<Self>, WorkdirError> {
let op = WorkdirOperation::Read;
let segments = self.segments(rel, op)?;
let Some(held) = self.walk(rel, &segments, segments.len(), op, None)? else {
return Ok(None);
};
let Held::Opened(fd) = held else {
unreachable!("a walk over at least one segment opens a descriptor")
};
Ok(Some(Self {
anchor: Arc::new(Anchor {
fd: Mutex::new(Arc::new(fd)),
path: self.anchor.path.join(rel),
prefix: self.display(rel),
origin: Origin::Beneath {
from: self.clone(),
rel: rel.to_string(),
},
}),
latch: Arc::clone(&self.latch),
}))
}
pub fn open_sharing_latch(path: &Path, latch_of: &WorkdirRoot) -> Result<Self, WorkdirError> {
let opened = Self::open(path)?;
Ok(Self {
anchor: opened.anchor,
latch: Arc::clone(&latch_of.latch),
})
}
pub fn open_sharing_latch_absolute(
path: &Path,
latch_of: &WorkdirRoot,
) -> Result<Self, WorkdirError> {
let opened = Self::open(path)?;
Ok(Self {
anchor: Arc::new(Anchor {
fd: Mutex::new(opened.anchor_fd(false)?),
path: path.to_path_buf(),
prefix: path.display().to_string(),
origin: Origin::Path,
}),
latch: Arc::clone(&latch_of.latch),
})
}
pub fn identity(&self) -> Result<(u64, u64), WorkdirError> {
fd_identity(&*self.anchor_fd(false)?).map_err(|errno| self.io("", errno.into()))
}
pub fn dir_fd(&self) -> Result<OwnedFd, WorkdirError> {
let anchor = self.anchor_fd(false)?;
rustix::fs::openat(&*anchor, ".", DIR_FLAGS, Mode::empty())
.map_err(|errno| self.io("", errno.into()))
}
#[cfg(target_os = "linux")]
pub fn path_fd(&self) -> Result<OwnedFd, WorkdirError> {
let anchor = self.anchor_fd(false)?;
rustix::fs::openat(
&*anchor,
".",
OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
Mode::empty(),
)
.map_err(|errno| self.io("", errno.into()))
}
#[cfg(target_os = "linux")]
pub fn path_fd_beneath(&self, rel: impl AsRef<Path>) -> Result<OwnedFd, WorkdirError> {
let rel = rel.as_ref().to_string_lossy();
let op = WorkdirOperation::Read;
let (parent, name) = self.parent_of(&rel, op, false)?;
let Some(parent) = parent else {
return Err(self.io(&rel, std::io::ErrorKind::NotFound.into()));
};
let flags = OFlags::PATH | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
let refused_or = |errno: Errno| match self.leaf_type(&parent, name, &rel) {
Ok(Some(FileType::Symlink)) => self.refuse(&rel, &rel, op, RefusalReason::Symlink),
Ok(_) => self.io(&rel, errno.into()),
Err(error) => error,
};
let fd = rustix::fs::openat(&parent, name, flags, Mode::empty()).map_err(refused_or)?;
let stat = rustix::fs::fstat(&fd).map_err(|errno| self.io(&rel, errno.into()))?;
if FileType::from_raw_mode(stat.st_mode) != FileType::Directory {
return Err(refused_or(Errno::NOTDIR));
}
Ok(fd)
}
pub fn path(&self) -> &Path {
&self.anchor.path
}
pub fn host_path(&self, rel: impl AsRef<Path>) -> PathBuf {
self.anchor.path.join(rel)
}
pub fn display_path(&self, rel: impl AsRef<Path>) -> String {
self.display(&rel.as_ref().to_string_lossy())
}
pub fn owner(&self) -> Result<(u32, u32), WorkdirError> {
let stat = rustix::fs::fstat(&*self.anchor_fd(false)?)
.map_err(|errno| self.io("", errno.into()))?;
Ok((stat.st_uid, stat.st_gid))
}
pub fn take_refusals(&self) -> Vec<WorkdirRefusal> {
std::mem::take(&mut *self.latch.lock().unwrap_or_else(|e| e.into_inner()))
}
pub fn write_file(
&self,
rel: impl AsRef<Path>,
bytes: &[u8],
mode: u32,
) -> Result<(), WorkdirError> {
self.retry_if_removed(|| self.write_file_inner(rel.as_ref(), bytes, mode, false))
}
fn write_file_inner(
&self,
rel: &Path,
bytes: &[u8],
mode: u32,
exact_mode: bool,
) -> Result<(), WorkdirError> {
let rel = rel.to_string_lossy();
let op = WorkdirOperation::Write;
let (parent, name) = self.parent_of(&rel, op, true)?;
let parent = parent.expect("a creating walk reaches the parent");
match self.leaf_type(&parent, name, &rel)? {
Some(FileType::Symlink) => {
return Err(self.refuse(&rel, &rel, op, RefusalReason::Symlink));
}
Some(FileType::Directory) => {
return Err(self.io(&rel, std::io::ErrorKind::IsADirectory.into()));
}
_ => {}
}
let temp = format!(
".{}.{}.tmp",
name.to_string_lossy(),
uuid::Uuid::now_v7().simple()
);
let flags =
OFlags::CREATE | OFlags::EXCL | OFlags::WRONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
let fd = rustix::fs::openat(&parent, temp.as_str(), flags, mode_of(mode))
.map_err(|errno| self.io(&rel, errno.into()))?;
let chmod = if exact_mode {
rustix::fs::fchmod(&fd, mode_of(mode)).map_err(|errno| self.io(&rel, errno.into()))
} else {
Ok(())
};
let result = chmod
.and_then(|()| {
File::from(fd)
.write_all(bytes)
.map_err(|source| self.io(&rel, source))
})
.and_then(|()| {
#[cfg(test)]
if let Some(hook) = BEFORE_RENAME.with(|slot| slot.borrow_mut().take()) {
hook();
}
rustix::fs::renameat(&parent, temp.as_str(), &parent, name)
.map_err(|errno| self.io(&rel, errno.into()))
});
if result.is_err() {
let _ = rustix::fs::unlinkat(&parent, temp.as_str(), AtFlags::empty());
}
result
}
pub fn write_file_exact_mode(
&self,
rel: impl AsRef<Path>,
bytes: &[u8],
mode: u32,
) -> Result<(), WorkdirError> {
self.retry_if_removed(|| self.write_file_inner(rel.as_ref(), bytes, mode, true))
}
pub fn overwrite_in_place(
&self,
rel: impl AsRef<Path>,
bytes: &[u8],
mode: u32,
) -> Result<(), WorkdirError> {
let rel = rel.as_ref().to_string_lossy();
let op = WorkdirOperation::Write;
let flags = OFlags::WRONLY | OFlags::CREATE;
let mut file = self.retry_if_removed(|| self.open_regular(&rel, op, flags, mode, true))?;
file.set_len(0).map_err(|source| self.io(&rel, source))?;
file.write_all(bytes)
.map_err(|source| self.io(&rel, source))
}
pub fn open_append(&self, rel: impl AsRef<Path>, mode: u32) -> Result<File, WorkdirError> {
let rel = rel.as_ref().to_string_lossy();
let flags = OFlags::WRONLY | OFlags::APPEND | OFlags::CREATE;
self.retry_if_removed(|| {
self.open_regular(&rel, WorkdirOperation::Append, flags, mode, true)
})
}
pub fn read_file(
&self,
rel: impl AsRef<Path>,
max_bytes: u64,
) -> Result<Option<Vec<u8>>, WorkdirError> {
let rel = rel.as_ref().to_string_lossy();
let mut file =
match self.open_regular(&rel, WorkdirOperation::Read, OFlags::RDONLY, 0, false) {
Ok(file) => file,
Err(error) if error.is_not_found() => return Ok(None),
Err(error) => return Err(error),
};
let mut bytes = Vec::new();
Read::by_ref(&mut file)
.take(max_bytes.saturating_add(1))
.read_to_end(&mut bytes)
.map_err(|source| self.io(&rel, source))?;
if bytes.len() as u64 > max_bytes {
return Err(self.io(
&rel,
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("larger than {max_bytes} bytes"),
),
));
}
Ok(Some(bytes))
}
pub fn read_to_string(&self, rel: impl AsRef<Path>) -> Result<Option<String>, WorkdirError> {
let rel = rel.as_ref();
let Some(bytes) = self.read_file(rel, u64::MAX)? else {
return Ok(None);
};
String::from_utf8(bytes).map(Some).map_err(|error| {
self.io(
&rel.to_string_lossy(),
std::io::Error::new(std::io::ErrorKind::InvalidData, error),
)
})
}
pub fn create_dir_all(&self, rel: impl AsRef<Path>, mode: u32) -> Result<(), WorkdirError> {
let rel = rel.as_ref().to_string_lossy();
let op = WorkdirOperation::CreateDir;
let segments = self.segments(&rel, op)?;
self.retry_if_removed(|| self.walk(&rel, &segments, segments.len(), op, Some(mode)))
.map(|_| ())
}
pub fn set_dir_mode(&self, rel: impl AsRef<Path>, mode: u32) -> Result<(), WorkdirError> {
let rel = rel.as_ref().to_string_lossy();
let op = WorkdirOperation::CreateDir;
let (parent, name) = self.parent_of(&rel, op, false)?;
let Some(parent) = parent else {
return Err(self.io(&rel, std::io::ErrorKind::NotFound.into()));
};
match self.leaf_type(&parent, name, &rel)? {
None => Err(self.io(&rel, std::io::ErrorKind::NotFound.into())),
Some(FileType::Symlink) => Err(self.refuse(&rel, &rel, op, RefusalReason::Symlink)),
Some(FileType::Directory) => {
self.chmod_dir(&parent, name, &rel, |_| (mode & 0o7777) as RawMode)
}
Some(_) => Err(self.io(&rel, std::io::ErrorKind::NotADirectory.into())),
}
}
pub fn entry_kind(&self, rel: impl AsRef<Path>) -> Result<Option<EntryKind>, WorkdirError> {
let rel = rel.as_ref().to_string_lossy();
let op = WorkdirOperation::Read;
let (parent, name) = self.parent_of(&rel, op, false)?;
let Some(parent) = parent else {
return Ok(None);
};
Ok(self.leaf_type(&parent, name, &rel)?.map(EntryKind::of))
}
pub fn file_len(&self, rel: impl AsRef<Path>) -> Result<Option<u64>, WorkdirError> {
let rel = rel.as_ref().to_string_lossy();
let op = WorkdirOperation::Read;
let (parent, name) = self.parent_of(&rel, op, false)?;
let Some(parent) = parent else {
return Ok(None);
};
match rustix::fs::statat(&parent, name, AtFlags::SYMLINK_NOFOLLOW) {
Ok(stat) => match FileType::from_raw_mode(stat.st_mode) {
FileType::RegularFile => Ok(Some(stat.st_size as u64)),
FileType::Symlink => Err(self.refuse(&rel, &rel, op, RefusalReason::Symlink)),
_ => Ok(None),
},
Err(Errno::NOENT) => Ok(None),
Err(errno) => Err(self.io(&rel, errno.into())),
}
}
pub fn list_dir(
&self,
rel: impl AsRef<Path>,
) -> Result<Option<Vec<(String, EntryKind)>>, WorkdirError> {
let rel = rel.as_ref().to_string_lossy();
let op = WorkdirOperation::Read;
let held = if rel.is_empty() {
Some(Held::Anchor(self.anchor_fd(false)?))
} else {
let segments = self.segments(&rel, op)?;
self.walk(&rel, &segments, segments.len(), op, None)?
};
let Some(dir) = held else {
return Ok(None);
};
let mut entries = Vec::new();
for (name, file_type) in self.children(&dir, &rel)? {
entries.push((
name.to_string_lossy().into_owned(),
EntryKind::of(file_type),
));
}
entries.sort();
Ok(Some(entries))
}
pub fn remove_file(&self, rel: impl AsRef<Path>) -> Result<(), WorkdirError> {
let rel = rel.as_ref().to_string_lossy();
let op = WorkdirOperation::Remove;
let (parent, name) = self.parent_of(&rel, op, false)?;
let Some(parent) = parent else {
return Err(self.io(&rel, std::io::ErrorKind::NotFound.into()));
};
if self.leaf_type(&parent, name, &rel)? == Some(FileType::Symlink) {
return Err(self.refuse(&rel, &rel, op, RefusalReason::Symlink));
}
rustix::fs::unlinkat(&parent, name, AtFlags::empty())
.map_err(|errno| self.io(&rel, errno.into()))
}
pub fn remove_dir_all(&self, rel: impl AsRef<Path>) -> Result<(), WorkdirError> {
let rel = rel.as_ref().to_string_lossy();
let op = WorkdirOperation::Remove;
let (parent, name) = self.parent_of(&rel, op, false)?;
let Some(parent) = parent else {
return Err(self.io(&rel, std::io::ErrorKind::NotFound.into()));
};
match self.leaf_type(&parent, name, &rel)? {
None => return Err(self.io(&rel, std::io::ErrorKind::NotFound.into())),
Some(FileType::Symlink) => {
return Err(self.refuse(&rel, &rel, op, RefusalReason::Symlink));
}
Some(FileType::Directory) => {}
Some(_) => return Err(self.io(&rel, std::io::ErrorKind::NotADirectory.into())),
}
self.grant_owner_rwx(&parent, name, &rel)?;
let dir = self.open_dir(&parent, name, &rel, &rel, op)?;
self.empty_dir(&dir, &rel)?;
rustix::fs::unlinkat(&parent, name, AtFlags::REMOVEDIR)
.map_err(|errno| self.io(&rel, errno.into()))
}
fn empty_dir(&self, dir: &OwnedFd, rel: &str) -> Result<(), WorkdirError> {
for (name, file_type) in self.children(dir, rel)? {
let child = format!("{rel}/{}", name.to_string_lossy());
if file_type == FileType::Directory {
self.grant_owner_rwx(dir, &name, &child)?;
let sub = self.open_dir(dir, &name, &child, &child, WorkdirOperation::Remove)?;
self.empty_dir(&sub, &child)?;
rustix::fs::unlinkat(dir, &name, AtFlags::REMOVEDIR)
} else {
rustix::fs::unlinkat(dir, &name, AtFlags::empty())
}
.map_err(|errno| self.io(&child, errno.into()))?;
}
Ok(())
}
fn grant_owner_rwx(
&self,
parent: &impl AsFd,
name: &OsStr,
rel: &str,
) -> Result<(), WorkdirError> {
const OWNER_RWX: RawMode = 0o700;
self.chmod_dir(parent, name, rel, |mode| mode | OWNER_RWX)
}
fn chmod_dir(
&self,
parent: &impl AsFd,
name: &OsStr,
rel: &str,
new_mode: impl FnOnce(RawMode) -> RawMode,
) -> Result<(), WorkdirError> {
#[cfg(target_os = "linux")]
{
use std::os::fd::AsRawFd;
let fd = rustix::fs::openat(
parent,
name,
OFlags::PATH | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)
.map_err(|errno| self.io(rel, errno.into()))?;
let stat = rustix::fs::fstat(&fd).map_err(|errno| self.io(rel, errno.into()))?;
let mode = stat.st_mode as RawMode & 0o7777;
let wanted = new_mode(mode);
if wanted == mode {
return Ok(());
}
rustix::fs::chmod(
format!("/proc/self/fd/{}", fd.as_raw_fd()),
Mode::from_raw_mode(wanted),
)
.map_err(|errno| self.io(rel, errno.into()))
}
#[cfg(not(target_os = "linux"))]
{
let stat = rustix::fs::statat(parent, name, AtFlags::SYMLINK_NOFOLLOW)
.map_err(|errno| self.io(rel, errno.into()))?;
if FileType::from_raw_mode(stat.st_mode) != FileType::Directory {
return Err(self.io(rel, std::io::ErrorKind::NotADirectory.into()));
}
let mode = stat.st_mode as RawMode & 0o7777;
let wanted = new_mode(mode);
if wanted == mode {
return Ok(());
}
rustix::fs::chmodat(
parent,
name,
Mode::from_raw_mode(wanted),
AtFlags::SYMLINK_NOFOLLOW,
)
.map_err(|errno| self.io(rel, errno.into()))
}
}
fn children(
&self,
dir: &impl AsFd,
rel: &str,
) -> Result<Vec<(std::ffi::OsString, FileType)>, WorkdirError> {
let mut listing =
rustix::fs::Dir::read_from(dir).map_err(|errno| self.io(rel, errno.into()))?;
let mut names = Vec::new();
while let Some(entry) = listing.read() {
let entry = entry.map_err(|errno| self.io(rel, errno.into()))?;
let name = OsStr::from_bytes(entry.file_name().to_bytes());
if name == "." || name == ".." {
continue;
}
names.push(name.to_os_string());
}
let mut children = Vec::with_capacity(names.len());
for name in names {
match rustix::fs::statat(dir, &name, AtFlags::SYMLINK_NOFOLLOW) {
Ok(stat) => children.push((name, FileType::from_raw_mode(stat.st_mode))),
Err(Errno::NOENT) => {}
Err(errno) => return Err(self.io(rel, errno.into())),
}
}
Ok(children)
}
fn open_regular(
&self,
rel: &str,
op: WorkdirOperation,
flags: OFlags,
mode: u32,
create_parents: bool,
) -> Result<File, WorkdirError> {
let (parent, name) = self.parent_of(rel, op, create_parents)?;
let Some(parent) = parent else {
return Err(self.io(rel, std::io::ErrorKind::NotFound.into()));
};
let flags = flags | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC;
let fd = match rustix::fs::openat(&parent, name, flags, mode_of(mode)) {
Ok(fd) => fd,
Err(errno) => {
return Err(match self.leaf_type(&parent, name, rel)? {
Some(FileType::Symlink) => self.refuse(rel, rel, op, RefusalReason::Symlink),
Some(FileType::RegularFile) | None => self.io(rel, errno.into()),
Some(_) => self.refuse(rel, rel, op, RefusalReason::NotARegularFile),
});
}
};
let stat = rustix::fs::fstat(&fd).map_err(|errno| self.io(rel, errno.into()))?;
if FileType::from_raw_mode(stat.st_mode) != FileType::RegularFile {
return Err(self.refuse(rel, rel, op, RefusalReason::NotARegularFile));
}
if op != WorkdirOperation::Read && stat.st_nlink > 1 {
return Err(self.refuse(rel, rel, op, RefusalReason::HardLink));
}
Ok(File::from(fd))
}
fn segments<'r>(
&self,
rel: &'r str,
op: WorkdirOperation,
) -> Result<Vec<&'r OsStr>, WorkdirError> {
let segments: Vec<&OsStr> = rel.split('/').map(OsStr::new).collect();
if segments
.iter()
.any(|segment| segment.is_empty() || *segment == "." || *segment == "..")
{
return Err(self.refuse(rel, rel, op, RefusalReason::InvalidComponent));
}
Ok(segments)
}
fn parent_of<'r>(
&self,
rel: &'r str,
op: WorkdirOperation,
create: bool,
) -> Result<(Option<Held>, &'r OsStr), WorkdirError> {
let segments = self.segments(rel, op)?;
let name = *segments.last().expect("split yields at least one segment");
let held = self.walk(
rel,
&segments,
segments.len() - 1,
op,
create.then_some(DEFAULT_DIR_MODE),
)?;
Ok((held, name))
}
fn walk(
&self,
rel: &str,
segments: &[&OsStr],
depth: usize,
op: WorkdirOperation,
create: Option<u32>,
) -> Result<Option<Held>, WorkdirError> {
let mut current = Held::Anchor(self.anchor_fd(create.is_some())?);
for index in 0..depth {
let name = segments[index];
let so_far = segments[..=index]
.iter()
.map(|segment| segment.to_string_lossy())
.collect::<Vec<_>>()
.join("/");
let next = match rustix::fs::openat(¤t, name, DIR_FLAGS, Mode::empty()) {
Ok(fd) => fd,
Err(Errno::NOENT) => {
let Some(mode) = create else {
return Ok(None);
};
match rustix::fs::mkdirat(¤t, name, mode_of(mode)) {
Ok(()) | Err(Errno::EXIST) => {}
Err(errno) => return Err(self.io(&so_far, errno.into())),
}
self.open_dir(¤t, name, rel, &so_far, op)?
}
Err(errno) => return Err(self.classify(¤t, name, rel, &so_far, op, errno)),
};
current = Held::Opened(next);
}
Ok(Some(current))
}
fn open_dir(
&self,
parent: &impl AsFd,
name: &OsStr,
rel: &str,
so_far: &str,
op: WorkdirOperation,
) -> Result<OwnedFd, WorkdirError> {
rustix::fs::openat(parent, name, DIR_FLAGS, Mode::empty())
.map_err(|errno| self.classify(parent, name, rel, so_far, op, errno))
}
fn classify(
&self,
parent: &impl AsFd,
name: &OsStr,
rel: &str,
so_far: &str,
op: WorkdirOperation,
errno: Errno,
) -> WorkdirError {
match rustix::fs::statat(parent, name, AtFlags::SYMLINK_NOFOLLOW) {
Ok(stat) if FileType::from_raw_mode(stat.st_mode) == FileType::Symlink => {
self.refuse(rel, so_far, op, RefusalReason::Symlink)
}
_ => self.io(so_far, errno.into()),
}
}
fn leaf_type(
&self,
parent: &impl AsFd,
name: &OsStr,
rel: &str,
) -> Result<Option<FileType>, WorkdirError> {
match rustix::fs::statat(parent, name, AtFlags::SYMLINK_NOFOLLOW) {
Ok(stat) => Ok(Some(FileType::from_raw_mode(stat.st_mode))),
Err(Errno::NOENT) => Ok(None),
Err(errno) => Err(self.io(rel, errno.into())),
}
}
fn anchor_fd(&self, creating: bool) -> Result<Arc<OwnedFd>, WorkdirError> {
let mut held = self.anchor.fd.lock().unwrap_or_else(|e| e.into_inner());
let removed = creating
&& !nlink_unreliable()
&& rustix::fs::fstat(&**held).is_ok_and(|stat| stat.st_nlink == 0);
if removed {
*held = Arc::new(self.reach_again()?);
}
Ok(Arc::clone(&held))
}
fn retry_if_removed<T>(
&self,
call: impl Fn() -> Result<T, WorkdirError>,
) -> Result<T, WorkdirError> {
match call() {
Err(error) if error.is_not_found() && self.reach_again_if_removed()? => call(),
result => result,
}
}
fn reach_again_if_removed(&self) -> Result<bool, WorkdirError> {
let mut held = self.anchor.fd.lock().unwrap_or_else(|e| e.into_inner());
let probe = format!(".{}.probe", uuid::Uuid::now_v7().simple());
match rustix::fs::mkdirat(&**held, probe.as_str(), Mode::RWXU) {
Ok(()) => {
let _ = rustix::fs::unlinkat(&**held, probe.as_str(), AtFlags::REMOVEDIR);
Ok(false)
}
Err(Errno::NOENT) => {
*held = Arc::new(self.reach_again()?);
Ok(true)
}
Err(_) => Ok(false),
}
}
fn reach_again(&self) -> Result<OwnedFd, WorkdirError> {
match &self.anchor.origin {
Origin::Path => {
std::fs::create_dir_all(&self.anchor.path).map_err(|source| self.io("", source))?;
open_by_path(&self.anchor.path)
}
Origin::Beneath { from, rel } => {
let op = WorkdirOperation::CreateDir;
let segments = from.segments(rel, op)?;
let walked = from.retry_if_removed(|| {
from.walk(rel, &segments, segments.len(), op, Some(DEFAULT_DIR_MODE))
})?;
let Some(Held::Opened(fd)) = walked else {
unreachable!("a walk that creates always reaches its last segment")
};
Ok(fd)
}
}
}
fn display(&self, rel: &str) -> String {
match (self.anchor.prefix.is_empty(), rel.is_empty()) {
(true, _) => rel.to_string(),
(false, true) => self.anchor.prefix.clone(),
(false, false) => format!("{}/{rel}", self.anchor.prefix),
}
}
fn refuse(
&self,
rel: &str,
refused_at: &str,
operation: WorkdirOperation,
reason: RefusalReason,
) -> WorkdirError {
let refusal = WorkdirRefusal {
path: self.display(rel),
refused_at: self.display(refused_at),
operation,
reason,
};
self.latch
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(refusal.clone());
WorkdirError::Refused(refusal)
}
fn io(&self, rel: &str, source: std::io::Error) -> WorkdirError {
WorkdirError::Io {
path: self.display(rel),
source,
}
}
}
pub(crate) fn fd_identity(fd: impl AsFd) -> rustix::io::Result<(u64, u64)> {
let stat = rustix::fs::fstat(fd)?;
Ok((stat.st_dev as u64, stat.st_ino as u64))
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::{symlink, MetadataExt, PermissionsExt};
struct Fixture {
_temp: tempfile::TempDir,
workdir: PathBuf,
outside: PathBuf,
sentinel: PathBuf,
root: WorkdirRoot,
}
const SENTINEL: &[u8] = b"operator data";
fn refusal_of(error: &WorkdirError) -> &WorkdirRefusal {
match error {
WorkdirError::Refused(refusal) => refusal,
other => panic!("expected a refusal, got {other:?}"),
}
}
fn fixture() -> Fixture {
let temp = tempfile::tempdir().unwrap();
let workdir = temp.path().join("workdir");
let outside = temp.path().join("outside");
std::fs::create_dir(&workdir).unwrap();
std::fs::create_dir(&outside).unwrap();
let sentinel = outside.join("sentinel.txt");
std::fs::write(&sentinel, SENTINEL).unwrap();
let root = WorkdirRoot::open(&workdir).unwrap();
Fixture {
_temp: temp,
workdir,
outside,
sentinel,
root,
}
}
impl Fixture {
fn outside_snapshot(&self) -> Vec<(String, Vec<u8>, i64, i64)> {
let mut entries: Vec<_> = std::fs::read_dir(&self.outside)
.unwrap()
.map(|entry| {
let entry = entry.unwrap();
let meta = std::fs::symlink_metadata(entry.path()).unwrap();
let bytes = if meta.is_file() {
std::fs::read(entry.path()).unwrap()
} else {
Vec::new()
};
(
entry.file_name().to_string_lossy().into_owned(),
bytes,
meta.mtime(),
meta.mtime_nsec(),
)
})
.collect();
entries.sort();
entries
}
fn link_at_target(&self, rel: &str) {
let host = self.workdir.join(rel);
std::fs::create_dir_all(host.parent().unwrap()).unwrap();
symlink(&self.sentinel, host).unwrap();
}
fn dangling_at_target(&self, rel: &str) {
let host = self.workdir.join(rel);
std::fs::create_dir_all(host.parent().unwrap()).unwrap();
symlink(self.outside.join("not-yet.txt"), host).unwrap();
}
fn link_at_parent(&self, parent_rel: &str) {
let host = self.workdir.join(parent_rel);
std::fs::create_dir_all(host.parent().unwrap()).unwrap();
symlink(&self.outside, host).unwrap();
}
}
type Operation = fn(&WorkdirRoot, &str) -> Result<(), WorkdirError>;
fn operations() -> Vec<(&'static str, WorkdirOperation, Operation)> {
vec![
("write_file", WorkdirOperation::Write, |root, rel| {
root.write_file(rel, b"runtime bytes", 0o644)
}),
("open_append", WorkdirOperation::Append, |root, rel| {
root.open_append(rel, 0o644).and_then(|mut file| {
file.write_all(b"runtime bytes")
.map_err(|source| WorkdirError::Io {
path: rel.to_string(),
source,
})
})
}),
("read_file", WorkdirOperation::Read, |root, rel| {
root.read_file(rel, u64::MAX).map(|_| ())
}),
(
"create_dir_all",
WorkdirOperation::CreateDir,
|root, rel| root.create_dir_all(rel, 0o755),
),
("remove_file", WorkdirOperation::Remove, |root, rel| {
root.remove_file(rel)
}),
("remove_dir_all", WorkdirOperation::Remove, |root, rel| {
root.remove_dir_all(rel)
}),
]
}
fn assert_refused(
fixture: &Fixture,
result: Result<(), WorkdirError>,
before: &[(String, Vec<u8>, i64, i64)],
label: &str,
path: &str,
refused_at: &str,
operation: WorkdirOperation,
) {
let expected = WorkdirRefusal {
path: path.to_string(),
refused_at: refused_at.to_string(),
operation,
reason: RefusalReason::Symlink,
};
match result {
Err(WorkdirError::Refused(refusal)) => assert_eq!(refusal, expected, "{label}"),
other => panic!("{label}: expected a refusal, got {other:?}"),
}
assert_eq!(
fixture.root.take_refusals(),
vec![expected],
"{label}: latched once"
);
assert_eq!(
fixture.outside_snapshot(),
before,
"{label}: outside changed"
);
assert_eq!(std::fs::read(&fixture.sentinel).unwrap(), SENTINEL);
}
#[test]
fn every_operation_refuses_a_link_at_the_target() {
for (label, operation, call) in operations() {
let fixture = fixture();
fixture.link_at_target("dir/target");
let before = fixture.outside_snapshot();
let result = call(&fixture.root, "dir/target");
assert_refused(
&fixture,
result,
&before,
label,
"dir/target",
"dir/target",
operation,
);
assert!(
std::fs::symlink_metadata(fixture.workdir.join("dir/target"))
.unwrap()
.file_type()
.is_symlink()
);
}
}
#[test]
fn every_operation_refuses_a_link_at_a_parent_directory() {
for (label, operation, call) in operations() {
let fixture = fixture();
std::fs::create_dir(fixture.outside.join("dir")).unwrap();
std::fs::write(fixture.outside.join("dir/target"), SENTINEL).unwrap();
fixture.link_at_parent("a/b");
let before = fixture.outside_snapshot();
let result = call(&fixture.root, "a/b/dir/target");
assert_refused(
&fixture,
result,
&before,
label,
"a/b/dir/target",
"a/b",
operation,
);
assert_eq!(
std::fs::read(fixture.outside.join("dir/target")).unwrap(),
SENTINEL
);
}
}
#[test]
fn every_operation_refuses_a_dangling_link_at_the_target() {
for (label, operation, call) in operations() {
let fixture = fixture();
fixture.dangling_at_target("target");
let before = fixture.outside_snapshot();
let result = call(&fixture.root, "target");
assert_refused(
&fixture, result, &before, label, "target", "target", operation,
);
assert!(!fixture.outside.join("not-yet.txt").exists(), "{label}");
}
}
#[test]
fn a_non_plain_component_is_refused_before_any_syscall() {
let fixture = fixture();
for rel in [
"",
"/etc/passwd",
"../outside/sentinel.txt",
"a/./b",
"a//b",
"a/",
".",
] {
for (label, operation, call) in operations() {
match call(&fixture.root, rel) {
Err(WorkdirError::Refused(refusal)) => {
assert_eq!(
refusal.reason,
RefusalReason::InvalidComponent,
"{label} {rel}"
);
assert_eq!(refusal.operation, operation);
assert_eq!(refusal.path, rel);
}
other => panic!("{label} {rel:?}: expected a refusal, got {other:?}"),
}
assert_eq!(fixture.root.take_refusals().len(), 1);
}
}
assert_eq!(std::fs::read(&fixture.sentinel).unwrap(), SENTINEL);
assert_eq!(std::fs::read_dir(&fixture.workdir).unwrap().count(), 0);
}
#[test]
fn a_fifo_is_refused_for_append_and_read_without_blocking() {
let fixture = fixture();
let fifo = fixture.workdir.join("pipe");
assert!(std::process::Command::new("mkfifo")
.arg(&fifo)
.status()
.unwrap()
.success());
let append = fixture.root.open_append("pipe", 0o644).map(|_| ());
let read = fixture.root.read_file("pipe", u64::MAX).map(|_| ());
for (result, operation) in [
(append, WorkdirOperation::Append),
(read, WorkdirOperation::Read),
] {
match result {
Err(WorkdirError::Refused(refusal)) => {
assert_eq!(refusal.reason, RefusalReason::NotARegularFile);
assert_eq!(refusal.operation, operation);
assert_eq!(refusal.refused_at, "pipe");
}
other => panic!("expected a refusal, got {other:?}"),
}
}
assert_eq!(fixture.root.take_refusals().len(), 2);
}
#[test]
fn an_append_to_a_hard_link_into_outside_is_refused() {
let fixture = fixture();
std::fs::hard_link(&fixture.sentinel, fixture.workdir.join("log")).unwrap();
let before = fixture.outside_snapshot();
match fixture.root.open_append("log", 0o644) {
Err(WorkdirError::Refused(refusal)) => {
assert_eq!(refusal.reason, RefusalReason::HardLink);
assert_eq!(refusal.operation, WorkdirOperation::Append);
assert_eq!(refusal.path, "log");
}
other => panic!("expected a refusal, got {other:?}"),
}
assert_eq!(fixture.outside_snapshot(), before);
assert_eq!(fixture.root.take_refusals().len(), 1);
}
#[test]
fn a_write_replaces_an_existing_file_and_leaves_no_temporary_sibling() {
let fixture = fixture();
std::fs::create_dir(fixture.workdir.join("out")).unwrap();
std::fs::write(fixture.workdir.join("out/result.txt"), b"old").unwrap();
fixture
.root
.write_file("out/result.txt", b"new", 0o600)
.unwrap();
assert_eq!(
std::fs::read(fixture.workdir.join("out/result.txt")).unwrap(),
b"new"
);
let names: Vec<_> = std::fs::read_dir(fixture.workdir.join("out"))
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect();
assert_eq!(names, vec![std::ffi::OsString::from("result.txt")]);
let mode = std::fs::metadata(fixture.workdir.join("out/result.txt"))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
assert!(fixture.root.take_refusals().is_empty());
}
#[test]
fn a_link_planted_between_the_check_and_the_rename_is_replaced_not_followed() {
let fixture = fixture();
let target = fixture.workdir.join("task.md");
let sentinel = fixture.sentinel.clone();
let planted = target.clone();
before_next_rename(move || symlink(&sentinel, &planted).unwrap());
let before = fixture.outside_snapshot();
fixture
.root
.write_file("task.md", b"the task", 0o644)
.unwrap();
assert_eq!(fixture.outside_snapshot(), before);
let meta = std::fs::symlink_metadata(&target).unwrap();
assert!(meta.is_file(), "the rename replaced the planted link");
assert_eq!(std::fs::read(&target).unwrap(), b"the task");
}
fn held_beneath(root: &WorkdirRoot) -> Result<WorkdirRoot, WorkdirError> {
root.create_dir_all("records/ses_test", DEFAULT_DIR_MODE)?;
Ok(root
.open_beneath("records/ses_test")?
.expect("the directory was just created"))
}
#[test]
fn a_link_planted_after_a_root_beneath_is_held_redirects_nothing() {
let fixture = fixture();
let session = held_beneath(&fixture.root).unwrap();
let murmur = fixture.workdir.join("records");
std::fs::rename(&murmur, fixture.workdir.join("moved")).unwrap();
symlink(&fixture.outside, &murmur).unwrap();
let before = fixture.outside_snapshot();
session
.write_file("plans/plan-1.json", b"{}", 0o644)
.unwrap();
assert_eq!(fixture.outside_snapshot(), before);
assert_eq!(
std::fs::read(fixture.workdir.join("moved/ses_test/plans/plan-1.json")).unwrap(),
b"{}"
);
}
#[test]
fn a_refusal_beneath_a_held_root_names_the_path_from_its_parent_and_shares_the_latch() {
let fixture = fixture();
let session = held_beneath(&fixture.root).unwrap();
symlink(
&fixture.outside,
fixture.workdir.join("records/ses_test/plans"),
)
.unwrap();
let error = session
.write_file("plans/plan-1.json", b"{}", 0o644)
.unwrap_err();
let refusal = refusal_of(&error);
assert_eq!(refusal.path, "records/ses_test/plans/plan-1.json");
assert_eq!(refusal.refused_at, "records/ses_test/plans");
assert_eq!(
error.to_string(),
"refused to write records/ses_test/plans/plan-1.json: records/ses_test/plans is a \
symbolic link inside the workdir"
);
assert_eq!(fixture.root.take_refusals(), vec![refusal.clone()]);
assert!(session.take_refusals().is_empty());
}
#[test]
fn a_link_at_derivation_of_a_root_beneath_is_refused() {
let fixture = fixture();
symlink(&fixture.outside, fixture.workdir.join("records")).unwrap();
let error = held_beneath(&fixture.root).unwrap_err();
let refusal = refusal_of(&error);
assert_eq!(refusal.refused_at, "records");
assert_eq!(refusal.operation, WorkdirOperation::CreateDir);
assert_eq!(std::fs::read_dir(&fixture.outside).unwrap().count(), 1);
}
#[test]
fn a_removed_directory_beneath_is_walked_to_again_before_a_write() {
for nlink_unreliable in [false, true] {
NLINK_UNRELIABLE.with(|cell| cell.set(nlink_unreliable));
let fixture = fixture();
let session = held_beneath(&fixture.root).unwrap();
std::fs::remove_dir_all(fixture.workdir.join("records")).unwrap();
session
.write_file("out/result.txt", b"answer", 0o644)
.unwrap();
session
.open_append("logs/bootstrap.log", 0o644)
.unwrap()
.write_all(b"line\n")
.unwrap();
assert_eq!(
std::fs::read(fixture.workdir.join("records/ses_test/out/result.txt")).unwrap(),
b"answer"
);
assert!(fixture
.workdir
.join("records/ses_test/logs/bootstrap.log")
.is_file());
assert!(fixture.root.take_refusals().is_empty());
}
NLINK_UNRELIABLE.with(|cell| cell.set(false));
}
#[test]
fn a_moved_directory_beneath_is_still_the_one_written_to() {
NLINK_UNRELIABLE.with(|cell| cell.set(true));
let fixture = fixture();
let session = held_beneath(&fixture.root).unwrap();
std::fs::rename(
fixture.workdir.join("records"),
fixture.workdir.join("moved"),
)
.unwrap();
session
.write_file("out/result.txt", b"answer", 0o644)
.unwrap();
NLINK_UNRELIABLE.with(|cell| cell.set(false));
assert_eq!(
std::fs::read(fixture.workdir.join("moved/ses_test/out/result.txt")).unwrap(),
b"answer"
);
assert!(!fixture.workdir.join("records").exists());
let names: Vec<_> = std::fs::read_dir(fixture.workdir.join("moved/ses_test"))
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect();
assert_eq!(names, ["out"]);
}
#[test]
fn a_removed_directory_beneath_replaced_by_a_link_is_refused() {
for nlink_unreliable in [false, true] {
NLINK_UNRELIABLE.with(|cell| cell.set(nlink_unreliable));
let fixture = fixture();
let session = held_beneath(&fixture.root).unwrap();
std::fs::remove_dir_all(fixture.workdir.join("records")).unwrap();
std::os::unix::fs::symlink(&fixture.outside, fixture.workdir.join("records")).unwrap();
let before = fixture.outside_snapshot();
let error = session
.write_file("out/result.txt", b"answer", 0o644)
.unwrap_err();
assert_eq!(refusal_of(&error).refused_at, "records");
assert_eq!(fixture.outside_snapshot(), before);
}
NLINK_UNRELIABLE.with(|cell| cell.set(false));
}
#[test]
fn absent_paths_read_as_none_with_no_refusal() {
let fixture = fixture();
assert_eq!(fixture.root.read_file("task.md", 10).unwrap(), None);
assert_eq!(fixture.root.read_to_string("a/b/c").unwrap(), None);
assert_eq!(fixture.root.entry_kind("a/b").unwrap(), None);
assert_eq!(fixture.root.list_dir("tools").unwrap(), None);
assert_eq!(fixture.root.file_len("logs/x.log").unwrap(), None);
assert!(fixture.root.take_refusals().is_empty());
}
#[test]
fn create_dir_all_applies_the_mode_to_what_it_creates() {
let fixture = fixture();
fixture
.root
.create_dir_all(".process-driver/run", 0o700)
.unwrap();
let mode = std::fs::metadata(fixture.workdir.join(".process-driver/run"))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o700);
assert_eq!(
fixture.root.entry_kind(".process-driver").unwrap(),
Some(EntryKind::Directory)
);
}
#[test]
fn remove_dir_all_unlinks_a_link_inside_without_following_it() {
let fixture = fixture();
std::fs::create_dir_all(fixture.workdir.join("tools/x/sub")).unwrap();
std::fs::write(fixture.workdir.join("tools/x/sub/file"), b"x").unwrap();
symlink(&fixture.outside, fixture.workdir.join("tools/x/escape")).unwrap();
let before = fixture.outside_snapshot();
fixture.root.remove_dir_all("tools/x").unwrap();
assert!(!fixture.workdir.join("tools/x").exists());
assert_eq!(fixture.outside_snapshot(), before);
}
#[test]
fn overwrite_in_place_keeps_the_inode_and_refuses_a_link() {
let fixture = fixture();
fixture
.root
.overwrite_in_place(".mur-etc/passwd", b"one\n", 0o644)
.unwrap();
let inode = std::fs::metadata(fixture.workdir.join(".mur-etc/passwd"))
.unwrap()
.ino();
fixture
.root
.overwrite_in_place(".mur-etc/passwd", b"two\n", 0o644)
.unwrap();
let meta = std::fs::metadata(fixture.workdir.join(".mur-etc/passwd")).unwrap();
assert_eq!(meta.ino(), inode);
assert_eq!(
std::fs::read(fixture.workdir.join(".mur-etc/passwd")).unwrap(),
b"two\n"
);
fixture.link_at_target(".mur-etc/group");
let error = fixture
.root
.overwrite_in_place(".mur-etc/group", b"x", 0o644)
.unwrap_err();
assert_eq!(refusal_of(&error).reason, RefusalReason::Symlink);
assert_eq!(std::fs::read(&fixture.sentinel).unwrap(), SENTINEL);
}
#[test]
fn list_dir_reports_a_link_inside_as_a_symlink() {
let fixture = fixture();
std::fs::create_dir_all(fixture.workdir.join("tools/a")).unwrap();
symlink(&fixture.outside, fixture.workdir.join("tools/b")).unwrap();
assert_eq!(
fixture.root.list_dir("tools").unwrap().unwrap(),
vec![
("a".to_string(), EntryKind::Directory),
("b".to_string(), EntryKind::Symlink)
]
);
}
#[test]
fn a_refusal_serializes_with_the_wire_names() {
let refusal = WorkdirRefusal {
path: "task.md".into(),
refused_at: "task.md".into(),
operation: WorkdirOperation::CreateDir,
reason: RefusalReason::NotARegularFile,
};
assert_eq!(
serde_json::to_string(&refusal).unwrap(),
r#"{"path":"task.md","refused_at":"task.md","operation":"create_dir","reason":"not_a_regular_file"}"#
);
for (op, wire) in [
(WorkdirOperation::Write, "write"),
(WorkdirOperation::Append, "append"),
(WorkdirOperation::Read, "read"),
(WorkdirOperation::CreateDir, "create_dir"),
(WorkdirOperation::Remove, "remove"),
] {
assert_eq!(serde_json::to_value(op).unwrap(), serde_json::json!(wire));
}
for (reason, wire) in [
(RefusalReason::Symlink, "symlink"),
(RefusalReason::HardLink, "hard_link"),
(RefusalReason::NotARegularFile, "not_a_regular_file"),
(RefusalReason::InvalidComponent, "invalid_component"),
] {
assert_eq!(
serde_json::to_value(reason).unwrap(),
serde_json::json!(wire)
);
}
}
}