Skip to main content

capsule_runtime/
workdir_fs.rs

1//! Every runtime write, append, create, removal and decision-read inside a capsule's workdir,
2//! performed relative to a held directory descriptor with no symbolic link followed.
3//!
4//! The capsule's shell commands can write anywhere in the accessible workdir, and without
5//! `--workdir` the session directory *is* that workdir. The runtime itself is unconfined, so a
6//! path it builds by joining names onto the workdir and hands to `std::fs` resolves whatever the
7//! capsule planted there: `task.md -> ~/victim.txt` turns the runtime's task write into a write to
8//! the operator's file, and `tools -> ~/.local/bin` turns a tool install into an install outside
9//! the capsule. [`WorkdirRoot`] is the one way the runtime touches those paths.
10//!
11//! # The walk
12//!
13//! A relative path is split on `/`; a segment that is empty, `.` or `..` is refused before any
14//! syscall, which also refuses an absolute path. Each directory segment is opened with
15//! `openat(parent, name, O_DIRECTORY | O_NOFOLLOW)` from the descriptor the previous segment
16//! produced, starting at the anchor's own descriptor, so no step resolves a name the capsule could
17//! have swapped for a link and no step resolves anything from the anchor's host path. A segment
18//! that fails to open is `fstatat(AT_SYMLINK_NOFOLLOW)`-ed to say whether it was a link.
19//!
20//! This per-component walk is the only implementation, on Linux and macOS alike, for the reason
21//! [`crate::resource_plane`] keeps one portable resolution path: `openat2(RESOLVE_BENEATH)` is
22//! Linux-only and could only ever be an extra layer over a portable path that has to be correct by
23//! itself. Every primitive is `rustix`'s safe wrapper over the POSIX `*at` call of the same name.
24//!
25//! # Paths in refusals
26//!
27//! A [`WorkdirRefusal`] names its paths relative to the **accessible** workdir with the real
28//! session id — `.murmur/ses_…/plans/plan-1.json`, the form `peer_file_fetch.stored_path` uses —
29//! whichever root the call went through. Without `--workdir` the two roots are the same directory
30//! and a session path has no `.murmur/<id>/` prefix.
31//!
32//! # The refusal latch
33//!
34//! Every refusal is also pushed onto a latch every clone of both roots shares. A best-effort
35//! writer that swallows its error (a hook log, a blob, `MURMUR.md`) still leaves the refusal
36//! there, and the task loop drains it with [`WorkdirRoot::take_refusals`] at its checkpoints and
37//! fails the task.
38
39use std::ffi::OsStr;
40use std::fmt;
41use std::fs::File;
42use std::io::{Read, Write};
43use std::os::fd::{AsFd, BorrowedFd, OwnedFd};
44use std::os::unix::ffi::OsStrExt;
45use std::path::{Path, PathBuf};
46use std::sync::{Arc, Mutex};
47
48use rustix::fs::{AtFlags, FileType, Mode, OFlags, RawMode};
49use rustix::io::Errno;
50use serde::Serialize;
51
52/// Mode for a directory created with no reason to choose another: `std::fs::create_dir`'s, so the
53/// umask decides the result exactly as it does for `std`.
54pub const DEFAULT_DIR_MODE: u32 = 0o777;
55
56/// Mode for a file written with no reason to choose another: `std::fs::File::create`'s, on the
57/// same terms as [`DEFAULT_DIR_MODE`].
58pub const DEFAULT_FILE_MODE: u32 = 0o666;
59
60/// What a refused call was trying to do. The wire names are what `workdir_path_refused.operation`
61/// carries.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
63#[serde(rename_all = "snake_case")]
64pub enum WorkdirOperation {
65    Write,
66    Append,
67    Read,
68    CreateDir,
69    Remove,
70}
71
72impl WorkdirOperation {
73    fn verb(self) -> &'static str {
74        match self {
75            Self::Write => "write",
76            Self::Append => "append to",
77            Self::Read => "read",
78            Self::CreateDir => "create directory",
79            Self::Remove => "remove",
80        }
81    }
82}
83
84/// Why a call was refused. The wire names are what `workdir_path_refused.reason` carries.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
86#[serde(rename_all = "snake_case")]
87pub enum RefusalReason {
88    /// `refused_at` is a symbolic link.
89    Symlink,
90    /// A file being appended to or rewritten in place has more than one name, so it may also be
91    /// a file outside the workdir.
92    HardLink,
93    /// The target is a FIFO, socket, device node or directory where a regular file belongs.
94    NotARegularFile,
95    /// The relative path has an empty, `.` or `..` segment, or is absolute.
96    InvalidComponent,
97}
98
99/// One refused call. `path` and `refused_at` are relative to the accessible workdir.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
101pub struct WorkdirRefusal {
102    /// The path the runtime meant to touch.
103    pub path: String,
104    /// The component found to be a link or the wrong file type; the whole path for an invalid
105    /// component.
106    pub refused_at: String,
107    pub operation: WorkdirOperation,
108    pub reason: RefusalReason,
109}
110
111impl fmt::Display for WorkdirRefusal {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        let what = match self.reason {
114            RefusalReason::Symlink => "is a symbolic link inside the workdir",
115            RefusalReason::HardLink => {
116                "is a hard link with another name that may be outside the workdir"
117            }
118            RefusalReason::NotARegularFile => "is not a regular file",
119            RefusalReason::InvalidComponent => "is not a plain relative path inside the workdir",
120        };
121        write!(
122            f,
123            "refused to {} {}: {} {what}",
124            self.operation.verb(),
125            self.path,
126            self.refused_at
127        )
128    }
129}
130
131/// Error from a [`WorkdirRoot`] call.
132#[derive(Debug, thiserror::Error)]
133pub enum WorkdirError {
134    /// The call met a link or the wrong file type and did nothing. Already latched.
135    #[error("{0}")]
136    Refused(WorkdirRefusal),
137    /// The call failed for an ordinary I/O reason. `path` is in [`WorkdirRoot::display_path`]'s
138    /// form, or the host path when a root opened by path could not be opened.
139    #[error("{path}: {source}")]
140    Io {
141        path: String,
142        #[source]
143        source: std::io::Error,
144    },
145}
146
147impl WorkdirError {
148    /// Whether the call failed because the path does not exist.
149    pub fn is_not_found(&self) -> bool {
150        matches!(self, Self::Io { source, .. } if source.kind() == std::io::ErrorKind::NotFound)
151    }
152}
153
154impl From<WorkdirError> for std::io::Error {
155    /// A refusal becomes `PermissionDenied` carrying the refusal's text, for the callers whose
156    /// own error type is `io::Error`. The refusal itself is already on the latch.
157    fn from(error: WorkdirError) -> Self {
158        match error {
159            WorkdirError::Refused(refusal) => {
160                std::io::Error::new(std::io::ErrorKind::PermissionDenied, refusal.to_string())
161            }
162            WorkdirError::Io { source, .. } => source,
163        }
164    }
165}
166
167/// What `fstatat(AT_SYMLINK_NOFOLLOW)` found at a path's last component.
168#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
169pub enum EntryKind {
170    File,
171    Directory,
172    Symlink,
173    Other,
174}
175
176impl EntryKind {
177    fn of(file_type: FileType) -> Self {
178        match file_type {
179            FileType::RegularFile => Self::File,
180            FileType::Directory => Self::Directory,
181            FileType::Symlink => Self::Symlink,
182            _ => Self::Other,
183        }
184    }
185}
186
187/// A held directory descriptor inside (or at) the accessible workdir, and the only way the
188/// runtime reads its own decisions from, or writes into, that tree.
189///
190/// Cheap to clone. Every clone of the accessible root and of the session root derived from it
191/// shares one refusal latch.
192#[derive(Clone)]
193pub struct WorkdirRoot {
194    anchor: Arc<Anchor>,
195    latch: Arc<Mutex<Vec<WorkdirRefusal>>>,
196}
197
198struct Anchor {
199    /// Swapped only by [`WorkdirRoot::anchor_fd`], when the directory it names has been removed.
200    fd: Mutex<Arc<OwnedFd>>,
201    path: PathBuf,
202    /// This root's path relative to the accessible root, `""` for the accessible root itself.
203    prefix: String,
204    origin: Origin,
205}
206
207/// How a root reaches its directory again once the one it holds has been removed — a later
208/// launch's `trace.retain` pruning a session directory that is still running, for one.
209enum Origin {
210    /// Opened by its host path, the operator's choice: created and opened by that path again.
211    Path,
212    /// Walked to from another root: walked to the same way again, creating what is missing and
213    /// following no link.
214    Beneath { from: WorkdirRoot, rel: String },
215}
216
217impl fmt::Debug for WorkdirRoot {
218    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219        f.debug_struct("WorkdirRoot")
220            .field("path", &self.anchor.path)
221            .field("prefix", &self.anchor.prefix)
222            .finish()
223    }
224}
225
226/// The descriptor a walk stopped at: the anchor's own, or one the walk opened.
227enum Held {
228    Anchor(Arc<OwnedFd>),
229    Opened(OwnedFd),
230}
231
232impl AsFd for Held {
233    fn as_fd(&self) -> BorrowedFd<'_> {
234        match self {
235            Self::Anchor(fd) => fd.as_fd(),
236            Self::Opened(fd) => fd.as_fd(),
237        }
238    }
239}
240
241const DIR_FLAGS: OFlags = OFlags::RDONLY
242    .union(OFlags::DIRECTORY)
243    .union(OFlags::NOFOLLOW)
244    .union(OFlags::CLOEXEC)
245    .union(OFlags::NONBLOCK);
246
247#[cfg(test)]
248thread_local! {
249    /// Runs between `write_file`'s link check and its rename, so a test can plant a link in the
250    /// window a racing capsule would use.
251    static BEFORE_RENAME: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
252        const { std::cell::RefCell::new(None) };
253}
254
255#[cfg(test)]
256thread_local! {
257    /// Makes [`WorkdirRoot::anchor_fd`] ignore `st_nlink`, as on a platform whose `fstat` does not
258    /// report a removed directory, so a test reaches [`WorkdirRoot::retry_if_removed`] alone.
259    static NLINK_UNRELIABLE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
260}
261
262fn nlink_unreliable() -> bool {
263    #[cfg(test)]
264    {
265        NLINK_UNRELIABLE.with(std::cell::Cell::get)
266    }
267    #[cfg(not(test))]
268    {
269        false
270    }
271}
272
273/// Arms [`BEFORE_RENAME`] for the next `write_file` on this thread.
274#[cfg(test)]
275pub(crate) fn before_next_rename(hook: impl FnOnce() + 'static) {
276    BEFORE_RENAME.with(|slot| *slot.borrow_mut() = Some(Box::new(hook)));
277}
278
279/// Opens the directory at `path` as given, links included.
280fn open_by_path(path: &Path) -> Result<OwnedFd, WorkdirError> {
281    rustix::fs::open(path, DIR_FLAGS.difference(OFlags::NOFOLLOW), Mode::empty()).map_err(|errno| {
282        WorkdirError::Io {
283            path: path.display().to_string(),
284            source: errno.into(),
285        }
286    })
287}
288
289fn mode_of(mode: u32) -> Mode {
290    Mode::from_raw_mode(mode as RawMode)
291}
292
293impl WorkdirRoot {
294    /// Opens the accessible workdir at `path`.
295    ///
296    /// `path` is the operator's choice (`--workdir`, or the directory the runtime created under
297    /// the manifest directory) and is resolved as given, links above it included. Nothing below
298    /// it is ever resolved by path again.
299    pub fn open(path: &Path) -> Result<Self, WorkdirError> {
300        Ok(Self {
301            anchor: Arc::new(Anchor {
302                fd: Mutex::new(Arc::new(open_by_path(path)?)),
303                path: path.to_path_buf(),
304                prefix: String::new(),
305                origin: Origin::Path,
306            }),
307            latch: Arc::new(Mutex::new(Vec::new())),
308        })
309    }
310
311    /// The existing directory `rel` beneath this root as a root of its own, reached with no link
312    /// followed and sharing this root's latch, or `None` when it or a parent is absent.
313    pub fn open_beneath(&self, rel: &str) -> Result<Option<Self>, WorkdirError> {
314        let op = WorkdirOperation::Read;
315        let segments = self.segments(rel, op)?;
316        let Some(held) = self.walk(rel, &segments, segments.len(), op, None)? else {
317            return Ok(None);
318        };
319        let Held::Opened(fd) = held else {
320            unreachable!("a walk over at least one segment opens a descriptor")
321        };
322        Ok(Some(Self {
323            anchor: Arc::new(Anchor {
324                fd: Mutex::new(Arc::new(fd)),
325                path: self.anchor.path.join(rel),
326                prefix: self.display(rel),
327                origin: Origin::Beneath {
328                    from: self.clone(),
329                    rel: rel.to_string(),
330                },
331            }),
332            latch: Arc::clone(&self.latch),
333        }))
334    }
335
336    /// [`Self::open`], sharing `latch_of`'s refusal latch: for a directory outside the accessible
337    /// workdir whose refusals still fail this session's task. Its refusals name paths relative to
338    /// `path` itself.
339    pub fn open_sharing_latch(path: &Path, latch_of: &WorkdirRoot) -> Result<Self, WorkdirError> {
340        let opened = Self::open(path)?;
341        Ok(Self {
342            anchor: opened.anchor,
343            latch: Arc::clone(&latch_of.latch),
344        })
345    }
346
347    /// [`Self::open_sharing_latch`], with every refusal and error naming its path as
348    /// `<path>/<rel>`: for a directory outside the workdir that an operator reads about by its
349    /// absolute path, such as the session's runtime directory.
350    pub fn open_sharing_latch_absolute(
351        path: &Path,
352        latch_of: &WorkdirRoot,
353    ) -> Result<Self, WorkdirError> {
354        let opened = Self::open(path)?;
355        Ok(Self {
356            anchor: Arc::new(Anchor {
357                fd: Mutex::new(opened.anchor_fd(false)?),
358                path: path.to_path_buf(),
359                prefix: path.display().to_string(),
360                origin: Origin::Path,
361            }),
362            latch: Arc::clone(&latch_of.latch),
363        })
364    }
365
366    /// `(st_dev, st_ino)` of this root's directory, from `fstat` on the held descriptor.
367    pub fn identity(&self) -> Result<(u64, u64), WorkdirError> {
368        fd_identity(&*self.anchor_fd(false)?).map_err(|errno| self.io("", errno.into()))
369    }
370
371    /// A fresh read-only directory descriptor for this root's own directory, reopened from the
372    /// held descriptor with no path lookup, for `*at` calls the root has no method for.
373    pub fn dir_fd(&self) -> Result<OwnedFd, WorkdirError> {
374        let anchor = self.anchor_fd(false)?;
375        rustix::fs::openat(&*anchor, ".", DIR_FLAGS, Mode::empty())
376            .map_err(|errno| self.io("", errno.into()))
377    }
378
379    /// An `O_PATH | O_DIRECTORY` descriptor for this root's own directory, reopened from the held
380    /// descriptor with no path lookup: a Landlock rule's fd, or a bind source through
381    /// `/proc/self/fd/<n>`.
382    #[cfg(target_os = "linux")]
383    pub fn path_fd(&self) -> Result<OwnedFd, WorkdirError> {
384        let anchor = self.anchor_fd(false)?;
385        rustix::fs::openat(
386            &*anchor,
387            ".",
388            OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
389            Mode::empty(),
390        )
391        .map_err(|errno| self.io("", errno.into()))
392    }
393
394    /// An `O_PATH | O_DIRECTORY | O_NOFOLLOW` descriptor for the existing directory `rel`,
395    /// reached by the same no-follow walk every other call takes. A link at any segment is
396    /// refused and latched; a missing directory is a `NotFound` I/O error.
397    #[cfg(target_os = "linux")]
398    pub fn path_fd_beneath(&self, rel: impl AsRef<Path>) -> Result<OwnedFd, WorkdirError> {
399        let rel = rel.as_ref().to_string_lossy();
400        let op = WorkdirOperation::Read;
401        let (parent, name) = self.parent_of(&rel, op, false)?;
402        let Some(parent) = parent else {
403            return Err(self.io(&rel, std::io::ErrorKind::NotFound.into()));
404        };
405        let flags = OFlags::PATH | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
406        let refused_or = |errno: Errno| match self.leaf_type(&parent, name, &rel) {
407            Ok(Some(FileType::Symlink)) => self.refuse(&rel, &rel, op, RefusalReason::Symlink),
408            Ok(_) => self.io(&rel, errno.into()),
409            Err(error) => error,
410        };
411        let fd = rustix::fs::openat(&parent, name, flags, Mode::empty()).map_err(refused_or)?;
412        // `O_PATH | O_NOFOLLOW` opens a link itself rather than failing on it, so the kind is
413        // checked on the descriptor that was opened.
414        let stat = rustix::fs::fstat(&fd).map_err(|errno| self.io(&rel, errno.into()))?;
415        if FileType::from_raw_mode(stat.st_mode) != FileType::Directory {
416            return Err(refused_or(Errno::NOTDIR));
417        }
418        Ok(fd)
419    }
420
421    /// This root's host path, for display, a subprocess argument, a Landlock rule or a bind
422    /// source. The runtime never opens it through `std`.
423    pub fn path(&self) -> &Path {
424        &self.anchor.path
425    }
426
427    /// The host path of `rel` beneath this root, on [`Self::path`]'s terms.
428    pub fn host_path(&self, rel: impl AsRef<Path>) -> PathBuf {
429        self.anchor.path.join(rel)
430    }
431
432    /// `rel` relative to the accessible workdir, the form every refusal and error uses.
433    pub fn display_path(&self, rel: impl AsRef<Path>) -> String {
434        self.display(&rel.as_ref().to_string_lossy())
435    }
436
437    /// The uid and gid that own this root's directory, from `fstat` on the held descriptor.
438    pub fn owner(&self) -> Result<(u32, u32), WorkdirError> {
439        let stat = rustix::fs::fstat(&*self.anchor_fd(false)?)
440            .map_err(|errno| self.io("", errno.into()))?;
441        Ok((stat.st_uid, stat.st_gid))
442    }
443
444    /// Every refusal latched since the last call, on any clone of either root, oldest first.
445    pub fn take_refusals(&self) -> Vec<WorkdirRefusal> {
446        std::mem::take(&mut *self.latch.lock().unwrap_or_else(|e| e.into_inner()))
447    }
448
449    /// Writes `bytes` to `rel` through a same-directory temporary file renamed over it.
450    ///
451    /// Missing parent directories are created with [`DEFAULT_DIR_MODE`]. A link at `rel` when the
452    /// call starts is refused; one planted after that check is replaced by the rename, never
453    /// followed. A directory at `rel` is an I/O error. The temporary file
454    /// `.<name>.<uuid v7 simple>.tmp` is removed on any failure.
455    pub fn write_file(
456        &self,
457        rel: impl AsRef<Path>,
458        bytes: &[u8],
459        mode: u32,
460    ) -> Result<(), WorkdirError> {
461        self.retry_if_removed(|| self.write_file_inner(rel.as_ref(), bytes, mode, false))
462    }
463
464    fn write_file_inner(
465        &self,
466        rel: &Path,
467        bytes: &[u8],
468        mode: u32,
469        exact_mode: bool,
470    ) -> Result<(), WorkdirError> {
471        let rel = rel.to_string_lossy();
472        let op = WorkdirOperation::Write;
473        let (parent, name) = self.parent_of(&rel, op, true)?;
474        let parent = parent.expect("a creating walk reaches the parent");
475        match self.leaf_type(&parent, name, &rel)? {
476            Some(FileType::Symlink) => {
477                return Err(self.refuse(&rel, &rel, op, RefusalReason::Symlink));
478            }
479            Some(FileType::Directory) => {
480                return Err(self.io(&rel, std::io::ErrorKind::IsADirectory.into()));
481            }
482            _ => {}
483        }
484        let temp = format!(
485            ".{}.{}.tmp",
486            name.to_string_lossy(),
487            uuid::Uuid::now_v7().simple()
488        );
489        let flags =
490            OFlags::CREATE | OFlags::EXCL | OFlags::WRONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
491        let fd = rustix::fs::openat(&parent, temp.as_str(), flags, mode_of(mode))
492            .map_err(|errno| self.io(&rel, errno.into()))?;
493        let chmod = if exact_mode {
494            rustix::fs::fchmod(&fd, mode_of(mode)).map_err(|errno| self.io(&rel, errno.into()))
495        } else {
496            Ok(())
497        };
498        let result = chmod
499            .and_then(|()| {
500                File::from(fd)
501                    .write_all(bytes)
502                    .map_err(|source| self.io(&rel, source))
503            })
504            .and_then(|()| {
505                #[cfg(test)]
506                if let Some(hook) = BEFORE_RENAME.with(|slot| slot.borrow_mut().take()) {
507                    hook();
508                }
509                rustix::fs::renameat(&parent, temp.as_str(), &parent, name)
510                    .map_err(|errno| self.io(&rel, errno.into()))
511            });
512        if result.is_err() {
513            let _ = rustix::fs::unlinkat(&parent, temp.as_str(), AtFlags::empty());
514        }
515        result
516    }
517
518    /// [`Self::write_file`], with the file's mode set to exactly `mode` — `fchmod` on the
519    /// temporary file before the rename — rather than `mode` less the umask.
520    pub fn write_file_exact_mode(
521        &self,
522        rel: impl AsRef<Path>,
523        bytes: &[u8],
524        mode: u32,
525    ) -> Result<(), WorkdirError> {
526        self.retry_if_removed(|| self.write_file_inner(rel.as_ref(), bytes, mode, true))
527    }
528
529    /// Truncates and rewrites the regular file at `rel` in place, creating it with `mode` if
530    /// absent, so its inode survives — what a Landlock rule bound to that inode needs.
531    ///
532    /// A link, a non-regular file or a file with more than one hard link at `rel` is refused.
533    pub fn overwrite_in_place(
534        &self,
535        rel: impl AsRef<Path>,
536        bytes: &[u8],
537        mode: u32,
538    ) -> Result<(), WorkdirError> {
539        let rel = rel.as_ref().to_string_lossy();
540        let op = WorkdirOperation::Write;
541        let flags = OFlags::WRONLY | OFlags::CREATE;
542        let mut file = self.retry_if_removed(|| self.open_regular(&rel, op, flags, mode, true))?;
543        file.set_len(0).map_err(|source| self.io(&rel, source))?;
544        file.write_all(bytes)
545            .map_err(|source| self.io(&rel, source))
546    }
547
548    /// Opens `rel` for appending, creating it with `mode` and its parents with
549    /// [`DEFAULT_DIR_MODE`] if absent.
550    ///
551    /// Refused when `rel` is a link, is not a regular file, or is a regular file with more than
552    /// one hard link — the last because a hard link to a host file would otherwise be appended to.
553    pub fn open_append(&self, rel: impl AsRef<Path>, mode: u32) -> Result<File, WorkdirError> {
554        let rel = rel.as_ref().to_string_lossy();
555        let flags = OFlags::WRONLY | OFlags::APPEND | OFlags::CREATE;
556        self.retry_if_removed(|| {
557            self.open_regular(&rel, WorkdirOperation::Append, flags, mode, true)
558        })
559    }
560
561    /// The bytes of the regular file at `rel`, or `None` when it or a parent is absent.
562    ///
563    /// Refused when `rel` or a parent is a link, or `rel` is not a regular file; a FIFO is
564    /// refused without blocking. A file longer than `max_bytes` is an `InvalidData` error.
565    pub fn read_file(
566        &self,
567        rel: impl AsRef<Path>,
568        max_bytes: u64,
569    ) -> Result<Option<Vec<u8>>, WorkdirError> {
570        let rel = rel.as_ref().to_string_lossy();
571        let mut file =
572            match self.open_regular(&rel, WorkdirOperation::Read, OFlags::RDONLY, 0, false) {
573                Ok(file) => file,
574                Err(error) if error.is_not_found() => return Ok(None),
575                Err(error) => return Err(error),
576            };
577        let mut bytes = Vec::new();
578        Read::by_ref(&mut file)
579            .take(max_bytes.saturating_add(1))
580            .read_to_end(&mut bytes)
581            .map_err(|source| self.io(&rel, source))?;
582        if bytes.len() as u64 > max_bytes {
583            return Err(self.io(
584                &rel,
585                std::io::Error::new(
586                    std::io::ErrorKind::InvalidData,
587                    format!("larger than {max_bytes} bytes"),
588                ),
589            ));
590        }
591        Ok(Some(bytes))
592    }
593
594    /// [`Self::read_file`] with no size bound, decoded as UTF-8.
595    pub fn read_to_string(&self, rel: impl AsRef<Path>) -> Result<Option<String>, WorkdirError> {
596        let rel = rel.as_ref();
597        let Some(bytes) = self.read_file(rel, u64::MAX)? else {
598            return Ok(None);
599        };
600        String::from_utf8(bytes).map(Some).map_err(|error| {
601            self.io(
602                &rel.to_string_lossy(),
603                std::io::Error::new(std::io::ErrorKind::InvalidData, error),
604            )
605        })
606    }
607
608    /// Creates `rel` and every missing parent. `mode` applies to each directory this call
609    /// creates (less the umask); an existing one keeps its mode. A link at any segment is refused.
610    pub fn create_dir_all(&self, rel: impl AsRef<Path>, mode: u32) -> Result<(), WorkdirError> {
611        let rel = rel.as_ref().to_string_lossy();
612        let op = WorkdirOperation::CreateDir;
613        let segments = self.segments(&rel, op)?;
614        self.retry_if_removed(|| self.walk(&rel, &segments, segments.len(), op, Some(mode)))
615            .map(|_| ())
616    }
617
618    /// Sets the mode of the existing directory `rel` to exactly `mode & 0o7777`. A link at `rel`
619    /// or at any parent is refused, so the mode lands only on a directory inside the root.
620    pub fn set_dir_mode(&self, rel: impl AsRef<Path>, mode: u32) -> Result<(), WorkdirError> {
621        let rel = rel.as_ref().to_string_lossy();
622        let op = WorkdirOperation::CreateDir;
623        let (parent, name) = self.parent_of(&rel, op, false)?;
624        let Some(parent) = parent else {
625            return Err(self.io(&rel, std::io::ErrorKind::NotFound.into()));
626        };
627        match self.leaf_type(&parent, name, &rel)? {
628            None => Err(self.io(&rel, std::io::ErrorKind::NotFound.into())),
629            Some(FileType::Symlink) => Err(self.refuse(&rel, &rel, op, RefusalReason::Symlink)),
630            Some(FileType::Directory) => {
631                self.chmod_dir(&parent, name, &rel, |_| (mode & 0o7777) as RawMode)
632            }
633            Some(_) => Err(self.io(&rel, std::io::ErrorKind::NotADirectory.into())),
634        }
635    }
636
637    /// What `fstatat(AT_SYMLINK_NOFOLLOW)` finds at `rel`, or `None` when it or a parent is
638    /// absent. A link at a parent is refused; a link at `rel` itself is reported as
639    /// [`EntryKind::Symlink`] for the caller to refuse.
640    pub fn entry_kind(&self, rel: impl AsRef<Path>) -> Result<Option<EntryKind>, WorkdirError> {
641        let rel = rel.as_ref().to_string_lossy();
642        let op = WorkdirOperation::Read;
643        let (parent, name) = self.parent_of(&rel, op, false)?;
644        let Some(parent) = parent else {
645            return Ok(None);
646        };
647        Ok(self.leaf_type(&parent, name, &rel)?.map(EntryKind::of))
648    }
649
650    /// Size in bytes of the regular file at `rel`, or `None` when it or a parent is absent or it
651    /// is not a regular file. A link at any segment is refused.
652    pub fn file_len(&self, rel: impl AsRef<Path>) -> Result<Option<u64>, WorkdirError> {
653        let rel = rel.as_ref().to_string_lossy();
654        let op = WorkdirOperation::Read;
655        let (parent, name) = self.parent_of(&rel, op, false)?;
656        let Some(parent) = parent else {
657            return Ok(None);
658        };
659        match rustix::fs::statat(&parent, name, AtFlags::SYMLINK_NOFOLLOW) {
660            Ok(stat) => match FileType::from_raw_mode(stat.st_mode) {
661                FileType::RegularFile => Ok(Some(stat.st_size as u64)),
662                FileType::Symlink => Err(self.refuse(&rel, &rel, op, RefusalReason::Symlink)),
663                _ => Ok(None),
664            },
665            Err(Errno::NOENT) => Ok(None),
666            Err(errno) => Err(self.io(&rel, errno.into())),
667        }
668    }
669
670    /// The entries of the directory `rel` (`""` for this root itself) with what each is, sorted by
671    /// name, or `None` when it or a parent is absent. A link at any segment of `rel` is refused;
672    /// a link *inside* it is listed as [`EntryKind::Symlink`].
673    pub fn list_dir(
674        &self,
675        rel: impl AsRef<Path>,
676    ) -> Result<Option<Vec<(String, EntryKind)>>, WorkdirError> {
677        let rel = rel.as_ref().to_string_lossy();
678        let op = WorkdirOperation::Read;
679        let held = if rel.is_empty() {
680            Some(Held::Anchor(self.anchor_fd(false)?))
681        } else {
682            let segments = self.segments(&rel, op)?;
683            self.walk(&rel, &segments, segments.len(), op, None)?
684        };
685        let Some(dir) = held else {
686            return Ok(None);
687        };
688        let mut entries = Vec::new();
689        for (name, file_type) in self.children(&dir, &rel)? {
690            entries.push((
691                name.to_string_lossy().into_owned(),
692                EntryKind::of(file_type),
693            ));
694        }
695        entries.sort();
696        Ok(Some(entries))
697    }
698
699    /// Unlinks the file at `rel`. A link at `rel` or at a parent is refused rather than removed,
700    /// so the refusal is recorded.
701    pub fn remove_file(&self, rel: impl AsRef<Path>) -> Result<(), WorkdirError> {
702        let rel = rel.as_ref().to_string_lossy();
703        let op = WorkdirOperation::Remove;
704        let (parent, name) = self.parent_of(&rel, op, false)?;
705        let Some(parent) = parent else {
706            return Err(self.io(&rel, std::io::ErrorKind::NotFound.into()));
707        };
708        if self.leaf_type(&parent, name, &rel)? == Some(FileType::Symlink) {
709            return Err(self.refuse(&rel, &rel, op, RefusalReason::Symlink));
710        }
711        rustix::fs::unlinkat(&parent, name, AtFlags::empty())
712            .map_err(|errno| self.io(&rel, errno.into()))
713    }
714
715    /// Removes the directory `rel` and everything in it, by descriptor: each child directory is
716    /// opened no-follow from its parent's descriptor, so no part of the removal resolves a path
717    /// from the root. A link at `rel` or at a parent is refused; a link inside is unlinked, not
718    /// followed. Each directory is given owner `rwx` before it is emptied, so one a command left
719    /// unreadable or unwritable is removed too.
720    pub fn remove_dir_all(&self, rel: impl AsRef<Path>) -> Result<(), WorkdirError> {
721        let rel = rel.as_ref().to_string_lossy();
722        let op = WorkdirOperation::Remove;
723        let (parent, name) = self.parent_of(&rel, op, false)?;
724        let Some(parent) = parent else {
725            return Err(self.io(&rel, std::io::ErrorKind::NotFound.into()));
726        };
727        match self.leaf_type(&parent, name, &rel)? {
728            None => return Err(self.io(&rel, std::io::ErrorKind::NotFound.into())),
729            Some(FileType::Symlink) => {
730                return Err(self.refuse(&rel, &rel, op, RefusalReason::Symlink));
731            }
732            Some(FileType::Directory) => {}
733            Some(_) => return Err(self.io(&rel, std::io::ErrorKind::NotADirectory.into())),
734        }
735        self.grant_owner_rwx(&parent, name, &rel)?;
736        let dir = self.open_dir(&parent, name, &rel, &rel, op)?;
737        self.empty_dir(&dir, &rel)?;
738        rustix::fs::unlinkat(&parent, name, AtFlags::REMOVEDIR)
739            .map_err(|errno| self.io(&rel, errno.into()))
740    }
741
742    fn empty_dir(&self, dir: &OwnedFd, rel: &str) -> Result<(), WorkdirError> {
743        for (name, file_type) in self.children(dir, rel)? {
744            let child = format!("{rel}/{}", name.to_string_lossy());
745            if file_type == FileType::Directory {
746                self.grant_owner_rwx(dir, &name, &child)?;
747                let sub = self.open_dir(dir, &name, &child, &child, WorkdirOperation::Remove)?;
748                self.empty_dir(&sub, &child)?;
749                rustix::fs::unlinkat(dir, &name, AtFlags::REMOVEDIR)
750            } else {
751                rustix::fs::unlinkat(dir, &name, AtFlags::empty())
752            }
753            .map_err(|errno| self.io(&child, errno.into()))?;
754        }
755        Ok(())
756    }
757
758    /// Adds owner `rwx` to the mode of the directory `name` in `parent`, reached with no link
759    /// followed. A directory whose owner bits are already `rwx` is left as it is.
760    fn grant_owner_rwx(
761        &self,
762        parent: &impl AsFd,
763        name: &OsStr,
764        rel: &str,
765    ) -> Result<(), WorkdirError> {
766        const OWNER_RWX: RawMode = 0o700;
767        self.chmod_dir(parent, name, rel, |mode| mode | OWNER_RWX)
768    }
769
770    /// Sets the mode of the directory `name` in `parent`, reached with no link followed, to what
771    /// `new_mode` makes of its current `mode & 0o7777`; left as it is when that is unchanged.
772    fn chmod_dir(
773        &self,
774        parent: &impl AsFd,
775        name: &OsStr,
776        rel: &str,
777        new_mode: impl FnOnce(RawMode) -> RawMode,
778    ) -> Result<(), WorkdirError> {
779        // `O_PATH | O_DIRECTORY | O_NOFOLLOW` needs no permission on the directory itself and
780        // fails on a link; the mode is changed through the descriptor's `/proc/self/fd` entry,
781        // since `fchmod` refuses an `O_PATH` descriptor.
782        #[cfg(target_os = "linux")]
783        {
784            use std::os::fd::AsRawFd;
785            let fd = rustix::fs::openat(
786                parent,
787                name,
788                OFlags::PATH | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
789                Mode::empty(),
790            )
791            .map_err(|errno| self.io(rel, errno.into()))?;
792            let stat = rustix::fs::fstat(&fd).map_err(|errno| self.io(rel, errno.into()))?;
793            let mode = stat.st_mode as RawMode & 0o7777;
794            let wanted = new_mode(mode);
795            if wanted == mode {
796                return Ok(());
797            }
798            rustix::fs::chmod(
799                format!("/proc/self/fd/{}", fd.as_raw_fd()),
800                Mode::from_raw_mode(wanted),
801            )
802            .map_err(|errno| self.io(rel, errno.into()))
803        }
804        #[cfg(not(target_os = "linux"))]
805        {
806            let stat = rustix::fs::statat(parent, name, AtFlags::SYMLINK_NOFOLLOW)
807                .map_err(|errno| self.io(rel, errno.into()))?;
808            if FileType::from_raw_mode(stat.st_mode) != FileType::Directory {
809                return Err(self.io(rel, std::io::ErrorKind::NotADirectory.into()));
810            }
811            let mode = stat.st_mode as RawMode & 0o7777;
812            let wanted = new_mode(mode);
813            if wanted == mode {
814                return Ok(());
815            }
816            rustix::fs::chmodat(
817                parent,
818                name,
819                Mode::from_raw_mode(wanted),
820                AtFlags::SYMLINK_NOFOLLOW,
821            )
822            .map_err(|errno| self.io(rel, errno.into()))
823        }
824    }
825
826    /// The names in `dir` other than `.` and `..`, each with the type `fstatat(AT_SYMLINK_NOFOLLOW)`
827    /// reports — not `d_type`, which some filesystems leave unknown.
828    fn children(
829        &self,
830        dir: &impl AsFd,
831        rel: &str,
832    ) -> Result<Vec<(std::ffi::OsString, FileType)>, WorkdirError> {
833        let mut listing =
834            rustix::fs::Dir::read_from(dir).map_err(|errno| self.io(rel, errno.into()))?;
835        let mut names = Vec::new();
836        while let Some(entry) = listing.read() {
837            let entry = entry.map_err(|errno| self.io(rel, errno.into()))?;
838            let name = OsStr::from_bytes(entry.file_name().to_bytes());
839            if name == "." || name == ".." {
840                continue;
841            }
842            names.push(name.to_os_string());
843        }
844        let mut children = Vec::with_capacity(names.len());
845        for name in names {
846            match rustix::fs::statat(dir, &name, AtFlags::SYMLINK_NOFOLLOW) {
847                Ok(stat) => children.push((name, FileType::from_raw_mode(stat.st_mode))),
848                Err(Errno::NOENT) => {}
849                Err(errno) => return Err(self.io(rel, errno.into())),
850            }
851        }
852        Ok(children)
853    }
854
855    /// Opens `rel` as a regular file with `flags | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC`, then
856    /// checks the descriptor's own `fstat`: not a regular file is refused, and so is more than
857    /// one hard link when `flags` writes.
858    fn open_regular(
859        &self,
860        rel: &str,
861        op: WorkdirOperation,
862        flags: OFlags,
863        mode: u32,
864        create_parents: bool,
865    ) -> Result<File, WorkdirError> {
866        let (parent, name) = self.parent_of(rel, op, create_parents)?;
867        let Some(parent) = parent else {
868            return Err(self.io(rel, std::io::ErrorKind::NotFound.into()));
869        };
870        let flags = flags | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC;
871        let fd = match rustix::fs::openat(&parent, name, flags, mode_of(mode)) {
872            Ok(fd) => fd,
873            Err(errno) => {
874                return Err(match self.leaf_type(&parent, name, rel)? {
875                    Some(FileType::Symlink) => self.refuse(rel, rel, op, RefusalReason::Symlink),
876                    Some(FileType::RegularFile) | None => self.io(rel, errno.into()),
877                    Some(_) => self.refuse(rel, rel, op, RefusalReason::NotARegularFile),
878                });
879            }
880        };
881        let stat = rustix::fs::fstat(&fd).map_err(|errno| self.io(rel, errno.into()))?;
882        if FileType::from_raw_mode(stat.st_mode) != FileType::RegularFile {
883            return Err(self.refuse(rel, rel, op, RefusalReason::NotARegularFile));
884        }
885        if op != WorkdirOperation::Read && stat.st_nlink > 1 {
886            return Err(self.refuse(rel, rel, op, RefusalReason::HardLink));
887        }
888        Ok(File::from(fd))
889    }
890
891    /// Splits `rel` on `/`, refusing an empty, `.` or `..` segment — and so an absolute path —
892    /// before any syscall.
893    fn segments<'r>(
894        &self,
895        rel: &'r str,
896        op: WorkdirOperation,
897    ) -> Result<Vec<&'r OsStr>, WorkdirError> {
898        let segments: Vec<&OsStr> = rel.split('/').map(OsStr::new).collect();
899        if segments
900            .iter()
901            .any(|segment| segment.is_empty() || *segment == "." || *segment == "..")
902        {
903            return Err(self.refuse(rel, rel, op, RefusalReason::InvalidComponent));
904        }
905        Ok(segments)
906    }
907
908    /// Walks to `rel`'s parent and returns its descriptor with the last segment, or `None` for
909    /// the descriptor when a parent is absent and `create` is false.
910    fn parent_of<'r>(
911        &self,
912        rel: &'r str,
913        op: WorkdirOperation,
914        create: bool,
915    ) -> Result<(Option<Held>, &'r OsStr), WorkdirError> {
916        let segments = self.segments(rel, op)?;
917        let name = *segments.last().expect("split yields at least one segment");
918        let held = self.walk(
919            rel,
920            &segments,
921            segments.len() - 1,
922            op,
923            create.then_some(DEFAULT_DIR_MODE),
924        )?;
925        Ok((held, name))
926    }
927
928    /// Opens the first `depth` segments one at a time from the anchor, creating each missing one
929    /// with `create`'s mode when given. `None` when a segment is absent and `create` is `None`.
930    fn walk(
931        &self,
932        rel: &str,
933        segments: &[&OsStr],
934        depth: usize,
935        op: WorkdirOperation,
936        create: Option<u32>,
937    ) -> Result<Option<Held>, WorkdirError> {
938        let mut current = Held::Anchor(self.anchor_fd(create.is_some())?);
939        for index in 0..depth {
940            let name = segments[index];
941            let so_far = segments[..=index]
942                .iter()
943                .map(|segment| segment.to_string_lossy())
944                .collect::<Vec<_>>()
945                .join("/");
946            let next = match rustix::fs::openat(&current, name, DIR_FLAGS, Mode::empty()) {
947                Ok(fd) => fd,
948                Err(Errno::NOENT) => {
949                    let Some(mode) = create else {
950                        return Ok(None);
951                    };
952                    match rustix::fs::mkdirat(&current, name, mode_of(mode)) {
953                        Ok(()) | Err(Errno::EXIST) => {}
954                        Err(errno) => return Err(self.io(&so_far, errno.into())),
955                    }
956                    self.open_dir(&current, name, rel, &so_far, op)?
957                }
958                Err(errno) => return Err(self.classify(&current, name, rel, &so_far, op, errno)),
959            };
960            current = Held::Opened(next);
961        }
962        Ok(Some(current))
963    }
964
965    fn open_dir(
966        &self,
967        parent: &impl AsFd,
968        name: &OsStr,
969        rel: &str,
970        so_far: &str,
971        op: WorkdirOperation,
972    ) -> Result<OwnedFd, WorkdirError> {
973        rustix::fs::openat(parent, name, DIR_FLAGS, Mode::empty())
974            .map_err(|errno| self.classify(parent, name, rel, so_far, op, errno))
975    }
976
977    /// Turns a failed directory open into a refusal when `fstatat` says the segment is a link,
978    /// and into an I/O error otherwise.
979    fn classify(
980        &self,
981        parent: &impl AsFd,
982        name: &OsStr,
983        rel: &str,
984        so_far: &str,
985        op: WorkdirOperation,
986        errno: Errno,
987    ) -> WorkdirError {
988        match rustix::fs::statat(parent, name, AtFlags::SYMLINK_NOFOLLOW) {
989            Ok(stat) if FileType::from_raw_mode(stat.st_mode) == FileType::Symlink => {
990                self.refuse(rel, so_far, op, RefusalReason::Symlink)
991            }
992            _ => self.io(so_far, errno.into()),
993        }
994    }
995
996    /// The type at `name` in `parent`, not following a link, or `None` when absent.
997    fn leaf_type(
998        &self,
999        parent: &impl AsFd,
1000        name: &OsStr,
1001        rel: &str,
1002    ) -> Result<Option<FileType>, WorkdirError> {
1003        match rustix::fs::statat(parent, name, AtFlags::SYMLINK_NOFOLLOW) {
1004            Ok(stat) => Ok(Some(FileType::from_raw_mode(stat.st_mode))),
1005            Err(Errno::NOENT) => Ok(None),
1006            Err(errno) => Err(self.io(rel, errno.into())),
1007        }
1008    }
1009
1010    /// The anchor's descriptor. Before a call that creates, a directory `fstat` reports removed
1011    /// (no links to it) is reached again by its [`Origin`] and the new descriptor held from then
1012    /// on.
1013    fn anchor_fd(&self, creating: bool) -> Result<Arc<OwnedFd>, WorkdirError> {
1014        let mut held = self.anchor.fd.lock().unwrap_or_else(|e| e.into_inner());
1015        let removed = creating
1016            && !nlink_unreliable()
1017            && rustix::fs::fstat(&**held).is_ok_and(|stat| stat.st_nlink == 0);
1018        if removed {
1019            *held = Arc::new(self.reach_again()?);
1020        }
1021        Ok(Arc::clone(&held))
1022    }
1023
1024    /// Runs a creating `call`, and runs it once more when it failed `NotFound` because the held
1025    /// directory was removed, after reaching that directory again by its [`Origin`].
1026    ///
1027    /// `fstat` does not reveal a removed directory on every platform — Darwin keeps its link
1028    /// count above zero — so [`Self::anchor_fd`]'s check alone would leave a removed root
1029    /// creating into a directory that no longer exists. A directory that was moved, not removed,
1030    /// stays held: writes follow it, never the name it used to have.
1031    fn retry_if_removed<T>(
1032        &self,
1033        call: impl Fn() -> Result<T, WorkdirError>,
1034    ) -> Result<T, WorkdirError> {
1035        match call() {
1036            Err(error) if error.is_not_found() && self.reach_again_if_removed()? => call(),
1037            result => result,
1038        }
1039    }
1040
1041    /// Whether the held directory was removed, found by creating a uniquely named directory in
1042    /// it and removing it again; when it was, the root holds the directory reached again.
1043    fn reach_again_if_removed(&self) -> Result<bool, WorkdirError> {
1044        let mut held = self.anchor.fd.lock().unwrap_or_else(|e| e.into_inner());
1045        let probe = format!(".{}.probe", uuid::Uuid::now_v7().simple());
1046        match rustix::fs::mkdirat(&**held, probe.as_str(), Mode::RWXU) {
1047            Ok(()) => {
1048                let _ = rustix::fs::unlinkat(&**held, probe.as_str(), AtFlags::REMOVEDIR);
1049                Ok(false)
1050            }
1051            Err(Errno::NOENT) => {
1052                *held = Arc::new(self.reach_again()?);
1053                Ok(true)
1054            }
1055            Err(_) => Ok(false),
1056        }
1057    }
1058
1059    /// The anchor's directory reached again from its [`Origin`], created if missing: by its host
1060    /// path for a root opened by path, and by a no-follow walk from the parent root otherwise.
1061    fn reach_again(&self) -> Result<OwnedFd, WorkdirError> {
1062        match &self.anchor.origin {
1063            Origin::Path => {
1064                std::fs::create_dir_all(&self.anchor.path).map_err(|source| self.io("", source))?;
1065                open_by_path(&self.anchor.path)
1066            }
1067            Origin::Beneath { from, rel } => {
1068                let op = WorkdirOperation::CreateDir;
1069                let segments = from.segments(rel, op)?;
1070                let walked = from.retry_if_removed(|| {
1071                    from.walk(rel, &segments, segments.len(), op, Some(DEFAULT_DIR_MODE))
1072                })?;
1073                let Some(Held::Opened(fd)) = walked else {
1074                    unreachable!("a walk that creates always reaches its last segment")
1075                };
1076                Ok(fd)
1077            }
1078        }
1079    }
1080
1081    fn display(&self, rel: &str) -> String {
1082        match (self.anchor.prefix.is_empty(), rel.is_empty()) {
1083            (true, _) => rel.to_string(),
1084            (false, true) => self.anchor.prefix.clone(),
1085            (false, false) => format!("{}/{rel}", self.anchor.prefix),
1086        }
1087    }
1088
1089    fn refuse(
1090        &self,
1091        rel: &str,
1092        refused_at: &str,
1093        operation: WorkdirOperation,
1094        reason: RefusalReason,
1095    ) -> WorkdirError {
1096        let refusal = WorkdirRefusal {
1097            path: self.display(rel),
1098            refused_at: self.display(refused_at),
1099            operation,
1100            reason,
1101        };
1102        self.latch
1103            .lock()
1104            .unwrap_or_else(|e| e.into_inner())
1105            .push(refusal.clone());
1106        WorkdirError::Refused(refusal)
1107    }
1108
1109    fn io(&self, rel: &str, source: std::io::Error) -> WorkdirError {
1110        WorkdirError::Io {
1111            path: self.display(rel),
1112            source,
1113        }
1114    }
1115}
1116
1117/// `(st_dev, st_ino)` of the file `fd` names, from `fstat`.
1118pub(crate) fn fd_identity(fd: impl AsFd) -> rustix::io::Result<(u64, u64)> {
1119    let stat = rustix::fs::fstat(fd)?;
1120    Ok((stat.st_dev as u64, stat.st_ino as u64))
1121}
1122
1123#[cfg(test)]
1124mod tests {
1125    use super::*;
1126    use std::os::unix::fs::{symlink, MetadataExt, PermissionsExt};
1127
1128    struct Fixture {
1129        _temp: tempfile::TempDir,
1130        workdir: PathBuf,
1131        outside: PathBuf,
1132        sentinel: PathBuf,
1133        root: WorkdirRoot,
1134    }
1135
1136    const SENTINEL: &[u8] = b"operator data";
1137
1138    fn refusal_of(error: &WorkdirError) -> &WorkdirRefusal {
1139        match error {
1140            WorkdirError::Refused(refusal) => refusal,
1141            other => panic!("expected a refusal, got {other:?}"),
1142        }
1143    }
1144
1145    fn fixture() -> Fixture {
1146        let temp = tempfile::tempdir().unwrap();
1147        let workdir = temp.path().join("workdir");
1148        let outside = temp.path().join("outside");
1149        std::fs::create_dir(&workdir).unwrap();
1150        std::fs::create_dir(&outside).unwrap();
1151        let sentinel = outside.join("sentinel.txt");
1152        std::fs::write(&sentinel, SENTINEL).unwrap();
1153        let root = WorkdirRoot::open(&workdir).unwrap();
1154        Fixture {
1155            _temp: temp,
1156            workdir,
1157            outside,
1158            sentinel,
1159            root,
1160        }
1161    }
1162
1163    impl Fixture {
1164        fn outside_snapshot(&self) -> Vec<(String, Vec<u8>, i64, i64)> {
1165            let mut entries: Vec<_> = std::fs::read_dir(&self.outside)
1166                .unwrap()
1167                .map(|entry| {
1168                    let entry = entry.unwrap();
1169                    let meta = std::fs::symlink_metadata(entry.path()).unwrap();
1170                    let bytes = if meta.is_file() {
1171                        std::fs::read(entry.path()).unwrap()
1172                    } else {
1173                        Vec::new()
1174                    };
1175                    (
1176                        entry.file_name().to_string_lossy().into_owned(),
1177                        bytes,
1178                        meta.mtime(),
1179                        meta.mtime_nsec(),
1180                    )
1181                })
1182                .collect();
1183            entries.sort();
1184            entries
1185        }
1186
1187        /// Plants a link at `rel` to the sentinel, creating its parent directories.
1188        fn link_at_target(&self, rel: &str) {
1189            let host = self.workdir.join(rel);
1190            std::fs::create_dir_all(host.parent().unwrap()).unwrap();
1191            symlink(&self.sentinel, host).unwrap();
1192        }
1193
1194        fn dangling_at_target(&self, rel: &str) {
1195            let host = self.workdir.join(rel);
1196            std::fs::create_dir_all(host.parent().unwrap()).unwrap();
1197            symlink(self.outside.join("not-yet.txt"), host).unwrap();
1198        }
1199
1200        fn link_at_parent(&self, parent_rel: &str) {
1201            let host = self.workdir.join(parent_rel);
1202            std::fs::create_dir_all(host.parent().unwrap()).unwrap();
1203            symlink(&self.outside, host).unwrap();
1204        }
1205    }
1206
1207    type Operation = fn(&WorkdirRoot, &str) -> Result<(), WorkdirError>;
1208
1209    fn operations() -> Vec<(&'static str, WorkdirOperation, Operation)> {
1210        vec![
1211            ("write_file", WorkdirOperation::Write, |root, rel| {
1212                root.write_file(rel, b"runtime bytes", 0o644)
1213            }),
1214            ("open_append", WorkdirOperation::Append, |root, rel| {
1215                root.open_append(rel, 0o644).and_then(|mut file| {
1216                    file.write_all(b"runtime bytes")
1217                        .map_err(|source| WorkdirError::Io {
1218                            path: rel.to_string(),
1219                            source,
1220                        })
1221                })
1222            }),
1223            ("read_file", WorkdirOperation::Read, |root, rel| {
1224                root.read_file(rel, u64::MAX).map(|_| ())
1225            }),
1226            (
1227                "create_dir_all",
1228                WorkdirOperation::CreateDir,
1229                |root, rel| root.create_dir_all(rel, 0o755),
1230            ),
1231            ("remove_file", WorkdirOperation::Remove, |root, rel| {
1232                root.remove_file(rel)
1233            }),
1234            ("remove_dir_all", WorkdirOperation::Remove, |root, rel| {
1235                root.remove_dir_all(rel)
1236            }),
1237        ]
1238    }
1239
1240    fn assert_refused(
1241        fixture: &Fixture,
1242        result: Result<(), WorkdirError>,
1243        before: &[(String, Vec<u8>, i64, i64)],
1244        label: &str,
1245        path: &str,
1246        refused_at: &str,
1247        operation: WorkdirOperation,
1248    ) {
1249        let expected = WorkdirRefusal {
1250            path: path.to_string(),
1251            refused_at: refused_at.to_string(),
1252            operation,
1253            reason: RefusalReason::Symlink,
1254        };
1255        match result {
1256            Err(WorkdirError::Refused(refusal)) => assert_eq!(refusal, expected, "{label}"),
1257            other => panic!("{label}: expected a refusal, got {other:?}"),
1258        }
1259        assert_eq!(
1260            fixture.root.take_refusals(),
1261            vec![expected],
1262            "{label}: latched once"
1263        );
1264        assert_eq!(
1265            fixture.outside_snapshot(),
1266            before,
1267            "{label}: outside changed"
1268        );
1269        assert_eq!(std::fs::read(&fixture.sentinel).unwrap(), SENTINEL);
1270    }
1271
1272    #[test]
1273    fn every_operation_refuses_a_link_at_the_target() {
1274        for (label, operation, call) in operations() {
1275            let fixture = fixture();
1276            fixture.link_at_target("dir/target");
1277            let before = fixture.outside_snapshot();
1278            let result = call(&fixture.root, "dir/target");
1279            assert_refused(
1280                &fixture,
1281                result,
1282                &before,
1283                label,
1284                "dir/target",
1285                "dir/target",
1286                operation,
1287            );
1288            assert!(
1289                std::fs::symlink_metadata(fixture.workdir.join("dir/target"))
1290                    .unwrap()
1291                    .file_type()
1292                    .is_symlink()
1293            );
1294        }
1295    }
1296
1297    #[test]
1298    fn every_operation_refuses_a_link_at_a_parent_directory() {
1299        for (label, operation, call) in operations() {
1300            let fixture = fixture();
1301            std::fs::create_dir(fixture.outside.join("dir")).unwrap();
1302            std::fs::write(fixture.outside.join("dir/target"), SENTINEL).unwrap();
1303            fixture.link_at_parent("a/b");
1304            let before = fixture.outside_snapshot();
1305            let result = call(&fixture.root, "a/b/dir/target");
1306            assert_refused(
1307                &fixture,
1308                result,
1309                &before,
1310                label,
1311                "a/b/dir/target",
1312                "a/b",
1313                operation,
1314            );
1315            assert_eq!(
1316                std::fs::read(fixture.outside.join("dir/target")).unwrap(),
1317                SENTINEL
1318            );
1319        }
1320    }
1321
1322    #[test]
1323    fn every_operation_refuses_a_dangling_link_at_the_target() {
1324        for (label, operation, call) in operations() {
1325            let fixture = fixture();
1326            fixture.dangling_at_target("target");
1327            let before = fixture.outside_snapshot();
1328            let result = call(&fixture.root, "target");
1329            assert_refused(
1330                &fixture, result, &before, label, "target", "target", operation,
1331            );
1332            assert!(!fixture.outside.join("not-yet.txt").exists(), "{label}");
1333        }
1334    }
1335
1336    #[test]
1337    fn a_non_plain_component_is_refused_before_any_syscall() {
1338        let fixture = fixture();
1339        for rel in [
1340            "",
1341            "/etc/passwd",
1342            "../outside/sentinel.txt",
1343            "a/./b",
1344            "a//b",
1345            "a/",
1346            ".",
1347        ] {
1348            for (label, operation, call) in operations() {
1349                match call(&fixture.root, rel) {
1350                    Err(WorkdirError::Refused(refusal)) => {
1351                        assert_eq!(
1352                            refusal.reason,
1353                            RefusalReason::InvalidComponent,
1354                            "{label} {rel}"
1355                        );
1356                        assert_eq!(refusal.operation, operation);
1357                        assert_eq!(refusal.path, rel);
1358                    }
1359                    other => panic!("{label} {rel:?}: expected a refusal, got {other:?}"),
1360                }
1361                assert_eq!(fixture.root.take_refusals().len(), 1);
1362            }
1363        }
1364        assert_eq!(std::fs::read(&fixture.sentinel).unwrap(), SENTINEL);
1365        assert_eq!(std::fs::read_dir(&fixture.workdir).unwrap().count(), 0);
1366    }
1367
1368    #[test]
1369    fn a_fifo_is_refused_for_append_and_read_without_blocking() {
1370        let fixture = fixture();
1371        let fifo = fixture.workdir.join("pipe");
1372        assert!(std::process::Command::new("mkfifo")
1373            .arg(&fifo)
1374            .status()
1375            .unwrap()
1376            .success());
1377        let append = fixture.root.open_append("pipe", 0o644).map(|_| ());
1378        let read = fixture.root.read_file("pipe", u64::MAX).map(|_| ());
1379        for (result, operation) in [
1380            (append, WorkdirOperation::Append),
1381            (read, WorkdirOperation::Read),
1382        ] {
1383            match result {
1384                Err(WorkdirError::Refused(refusal)) => {
1385                    assert_eq!(refusal.reason, RefusalReason::NotARegularFile);
1386                    assert_eq!(refusal.operation, operation);
1387                    assert_eq!(refusal.refused_at, "pipe");
1388                }
1389                other => panic!("expected a refusal, got {other:?}"),
1390            }
1391        }
1392        assert_eq!(fixture.root.take_refusals().len(), 2);
1393    }
1394
1395    #[test]
1396    fn an_append_to_a_hard_link_into_outside_is_refused() {
1397        let fixture = fixture();
1398        std::fs::hard_link(&fixture.sentinel, fixture.workdir.join("log")).unwrap();
1399        let before = fixture.outside_snapshot();
1400        match fixture.root.open_append("log", 0o644) {
1401            Err(WorkdirError::Refused(refusal)) => {
1402                assert_eq!(refusal.reason, RefusalReason::HardLink);
1403                assert_eq!(refusal.operation, WorkdirOperation::Append);
1404                assert_eq!(refusal.path, "log");
1405            }
1406            other => panic!("expected a refusal, got {other:?}"),
1407        }
1408        assert_eq!(fixture.outside_snapshot(), before);
1409        assert_eq!(fixture.root.take_refusals().len(), 1);
1410    }
1411
1412    #[test]
1413    fn a_write_replaces_an_existing_file_and_leaves_no_temporary_sibling() {
1414        let fixture = fixture();
1415        std::fs::create_dir(fixture.workdir.join("out")).unwrap();
1416        std::fs::write(fixture.workdir.join("out/result.txt"), b"old").unwrap();
1417        fixture
1418            .root
1419            .write_file("out/result.txt", b"new", 0o600)
1420            .unwrap();
1421        assert_eq!(
1422            std::fs::read(fixture.workdir.join("out/result.txt")).unwrap(),
1423            b"new"
1424        );
1425        let names: Vec<_> = std::fs::read_dir(fixture.workdir.join("out"))
1426            .unwrap()
1427            .map(|entry| entry.unwrap().file_name())
1428            .collect();
1429        assert_eq!(names, vec![std::ffi::OsString::from("result.txt")]);
1430        let mode = std::fs::metadata(fixture.workdir.join("out/result.txt"))
1431            .unwrap()
1432            .permissions()
1433            .mode();
1434        assert_eq!(mode & 0o777, 0o600);
1435        assert!(fixture.root.take_refusals().is_empty());
1436    }
1437
1438    #[test]
1439    fn a_link_planted_between_the_check_and_the_rename_is_replaced_not_followed() {
1440        let fixture = fixture();
1441        let target = fixture.workdir.join("task.md");
1442        let sentinel = fixture.sentinel.clone();
1443        let planted = target.clone();
1444        before_next_rename(move || symlink(&sentinel, &planted).unwrap());
1445        let before = fixture.outside_snapshot();
1446        fixture
1447            .root
1448            .write_file("task.md", b"the task", 0o644)
1449            .unwrap();
1450        assert_eq!(fixture.outside_snapshot(), before);
1451        let meta = std::fs::symlink_metadata(&target).unwrap();
1452        assert!(meta.is_file(), "the rename replaced the planted link");
1453        assert_eq!(std::fs::read(&target).unwrap(), b"the task");
1454    }
1455
1456    /// `records/ses_test` beneath `root`, created and held as a root of its own.
1457    fn held_beneath(root: &WorkdirRoot) -> Result<WorkdirRoot, WorkdirError> {
1458        root.create_dir_all("records/ses_test", DEFAULT_DIR_MODE)?;
1459        Ok(root
1460            .open_beneath("records/ses_test")?
1461            .expect("the directory was just created"))
1462    }
1463
1464    #[test]
1465    fn a_link_planted_after_a_root_beneath_is_held_redirects_nothing() {
1466        let fixture = fixture();
1467        let session = held_beneath(&fixture.root).unwrap();
1468        let murmur = fixture.workdir.join("records");
1469        std::fs::rename(&murmur, fixture.workdir.join("moved")).unwrap();
1470        symlink(&fixture.outside, &murmur).unwrap();
1471        let before = fixture.outside_snapshot();
1472        session
1473            .write_file("plans/plan-1.json", b"{}", 0o644)
1474            .unwrap();
1475        assert_eq!(fixture.outside_snapshot(), before);
1476        assert_eq!(
1477            std::fs::read(fixture.workdir.join("moved/ses_test/plans/plan-1.json")).unwrap(),
1478            b"{}"
1479        );
1480    }
1481
1482    #[test]
1483    fn a_refusal_beneath_a_held_root_names_the_path_from_its_parent_and_shares_the_latch() {
1484        let fixture = fixture();
1485        let session = held_beneath(&fixture.root).unwrap();
1486        symlink(
1487            &fixture.outside,
1488            fixture.workdir.join("records/ses_test/plans"),
1489        )
1490        .unwrap();
1491        let error = session
1492            .write_file("plans/plan-1.json", b"{}", 0o644)
1493            .unwrap_err();
1494        let refusal = refusal_of(&error);
1495        assert_eq!(refusal.path, "records/ses_test/plans/plan-1.json");
1496        assert_eq!(refusal.refused_at, "records/ses_test/plans");
1497        assert_eq!(
1498            error.to_string(),
1499            "refused to write records/ses_test/plans/plan-1.json: records/ses_test/plans is a \
1500             symbolic link inside the workdir"
1501        );
1502        assert_eq!(fixture.root.take_refusals(), vec![refusal.clone()]);
1503        assert!(session.take_refusals().is_empty());
1504    }
1505
1506    #[test]
1507    fn a_link_at_derivation_of_a_root_beneath_is_refused() {
1508        let fixture = fixture();
1509        symlink(&fixture.outside, fixture.workdir.join("records")).unwrap();
1510        let error = held_beneath(&fixture.root).unwrap_err();
1511        let refusal = refusal_of(&error);
1512        assert_eq!(refusal.refused_at, "records");
1513        assert_eq!(refusal.operation, WorkdirOperation::CreateDir);
1514        assert_eq!(std::fs::read_dir(&fixture.outside).unwrap().count(), 1);
1515    }
1516
1517    #[test]
1518    fn a_removed_directory_beneath_is_walked_to_again_before_a_write() {
1519        for nlink_unreliable in [false, true] {
1520            NLINK_UNRELIABLE.with(|cell| cell.set(nlink_unreliable));
1521            let fixture = fixture();
1522            let session = held_beneath(&fixture.root).unwrap();
1523            std::fs::remove_dir_all(fixture.workdir.join("records")).unwrap();
1524
1525            session
1526                .write_file("out/result.txt", b"answer", 0o644)
1527                .unwrap();
1528            session
1529                .open_append("logs/bootstrap.log", 0o644)
1530                .unwrap()
1531                .write_all(b"line\n")
1532                .unwrap();
1533
1534            assert_eq!(
1535                std::fs::read(fixture.workdir.join("records/ses_test/out/result.txt")).unwrap(),
1536                b"answer"
1537            );
1538            assert!(fixture
1539                .workdir
1540                .join("records/ses_test/logs/bootstrap.log")
1541                .is_file());
1542            assert!(fixture.root.take_refusals().is_empty());
1543        }
1544        NLINK_UNRELIABLE.with(|cell| cell.set(false));
1545    }
1546
1547    /// A directory beneath moved aside, not removed, stays the one written to, with no probe
1548    /// left behind in it.
1549    #[test]
1550    fn a_moved_directory_beneath_is_still_the_one_written_to() {
1551        NLINK_UNRELIABLE.with(|cell| cell.set(true));
1552        let fixture = fixture();
1553        let session = held_beneath(&fixture.root).unwrap();
1554        std::fs::rename(
1555            fixture.workdir.join("records"),
1556            fixture.workdir.join("moved"),
1557        )
1558        .unwrap();
1559
1560        session
1561            .write_file("out/result.txt", b"answer", 0o644)
1562            .unwrap();
1563
1564        NLINK_UNRELIABLE.with(|cell| cell.set(false));
1565        assert_eq!(
1566            std::fs::read(fixture.workdir.join("moved/ses_test/out/result.txt")).unwrap(),
1567            b"answer"
1568        );
1569        assert!(!fixture.workdir.join("records").exists());
1570        let names: Vec<_> = std::fs::read_dir(fixture.workdir.join("moved/ses_test"))
1571            .unwrap()
1572            .map(|entry| entry.unwrap().file_name())
1573            .collect();
1574        assert_eq!(names, ["out"]);
1575    }
1576
1577    #[test]
1578    fn a_removed_directory_beneath_replaced_by_a_link_is_refused() {
1579        for nlink_unreliable in [false, true] {
1580            NLINK_UNRELIABLE.with(|cell| cell.set(nlink_unreliable));
1581            let fixture = fixture();
1582            let session = held_beneath(&fixture.root).unwrap();
1583            std::fs::remove_dir_all(fixture.workdir.join("records")).unwrap();
1584            std::os::unix::fs::symlink(&fixture.outside, fixture.workdir.join("records")).unwrap();
1585            let before = fixture.outside_snapshot();
1586
1587            let error = session
1588                .write_file("out/result.txt", b"answer", 0o644)
1589                .unwrap_err();
1590
1591            assert_eq!(refusal_of(&error).refused_at, "records");
1592            assert_eq!(fixture.outside_snapshot(), before);
1593        }
1594        NLINK_UNRELIABLE.with(|cell| cell.set(false));
1595    }
1596
1597    #[test]
1598    fn absent_paths_read_as_none_with_no_refusal() {
1599        let fixture = fixture();
1600        assert_eq!(fixture.root.read_file("task.md", 10).unwrap(), None);
1601        assert_eq!(fixture.root.read_to_string("a/b/c").unwrap(), None);
1602        assert_eq!(fixture.root.entry_kind("a/b").unwrap(), None);
1603        assert_eq!(fixture.root.list_dir("tools").unwrap(), None);
1604        assert_eq!(fixture.root.file_len("logs/x.log").unwrap(), None);
1605        assert!(fixture.root.take_refusals().is_empty());
1606    }
1607
1608    #[test]
1609    fn create_dir_all_applies_the_mode_to_what_it_creates() {
1610        let fixture = fixture();
1611        fixture
1612            .root
1613            .create_dir_all(".process-driver/run", 0o700)
1614            .unwrap();
1615        let mode = std::fs::metadata(fixture.workdir.join(".process-driver/run"))
1616            .unwrap()
1617            .permissions()
1618            .mode();
1619        assert_eq!(mode & 0o777, 0o700);
1620        assert_eq!(
1621            fixture.root.entry_kind(".process-driver").unwrap(),
1622            Some(EntryKind::Directory)
1623        );
1624    }
1625
1626    #[test]
1627    fn remove_dir_all_unlinks_a_link_inside_without_following_it() {
1628        let fixture = fixture();
1629        std::fs::create_dir_all(fixture.workdir.join("tools/x/sub")).unwrap();
1630        std::fs::write(fixture.workdir.join("tools/x/sub/file"), b"x").unwrap();
1631        symlink(&fixture.outside, fixture.workdir.join("tools/x/escape")).unwrap();
1632        let before = fixture.outside_snapshot();
1633        fixture.root.remove_dir_all("tools/x").unwrap();
1634        assert!(!fixture.workdir.join("tools/x").exists());
1635        assert_eq!(fixture.outside_snapshot(), before);
1636    }
1637
1638    #[test]
1639    fn overwrite_in_place_keeps_the_inode_and_refuses_a_link() {
1640        let fixture = fixture();
1641        fixture
1642            .root
1643            .overwrite_in_place(".mur-etc/passwd", b"one\n", 0o644)
1644            .unwrap();
1645        let inode = std::fs::metadata(fixture.workdir.join(".mur-etc/passwd"))
1646            .unwrap()
1647            .ino();
1648        fixture
1649            .root
1650            .overwrite_in_place(".mur-etc/passwd", b"two\n", 0o644)
1651            .unwrap();
1652        let meta = std::fs::metadata(fixture.workdir.join(".mur-etc/passwd")).unwrap();
1653        assert_eq!(meta.ino(), inode);
1654        assert_eq!(
1655            std::fs::read(fixture.workdir.join(".mur-etc/passwd")).unwrap(),
1656            b"two\n"
1657        );
1658
1659        fixture.link_at_target(".mur-etc/group");
1660        let error = fixture
1661            .root
1662            .overwrite_in_place(".mur-etc/group", b"x", 0o644)
1663            .unwrap_err();
1664        assert_eq!(refusal_of(&error).reason, RefusalReason::Symlink);
1665        assert_eq!(std::fs::read(&fixture.sentinel).unwrap(), SENTINEL);
1666    }
1667
1668    #[test]
1669    fn list_dir_reports_a_link_inside_as_a_symlink() {
1670        let fixture = fixture();
1671        std::fs::create_dir_all(fixture.workdir.join("tools/a")).unwrap();
1672        symlink(&fixture.outside, fixture.workdir.join("tools/b")).unwrap();
1673        assert_eq!(
1674            fixture.root.list_dir("tools").unwrap().unwrap(),
1675            vec![
1676                ("a".to_string(), EntryKind::Directory),
1677                ("b".to_string(), EntryKind::Symlink)
1678            ]
1679        );
1680    }
1681
1682    #[test]
1683    fn a_refusal_serializes_with_the_wire_names() {
1684        let refusal = WorkdirRefusal {
1685            path: "task.md".into(),
1686            refused_at: "task.md".into(),
1687            operation: WorkdirOperation::CreateDir,
1688            reason: RefusalReason::NotARegularFile,
1689        };
1690        assert_eq!(
1691            serde_json::to_string(&refusal).unwrap(),
1692            r#"{"path":"task.md","refused_at":"task.md","operation":"create_dir","reason":"not_a_regular_file"}"#
1693        );
1694        for (op, wire) in [
1695            (WorkdirOperation::Write, "write"),
1696            (WorkdirOperation::Append, "append"),
1697            (WorkdirOperation::Read, "read"),
1698            (WorkdirOperation::CreateDir, "create_dir"),
1699            (WorkdirOperation::Remove, "remove"),
1700        ] {
1701            assert_eq!(serde_json::to_value(op).unwrap(), serde_json::json!(wire));
1702        }
1703        for (reason, wire) in [
1704            (RefusalReason::Symlink, "symlink"),
1705            (RefusalReason::HardLink, "hard_link"),
1706            (RefusalReason::NotARegularFile, "not_a_regular_file"),
1707            (RefusalReason::InvalidComponent, "invalid_component"),
1708        ] {
1709            assert_eq!(
1710                serde_json::to_value(reason).unwrap(),
1711                serde_json::json!(wire)
1712            );
1713        }
1714    }
1715}