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 session root `.murmur/<session_id>` beneath this accessible root, created if absent
312    /// and reached with no link followed.
313    ///
314    /// The returned root holds its own descriptor, so swapping `.murmur` or the session directory
315    /// for a link afterwards does not redirect anything written through it.
316    pub fn session_beneath(&self, session_id: &str) -> Result<Self, WorkdirError> {
317        let rel = format!(".murmur/{session_id}");
318        let segments = self.segments(&rel, WorkdirOperation::CreateDir)?;
319        let held = self.retry_if_removed(|| {
320            self.walk(
321                &rel,
322                &segments,
323                segments.len(),
324                WorkdirOperation::CreateDir,
325                Some(DEFAULT_DIR_MODE),
326            )
327        })?;
328        let Some(Held::Opened(fd)) = held else {
329            unreachable!("a walk that creates always reaches its last segment")
330        };
331        Ok(Self {
332            anchor: Arc::new(Anchor {
333                fd: Mutex::new(Arc::new(fd)),
334                path: self.anchor.path.join(".murmur").join(session_id),
335                prefix: self.display(&rel),
336                origin: Origin::Beneath {
337                    from: self.clone(),
338                    rel,
339                },
340            }),
341            latch: Arc::clone(&self.latch),
342        })
343    }
344
345    /// The existing directory `rel` beneath this root as a root of its own, reached with no link
346    /// followed and sharing this root's latch, or `None` when it or a parent is absent.
347    pub fn open_beneath(&self, rel: &str) -> Result<Option<Self>, WorkdirError> {
348        let op = WorkdirOperation::Read;
349        let segments = self.segments(rel, op)?;
350        let Some(held) = self.walk(rel, &segments, segments.len(), op, None)? else {
351            return Ok(None);
352        };
353        let Held::Opened(fd) = held else {
354            unreachable!("a walk over at least one segment opens a descriptor")
355        };
356        Ok(Some(Self {
357            anchor: Arc::new(Anchor {
358                fd: Mutex::new(Arc::new(fd)),
359                path: self.anchor.path.join(rel),
360                prefix: self.display(rel),
361                origin: Origin::Beneath {
362                    from: self.clone(),
363                    rel: rel.to_string(),
364                },
365            }),
366            latch: Arc::clone(&self.latch),
367        }))
368    }
369
370    /// [`Self::open`], sharing `latch_of`'s refusal latch: for a directory outside the accessible
371    /// workdir whose refusals still fail this session's task. Its refusals name paths relative to
372    /// `path` itself.
373    pub fn open_sharing_latch(path: &Path, latch_of: &WorkdirRoot) -> Result<Self, WorkdirError> {
374        let opened = Self::open(path)?;
375        Ok(Self {
376            anchor: opened.anchor,
377            latch: Arc::clone(&latch_of.latch),
378        })
379    }
380
381    /// [`Self::open_sharing_latch`], with every refusal and error naming its path as
382    /// `<path>/<rel>`: for a directory outside the workdir that an operator reads about by its
383    /// absolute path, such as the session's runtime directory.
384    pub fn open_sharing_latch_absolute(
385        path: &Path,
386        latch_of: &WorkdirRoot,
387    ) -> Result<Self, WorkdirError> {
388        let opened = Self::open(path)?;
389        Ok(Self {
390            anchor: Arc::new(Anchor {
391                fd: Mutex::new(opened.anchor_fd(false)?),
392                path: path.to_path_buf(),
393                prefix: path.display().to_string(),
394                origin: Origin::Path,
395            }),
396            latch: Arc::clone(&latch_of.latch),
397        })
398    }
399
400    /// `(st_dev, st_ino)` of this root's directory, from `fstat` on the held descriptor.
401    pub fn identity(&self) -> Result<(u64, u64), WorkdirError> {
402        fd_identity(&*self.anchor_fd(false)?).map_err(|errno| self.io("", errno.into()))
403    }
404
405    /// A fresh read-only directory descriptor for this root's own directory, reopened from the
406    /// held descriptor with no path lookup, for `*at` calls the root has no method for.
407    pub fn dir_fd(&self) -> Result<OwnedFd, WorkdirError> {
408        let anchor = self.anchor_fd(false)?;
409        rustix::fs::openat(&*anchor, ".", DIR_FLAGS, Mode::empty())
410            .map_err(|errno| self.io("", errno.into()))
411    }
412
413    /// An `O_PATH | O_DIRECTORY` descriptor for this root's own directory, reopened from the held
414    /// descriptor with no path lookup: a Landlock rule's fd, or a bind source through
415    /// `/proc/self/fd/<n>`.
416    #[cfg(target_os = "linux")]
417    pub fn path_fd(&self) -> Result<OwnedFd, WorkdirError> {
418        let anchor = self.anchor_fd(false)?;
419        rustix::fs::openat(
420            &*anchor,
421            ".",
422            OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
423            Mode::empty(),
424        )
425        .map_err(|errno| self.io("", errno.into()))
426    }
427
428    /// An `O_PATH | O_DIRECTORY | O_NOFOLLOW` descriptor for the existing directory `rel`,
429    /// reached by the same no-follow walk every other call takes. A link at any segment is
430    /// refused and latched; a missing directory is a `NotFound` I/O error.
431    #[cfg(target_os = "linux")]
432    pub fn path_fd_beneath(&self, rel: impl AsRef<Path>) -> Result<OwnedFd, WorkdirError> {
433        let rel = rel.as_ref().to_string_lossy();
434        let op = WorkdirOperation::Read;
435        let (parent, name) = self.parent_of(&rel, op, false)?;
436        let Some(parent) = parent else {
437            return Err(self.io(&rel, std::io::ErrorKind::NotFound.into()));
438        };
439        let flags = OFlags::PATH | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
440        let refused_or = |errno: Errno| match self.leaf_type(&parent, name, &rel) {
441            Ok(Some(FileType::Symlink)) => self.refuse(&rel, &rel, op, RefusalReason::Symlink),
442            Ok(_) => self.io(&rel, errno.into()),
443            Err(error) => error,
444        };
445        let fd = rustix::fs::openat(&parent, name, flags, Mode::empty()).map_err(refused_or)?;
446        // `O_PATH | O_NOFOLLOW` opens a link itself rather than failing on it, so the kind is
447        // checked on the descriptor that was opened.
448        let stat = rustix::fs::fstat(&fd).map_err(|errno| self.io(&rel, errno.into()))?;
449        if FileType::from_raw_mode(stat.st_mode) != FileType::Directory {
450            return Err(refused_or(Errno::NOTDIR));
451        }
452        Ok(fd)
453    }
454
455    /// This root's host path, for display, a subprocess argument, a Landlock rule or a bind
456    /// source. The runtime never opens it through `std`.
457    pub fn path(&self) -> &Path {
458        &self.anchor.path
459    }
460
461    /// The host path of `rel` beneath this root, on [`Self::path`]'s terms.
462    pub fn host_path(&self, rel: impl AsRef<Path>) -> PathBuf {
463        self.anchor.path.join(rel)
464    }
465
466    /// `rel` relative to the accessible workdir, the form every refusal and error uses.
467    pub fn display_path(&self, rel: impl AsRef<Path>) -> String {
468        self.display(&rel.as_ref().to_string_lossy())
469    }
470
471    /// The uid and gid that own this root's directory, from `fstat` on the held descriptor.
472    pub fn owner(&self) -> Result<(u32, u32), WorkdirError> {
473        let stat = rustix::fs::fstat(&*self.anchor_fd(false)?)
474            .map_err(|errno| self.io("", errno.into()))?;
475        Ok((stat.st_uid, stat.st_gid))
476    }
477
478    /// Every refusal latched since the last call, on any clone of either root, oldest first.
479    pub fn take_refusals(&self) -> Vec<WorkdirRefusal> {
480        std::mem::take(&mut *self.latch.lock().unwrap_or_else(|e| e.into_inner()))
481    }
482
483    /// Writes `bytes` to `rel` through a same-directory temporary file renamed over it.
484    ///
485    /// Missing parent directories are created with [`DEFAULT_DIR_MODE`]. A link at `rel` when the
486    /// call starts is refused; one planted after that check is replaced by the rename, never
487    /// followed. A directory at `rel` is an I/O error. The temporary file
488    /// `.<name>.<uuid v7 simple>.tmp` is removed on any failure.
489    pub fn write_file(
490        &self,
491        rel: impl AsRef<Path>,
492        bytes: &[u8],
493        mode: u32,
494    ) -> Result<(), WorkdirError> {
495        self.retry_if_removed(|| self.write_file_inner(rel.as_ref(), bytes, mode, false))
496    }
497
498    fn write_file_inner(
499        &self,
500        rel: &Path,
501        bytes: &[u8],
502        mode: u32,
503        exact_mode: bool,
504    ) -> Result<(), WorkdirError> {
505        let rel = rel.to_string_lossy();
506        let op = WorkdirOperation::Write;
507        let (parent, name) = self.parent_of(&rel, op, true)?;
508        let parent = parent.expect("a creating walk reaches the parent");
509        match self.leaf_type(&parent, name, &rel)? {
510            Some(FileType::Symlink) => {
511                return Err(self.refuse(&rel, &rel, op, RefusalReason::Symlink));
512            }
513            Some(FileType::Directory) => {
514                return Err(self.io(&rel, std::io::ErrorKind::IsADirectory.into()));
515            }
516            _ => {}
517        }
518        let temp = format!(
519            ".{}.{}.tmp",
520            name.to_string_lossy(),
521            uuid::Uuid::now_v7().simple()
522        );
523        let flags =
524            OFlags::CREATE | OFlags::EXCL | OFlags::WRONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
525        let fd = rustix::fs::openat(&parent, temp.as_str(), flags, mode_of(mode))
526            .map_err(|errno| self.io(&rel, errno.into()))?;
527        let chmod = if exact_mode {
528            rustix::fs::fchmod(&fd, mode_of(mode)).map_err(|errno| self.io(&rel, errno.into()))
529        } else {
530            Ok(())
531        };
532        let result = chmod
533            .and_then(|()| {
534                File::from(fd)
535                    .write_all(bytes)
536                    .map_err(|source| self.io(&rel, source))
537            })
538            .and_then(|()| {
539                #[cfg(test)]
540                if let Some(hook) = BEFORE_RENAME.with(|slot| slot.borrow_mut().take()) {
541                    hook();
542                }
543                rustix::fs::renameat(&parent, temp.as_str(), &parent, name)
544                    .map_err(|errno| self.io(&rel, errno.into()))
545            });
546        if result.is_err() {
547            let _ = rustix::fs::unlinkat(&parent, temp.as_str(), AtFlags::empty());
548        }
549        result
550    }
551
552    /// [`Self::write_file`], with the file's mode set to exactly `mode` — `fchmod` on the
553    /// temporary file before the rename — rather than `mode` less the umask.
554    pub fn write_file_exact_mode(
555        &self,
556        rel: impl AsRef<Path>,
557        bytes: &[u8],
558        mode: u32,
559    ) -> Result<(), WorkdirError> {
560        self.retry_if_removed(|| self.write_file_inner(rel.as_ref(), bytes, mode, true))
561    }
562
563    /// Truncates and rewrites the regular file at `rel` in place, creating it with `mode` if
564    /// absent, so its inode survives — what a Landlock rule bound to that inode needs.
565    ///
566    /// A link, a non-regular file or a file with more than one hard link at `rel` is refused.
567    pub fn overwrite_in_place(
568        &self,
569        rel: impl AsRef<Path>,
570        bytes: &[u8],
571        mode: u32,
572    ) -> Result<(), WorkdirError> {
573        let rel = rel.as_ref().to_string_lossy();
574        let op = WorkdirOperation::Write;
575        let flags = OFlags::WRONLY | OFlags::CREATE;
576        let mut file = self.retry_if_removed(|| self.open_regular(&rel, op, flags, mode, true))?;
577        file.set_len(0).map_err(|source| self.io(&rel, source))?;
578        file.write_all(bytes)
579            .map_err(|source| self.io(&rel, source))
580    }
581
582    /// Opens `rel` for appending, creating it with `mode` and its parents with
583    /// [`DEFAULT_DIR_MODE`] if absent.
584    ///
585    /// Refused when `rel` is a link, is not a regular file, or is a regular file with more than
586    /// one hard link — the last because a hard link to a host file would otherwise be appended to.
587    pub fn open_append(&self, rel: impl AsRef<Path>, mode: u32) -> Result<File, WorkdirError> {
588        let rel = rel.as_ref().to_string_lossy();
589        let flags = OFlags::WRONLY | OFlags::APPEND | OFlags::CREATE;
590        self.retry_if_removed(|| {
591            self.open_regular(&rel, WorkdirOperation::Append, flags, mode, true)
592        })
593    }
594
595    /// The bytes of the regular file at `rel`, or `None` when it or a parent is absent.
596    ///
597    /// Refused when `rel` or a parent is a link, or `rel` is not a regular file; a FIFO is
598    /// refused without blocking. A file longer than `max_bytes` is an `InvalidData` error.
599    pub fn read_file(
600        &self,
601        rel: impl AsRef<Path>,
602        max_bytes: u64,
603    ) -> Result<Option<Vec<u8>>, WorkdirError> {
604        let rel = rel.as_ref().to_string_lossy();
605        let mut file =
606            match self.open_regular(&rel, WorkdirOperation::Read, OFlags::RDONLY, 0, false) {
607                Ok(file) => file,
608                Err(error) if error.is_not_found() => return Ok(None),
609                Err(error) => return Err(error),
610            };
611        let mut bytes = Vec::new();
612        Read::by_ref(&mut file)
613            .take(max_bytes.saturating_add(1))
614            .read_to_end(&mut bytes)
615            .map_err(|source| self.io(&rel, source))?;
616        if bytes.len() as u64 > max_bytes {
617            return Err(self.io(
618                &rel,
619                std::io::Error::new(
620                    std::io::ErrorKind::InvalidData,
621                    format!("larger than {max_bytes} bytes"),
622                ),
623            ));
624        }
625        Ok(Some(bytes))
626    }
627
628    /// [`Self::read_file`] with no size bound, decoded as UTF-8.
629    pub fn read_to_string(&self, rel: impl AsRef<Path>) -> Result<Option<String>, WorkdirError> {
630        let rel = rel.as_ref();
631        let Some(bytes) = self.read_file(rel, u64::MAX)? else {
632            return Ok(None);
633        };
634        String::from_utf8(bytes).map(Some).map_err(|error| {
635            self.io(
636                &rel.to_string_lossy(),
637                std::io::Error::new(std::io::ErrorKind::InvalidData, error),
638            )
639        })
640    }
641
642    /// Creates `rel` and every missing parent. `mode` applies to each directory this call
643    /// creates (less the umask); an existing one keeps its mode. A link at any segment is refused.
644    pub fn create_dir_all(&self, rel: impl AsRef<Path>, mode: u32) -> Result<(), WorkdirError> {
645        let rel = rel.as_ref().to_string_lossy();
646        let op = WorkdirOperation::CreateDir;
647        let segments = self.segments(&rel, op)?;
648        self.retry_if_removed(|| self.walk(&rel, &segments, segments.len(), op, Some(mode)))
649            .map(|_| ())
650    }
651
652    /// What `fstatat(AT_SYMLINK_NOFOLLOW)` finds at `rel`, or `None` when it or a parent is
653    /// absent. A link at a parent is refused; a link at `rel` itself is reported as
654    /// [`EntryKind::Symlink`] for the caller to refuse.
655    pub fn entry_kind(&self, rel: impl AsRef<Path>) -> Result<Option<EntryKind>, WorkdirError> {
656        let rel = rel.as_ref().to_string_lossy();
657        let op = WorkdirOperation::Read;
658        let (parent, name) = self.parent_of(&rel, op, false)?;
659        let Some(parent) = parent else {
660            return Ok(None);
661        };
662        Ok(self.leaf_type(&parent, name, &rel)?.map(EntryKind::of))
663    }
664
665    /// Size in bytes of the regular file at `rel`, or `None` when it or a parent is absent or it
666    /// is not a regular file. A link at any segment is refused.
667    pub fn file_len(&self, rel: impl AsRef<Path>) -> Result<Option<u64>, WorkdirError> {
668        let rel = rel.as_ref().to_string_lossy();
669        let op = WorkdirOperation::Read;
670        let (parent, name) = self.parent_of(&rel, op, false)?;
671        let Some(parent) = parent else {
672            return Ok(None);
673        };
674        match rustix::fs::statat(&parent, name, AtFlags::SYMLINK_NOFOLLOW) {
675            Ok(stat) => match FileType::from_raw_mode(stat.st_mode) {
676                FileType::RegularFile => Ok(Some(stat.st_size as u64)),
677                FileType::Symlink => Err(self.refuse(&rel, &rel, op, RefusalReason::Symlink)),
678                _ => Ok(None),
679            },
680            Err(Errno::NOENT) => Ok(None),
681            Err(errno) => Err(self.io(&rel, errno.into())),
682        }
683    }
684
685    /// The entries of the directory `rel` (`""` for this root itself) with what each is, sorted by
686    /// name, or `None` when it or a parent is absent. A link at any segment of `rel` is refused;
687    /// a link *inside* it is listed as [`EntryKind::Symlink`].
688    pub fn list_dir(
689        &self,
690        rel: impl AsRef<Path>,
691    ) -> Result<Option<Vec<(String, EntryKind)>>, WorkdirError> {
692        let rel = rel.as_ref().to_string_lossy();
693        let op = WorkdirOperation::Read;
694        let held = if rel.is_empty() {
695            Some(Held::Anchor(self.anchor_fd(false)?))
696        } else {
697            let segments = self.segments(&rel, op)?;
698            self.walk(&rel, &segments, segments.len(), op, None)?
699        };
700        let Some(dir) = held else {
701            return Ok(None);
702        };
703        let mut entries = Vec::new();
704        for (name, file_type) in self.children(&dir, &rel)? {
705            entries.push((
706                name.to_string_lossy().into_owned(),
707                EntryKind::of(file_type),
708            ));
709        }
710        entries.sort();
711        Ok(Some(entries))
712    }
713
714    /// Unlinks the file at `rel`. A link at `rel` or at a parent is refused rather than removed,
715    /// so the refusal is recorded.
716    pub fn remove_file(&self, rel: impl AsRef<Path>) -> Result<(), WorkdirError> {
717        let rel = rel.as_ref().to_string_lossy();
718        let op = WorkdirOperation::Remove;
719        let (parent, name) = self.parent_of(&rel, op, false)?;
720        let Some(parent) = parent else {
721            return Err(self.io(&rel, std::io::ErrorKind::NotFound.into()));
722        };
723        if self.leaf_type(&parent, name, &rel)? == Some(FileType::Symlink) {
724            return Err(self.refuse(&rel, &rel, op, RefusalReason::Symlink));
725        }
726        rustix::fs::unlinkat(&parent, name, AtFlags::empty())
727            .map_err(|errno| self.io(&rel, errno.into()))
728    }
729
730    /// Removes the directory `rel` and everything in it, by descriptor: each child directory is
731    /// opened no-follow from its parent's descriptor, so no part of the removal resolves a path
732    /// from the root. A link at `rel` or at a parent is refused; a link inside is unlinked, not
733    /// followed.
734    pub fn remove_dir_all(&self, rel: impl AsRef<Path>) -> Result<(), WorkdirError> {
735        let rel = rel.as_ref().to_string_lossy();
736        let op = WorkdirOperation::Remove;
737        let (parent, name) = self.parent_of(&rel, op, false)?;
738        let Some(parent) = parent else {
739            return Err(self.io(&rel, std::io::ErrorKind::NotFound.into()));
740        };
741        match self.leaf_type(&parent, name, &rel)? {
742            None => return Err(self.io(&rel, std::io::ErrorKind::NotFound.into())),
743            Some(FileType::Symlink) => {
744                return Err(self.refuse(&rel, &rel, op, RefusalReason::Symlink));
745            }
746            Some(FileType::Directory) => {}
747            Some(_) => return Err(self.io(&rel, std::io::ErrorKind::NotADirectory.into())),
748        }
749        let dir = self.open_dir(&parent, name, &rel, &rel, op)?;
750        self.empty_dir(&dir, &rel)?;
751        rustix::fs::unlinkat(&parent, name, AtFlags::REMOVEDIR)
752            .map_err(|errno| self.io(&rel, errno.into()))
753    }
754
755    fn empty_dir(&self, dir: &OwnedFd, rel: &str) -> Result<(), WorkdirError> {
756        for (name, file_type) in self.children(dir, rel)? {
757            let child = format!("{rel}/{}", name.to_string_lossy());
758            if file_type == FileType::Directory {
759                let sub = self.open_dir(dir, &name, &child, &child, WorkdirOperation::Remove)?;
760                self.empty_dir(&sub, &child)?;
761                rustix::fs::unlinkat(dir, &name, AtFlags::REMOVEDIR)
762            } else {
763                rustix::fs::unlinkat(dir, &name, AtFlags::empty())
764            }
765            .map_err(|errno| self.io(&child, errno.into()))?;
766        }
767        Ok(())
768    }
769
770    /// The names in `dir` other than `.` and `..`, each with the type `fstatat(AT_SYMLINK_NOFOLLOW)`
771    /// reports — not `d_type`, which some filesystems leave unknown.
772    fn children(
773        &self,
774        dir: &impl AsFd,
775        rel: &str,
776    ) -> Result<Vec<(std::ffi::OsString, FileType)>, WorkdirError> {
777        let mut listing =
778            rustix::fs::Dir::read_from(dir).map_err(|errno| self.io(rel, errno.into()))?;
779        let mut names = Vec::new();
780        while let Some(entry) = listing.read() {
781            let entry = entry.map_err(|errno| self.io(rel, errno.into()))?;
782            let name = OsStr::from_bytes(entry.file_name().to_bytes());
783            if name == "." || name == ".." {
784                continue;
785            }
786            names.push(name.to_os_string());
787        }
788        let mut children = Vec::with_capacity(names.len());
789        for name in names {
790            match rustix::fs::statat(dir, &name, AtFlags::SYMLINK_NOFOLLOW) {
791                Ok(stat) => children.push((name, FileType::from_raw_mode(stat.st_mode))),
792                Err(Errno::NOENT) => {}
793                Err(errno) => return Err(self.io(rel, errno.into())),
794            }
795        }
796        Ok(children)
797    }
798
799    /// Opens `rel` as a regular file with `flags | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC`, then
800    /// checks the descriptor's own `fstat`: not a regular file is refused, and so is more than
801    /// one hard link when `flags` writes.
802    fn open_regular(
803        &self,
804        rel: &str,
805        op: WorkdirOperation,
806        flags: OFlags,
807        mode: u32,
808        create_parents: bool,
809    ) -> Result<File, WorkdirError> {
810        let (parent, name) = self.parent_of(rel, op, create_parents)?;
811        let Some(parent) = parent else {
812            return Err(self.io(rel, std::io::ErrorKind::NotFound.into()));
813        };
814        let flags = flags | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC;
815        let fd = match rustix::fs::openat(&parent, name, flags, mode_of(mode)) {
816            Ok(fd) => fd,
817            Err(errno) => {
818                return Err(match self.leaf_type(&parent, name, rel)? {
819                    Some(FileType::Symlink) => self.refuse(rel, rel, op, RefusalReason::Symlink),
820                    Some(FileType::RegularFile) | None => self.io(rel, errno.into()),
821                    Some(_) => self.refuse(rel, rel, op, RefusalReason::NotARegularFile),
822                });
823            }
824        };
825        let stat = rustix::fs::fstat(&fd).map_err(|errno| self.io(rel, errno.into()))?;
826        if FileType::from_raw_mode(stat.st_mode) != FileType::RegularFile {
827            return Err(self.refuse(rel, rel, op, RefusalReason::NotARegularFile));
828        }
829        if op != WorkdirOperation::Read && stat.st_nlink > 1 {
830            return Err(self.refuse(rel, rel, op, RefusalReason::HardLink));
831        }
832        Ok(File::from(fd))
833    }
834
835    /// Splits `rel` on `/`, refusing an empty, `.` or `..` segment — and so an absolute path —
836    /// before any syscall.
837    fn segments<'r>(
838        &self,
839        rel: &'r str,
840        op: WorkdirOperation,
841    ) -> Result<Vec<&'r OsStr>, WorkdirError> {
842        let segments: Vec<&OsStr> = rel.split('/').map(OsStr::new).collect();
843        if segments
844            .iter()
845            .any(|segment| segment.is_empty() || *segment == "." || *segment == "..")
846        {
847            return Err(self.refuse(rel, rel, op, RefusalReason::InvalidComponent));
848        }
849        Ok(segments)
850    }
851
852    /// Walks to `rel`'s parent and returns its descriptor with the last segment, or `None` for
853    /// the descriptor when a parent is absent and `create` is false.
854    fn parent_of<'r>(
855        &self,
856        rel: &'r str,
857        op: WorkdirOperation,
858        create: bool,
859    ) -> Result<(Option<Held>, &'r OsStr), WorkdirError> {
860        let segments = self.segments(rel, op)?;
861        let name = *segments.last().expect("split yields at least one segment");
862        let held = self.walk(
863            rel,
864            &segments,
865            segments.len() - 1,
866            op,
867            create.then_some(DEFAULT_DIR_MODE),
868        )?;
869        Ok((held, name))
870    }
871
872    /// Opens the first `depth` segments one at a time from the anchor, creating each missing one
873    /// with `create`'s mode when given. `None` when a segment is absent and `create` is `None`.
874    fn walk(
875        &self,
876        rel: &str,
877        segments: &[&OsStr],
878        depth: usize,
879        op: WorkdirOperation,
880        create: Option<u32>,
881    ) -> Result<Option<Held>, WorkdirError> {
882        let mut current = Held::Anchor(self.anchor_fd(create.is_some())?);
883        for index in 0..depth {
884            let name = segments[index];
885            let so_far = segments[..=index]
886                .iter()
887                .map(|segment| segment.to_string_lossy())
888                .collect::<Vec<_>>()
889                .join("/");
890            let next = match rustix::fs::openat(&current, name, DIR_FLAGS, Mode::empty()) {
891                Ok(fd) => fd,
892                Err(Errno::NOENT) => {
893                    let Some(mode) = create else {
894                        return Ok(None);
895                    };
896                    match rustix::fs::mkdirat(&current, name, mode_of(mode)) {
897                        Ok(()) | Err(Errno::EXIST) => {}
898                        Err(errno) => return Err(self.io(&so_far, errno.into())),
899                    }
900                    self.open_dir(&current, name, rel, &so_far, op)?
901                }
902                Err(errno) => return Err(self.classify(&current, name, rel, &so_far, op, errno)),
903            };
904            current = Held::Opened(next);
905        }
906        Ok(Some(current))
907    }
908
909    fn open_dir(
910        &self,
911        parent: &impl AsFd,
912        name: &OsStr,
913        rel: &str,
914        so_far: &str,
915        op: WorkdirOperation,
916    ) -> Result<OwnedFd, WorkdirError> {
917        rustix::fs::openat(parent, name, DIR_FLAGS, Mode::empty())
918            .map_err(|errno| self.classify(parent, name, rel, so_far, op, errno))
919    }
920
921    /// Turns a failed directory open into a refusal when `fstatat` says the segment is a link,
922    /// and into an I/O error otherwise.
923    fn classify(
924        &self,
925        parent: &impl AsFd,
926        name: &OsStr,
927        rel: &str,
928        so_far: &str,
929        op: WorkdirOperation,
930        errno: Errno,
931    ) -> WorkdirError {
932        match rustix::fs::statat(parent, name, AtFlags::SYMLINK_NOFOLLOW) {
933            Ok(stat) if FileType::from_raw_mode(stat.st_mode) == FileType::Symlink => {
934                self.refuse(rel, so_far, op, RefusalReason::Symlink)
935            }
936            _ => self.io(so_far, errno.into()),
937        }
938    }
939
940    /// The type at `name` in `parent`, not following a link, or `None` when absent.
941    fn leaf_type(
942        &self,
943        parent: &impl AsFd,
944        name: &OsStr,
945        rel: &str,
946    ) -> Result<Option<FileType>, WorkdirError> {
947        match rustix::fs::statat(parent, name, AtFlags::SYMLINK_NOFOLLOW) {
948            Ok(stat) => Ok(Some(FileType::from_raw_mode(stat.st_mode))),
949            Err(Errno::NOENT) => Ok(None),
950            Err(errno) => Err(self.io(rel, errno.into())),
951        }
952    }
953
954    /// The anchor's descriptor. Before a call that creates, a directory `fstat` reports removed
955    /// (no links to it) is reached again by its [`Origin`] and the new descriptor held from then
956    /// on.
957    fn anchor_fd(&self, creating: bool) -> Result<Arc<OwnedFd>, WorkdirError> {
958        let mut held = self.anchor.fd.lock().unwrap_or_else(|e| e.into_inner());
959        let removed = creating
960            && !nlink_unreliable()
961            && rustix::fs::fstat(&**held).is_ok_and(|stat| stat.st_nlink == 0);
962        if removed {
963            *held = Arc::new(self.reach_again()?);
964        }
965        Ok(Arc::clone(&held))
966    }
967
968    /// Runs a creating `call`, and runs it once more when it failed `NotFound` because the held
969    /// directory was removed, after reaching that directory again by its [`Origin`].
970    ///
971    /// `fstat` does not reveal a removed directory on every platform — Darwin keeps its link
972    /// count above zero — so [`Self::anchor_fd`]'s check alone would leave a removed root
973    /// creating into a directory that no longer exists. A directory that was moved, not removed,
974    /// stays held: writes follow it, never the name it used to have.
975    fn retry_if_removed<T>(
976        &self,
977        call: impl Fn() -> Result<T, WorkdirError>,
978    ) -> Result<T, WorkdirError> {
979        match call() {
980            Err(error) if error.is_not_found() && self.reach_again_if_removed()? => call(),
981            result => result,
982        }
983    }
984
985    /// Whether the held directory was removed, found by creating a uniquely named directory in
986    /// it and removing it again; when it was, the root holds the directory reached again.
987    fn reach_again_if_removed(&self) -> Result<bool, WorkdirError> {
988        let mut held = self.anchor.fd.lock().unwrap_or_else(|e| e.into_inner());
989        let probe = format!(".{}.probe", uuid::Uuid::now_v7().simple());
990        match rustix::fs::mkdirat(&**held, probe.as_str(), Mode::RWXU) {
991            Ok(()) => {
992                let _ = rustix::fs::unlinkat(&**held, probe.as_str(), AtFlags::REMOVEDIR);
993                Ok(false)
994            }
995            Err(Errno::NOENT) => {
996                *held = Arc::new(self.reach_again()?);
997                Ok(true)
998            }
999            Err(_) => Ok(false),
1000        }
1001    }
1002
1003    /// The anchor's directory reached again from its [`Origin`], created if missing: by its host
1004    /// path for a root opened by path, and by a no-follow walk from the parent root otherwise.
1005    fn reach_again(&self) -> Result<OwnedFd, WorkdirError> {
1006        match &self.anchor.origin {
1007            Origin::Path => {
1008                std::fs::create_dir_all(&self.anchor.path).map_err(|source| self.io("", source))?;
1009                open_by_path(&self.anchor.path)
1010            }
1011            Origin::Beneath { from, rel } => {
1012                let op = WorkdirOperation::CreateDir;
1013                let segments = from.segments(rel, op)?;
1014                let walked = from.retry_if_removed(|| {
1015                    from.walk(rel, &segments, segments.len(), op, Some(DEFAULT_DIR_MODE))
1016                })?;
1017                let Some(Held::Opened(fd)) = walked else {
1018                    unreachable!("a walk that creates always reaches its last segment")
1019                };
1020                Ok(fd)
1021            }
1022        }
1023    }
1024
1025    fn display(&self, rel: &str) -> String {
1026        match (self.anchor.prefix.is_empty(), rel.is_empty()) {
1027            (true, _) => rel.to_string(),
1028            (false, true) => self.anchor.prefix.clone(),
1029            (false, false) => format!("{}/{rel}", self.anchor.prefix),
1030        }
1031    }
1032
1033    fn refuse(
1034        &self,
1035        rel: &str,
1036        refused_at: &str,
1037        operation: WorkdirOperation,
1038        reason: RefusalReason,
1039    ) -> WorkdirError {
1040        let refusal = WorkdirRefusal {
1041            path: self.display(rel),
1042            refused_at: self.display(refused_at),
1043            operation,
1044            reason,
1045        };
1046        self.latch
1047            .lock()
1048            .unwrap_or_else(|e| e.into_inner())
1049            .push(refusal.clone());
1050        WorkdirError::Refused(refusal)
1051    }
1052
1053    fn io(&self, rel: &str, source: std::io::Error) -> WorkdirError {
1054        WorkdirError::Io {
1055            path: self.display(rel),
1056            source,
1057        }
1058    }
1059}
1060
1061/// `(st_dev, st_ino)` of the file `fd` names, from `fstat`.
1062pub(crate) fn fd_identity(fd: impl AsFd) -> rustix::io::Result<(u64, u64)> {
1063    let stat = rustix::fs::fstat(fd)?;
1064    Ok((stat.st_dev as u64, stat.st_ino as u64))
1065}
1066
1067#[cfg(test)]
1068mod tests {
1069    use super::*;
1070    use std::os::unix::fs::{symlink, MetadataExt, PermissionsExt};
1071
1072    struct Fixture {
1073        _temp: tempfile::TempDir,
1074        workdir: PathBuf,
1075        outside: PathBuf,
1076        sentinel: PathBuf,
1077        root: WorkdirRoot,
1078    }
1079
1080    const SENTINEL: &[u8] = b"operator data";
1081
1082    fn refusal_of(error: &WorkdirError) -> &WorkdirRefusal {
1083        match error {
1084            WorkdirError::Refused(refusal) => refusal,
1085            other => panic!("expected a refusal, got {other:?}"),
1086        }
1087    }
1088
1089    fn fixture() -> Fixture {
1090        let temp = tempfile::tempdir().unwrap();
1091        let workdir = temp.path().join("workdir");
1092        let outside = temp.path().join("outside");
1093        std::fs::create_dir(&workdir).unwrap();
1094        std::fs::create_dir(&outside).unwrap();
1095        let sentinel = outside.join("sentinel.txt");
1096        std::fs::write(&sentinel, SENTINEL).unwrap();
1097        let root = WorkdirRoot::open(&workdir).unwrap();
1098        Fixture {
1099            _temp: temp,
1100            workdir,
1101            outside,
1102            sentinel,
1103            root,
1104        }
1105    }
1106
1107    impl Fixture {
1108        fn outside_snapshot(&self) -> Vec<(String, Vec<u8>, i64, i64)> {
1109            let mut entries: Vec<_> = std::fs::read_dir(&self.outside)
1110                .unwrap()
1111                .map(|entry| {
1112                    let entry = entry.unwrap();
1113                    let meta = std::fs::symlink_metadata(entry.path()).unwrap();
1114                    let bytes = if meta.is_file() {
1115                        std::fs::read(entry.path()).unwrap()
1116                    } else {
1117                        Vec::new()
1118                    };
1119                    (
1120                        entry.file_name().to_string_lossy().into_owned(),
1121                        bytes,
1122                        meta.mtime(),
1123                        meta.mtime_nsec(),
1124                    )
1125                })
1126                .collect();
1127            entries.sort();
1128            entries
1129        }
1130
1131        /// Plants a link at `rel` to the sentinel, creating its parent directories.
1132        fn link_at_target(&self, rel: &str) {
1133            let host = self.workdir.join(rel);
1134            std::fs::create_dir_all(host.parent().unwrap()).unwrap();
1135            symlink(&self.sentinel, host).unwrap();
1136        }
1137
1138        fn dangling_at_target(&self, rel: &str) {
1139            let host = self.workdir.join(rel);
1140            std::fs::create_dir_all(host.parent().unwrap()).unwrap();
1141            symlink(self.outside.join("not-yet.txt"), host).unwrap();
1142        }
1143
1144        fn link_at_parent(&self, parent_rel: &str) {
1145            let host = self.workdir.join(parent_rel);
1146            std::fs::create_dir_all(host.parent().unwrap()).unwrap();
1147            symlink(&self.outside, host).unwrap();
1148        }
1149    }
1150
1151    type Operation = fn(&WorkdirRoot, &str) -> Result<(), WorkdirError>;
1152
1153    fn operations() -> Vec<(&'static str, WorkdirOperation, Operation)> {
1154        vec![
1155            ("write_file", WorkdirOperation::Write, |root, rel| {
1156                root.write_file(rel, b"runtime bytes", 0o644)
1157            }),
1158            ("open_append", WorkdirOperation::Append, |root, rel| {
1159                root.open_append(rel, 0o644).and_then(|mut file| {
1160                    file.write_all(b"runtime bytes")
1161                        .map_err(|source| WorkdirError::Io {
1162                            path: rel.to_string(),
1163                            source,
1164                        })
1165                })
1166            }),
1167            ("read_file", WorkdirOperation::Read, |root, rel| {
1168                root.read_file(rel, u64::MAX).map(|_| ())
1169            }),
1170            (
1171                "create_dir_all",
1172                WorkdirOperation::CreateDir,
1173                |root, rel| root.create_dir_all(rel, 0o755),
1174            ),
1175            ("remove_file", WorkdirOperation::Remove, |root, rel| {
1176                root.remove_file(rel)
1177            }),
1178            ("remove_dir_all", WorkdirOperation::Remove, |root, rel| {
1179                root.remove_dir_all(rel)
1180            }),
1181        ]
1182    }
1183
1184    fn assert_refused(
1185        fixture: &Fixture,
1186        result: Result<(), WorkdirError>,
1187        before: &[(String, Vec<u8>, i64, i64)],
1188        label: &str,
1189        path: &str,
1190        refused_at: &str,
1191        operation: WorkdirOperation,
1192    ) {
1193        let expected = WorkdirRefusal {
1194            path: path.to_string(),
1195            refused_at: refused_at.to_string(),
1196            operation,
1197            reason: RefusalReason::Symlink,
1198        };
1199        match result {
1200            Err(WorkdirError::Refused(refusal)) => assert_eq!(refusal, expected, "{label}"),
1201            other => panic!("{label}: expected a refusal, got {other:?}"),
1202        }
1203        assert_eq!(
1204            fixture.root.take_refusals(),
1205            vec![expected],
1206            "{label}: latched once"
1207        );
1208        assert_eq!(
1209            fixture.outside_snapshot(),
1210            before,
1211            "{label}: outside changed"
1212        );
1213        assert_eq!(std::fs::read(&fixture.sentinel).unwrap(), SENTINEL);
1214    }
1215
1216    #[test]
1217    fn every_operation_refuses_a_link_at_the_target() {
1218        for (label, operation, call) in operations() {
1219            let fixture = fixture();
1220            fixture.link_at_target("dir/target");
1221            let before = fixture.outside_snapshot();
1222            let result = call(&fixture.root, "dir/target");
1223            assert_refused(
1224                &fixture,
1225                result,
1226                &before,
1227                label,
1228                "dir/target",
1229                "dir/target",
1230                operation,
1231            );
1232            assert!(
1233                std::fs::symlink_metadata(fixture.workdir.join("dir/target"))
1234                    .unwrap()
1235                    .file_type()
1236                    .is_symlink()
1237            );
1238        }
1239    }
1240
1241    #[test]
1242    fn every_operation_refuses_a_link_at_a_parent_directory() {
1243        for (label, operation, call) in operations() {
1244            let fixture = fixture();
1245            std::fs::create_dir(fixture.outside.join("dir")).unwrap();
1246            std::fs::write(fixture.outside.join("dir/target"), SENTINEL).unwrap();
1247            fixture.link_at_parent("a/b");
1248            let before = fixture.outside_snapshot();
1249            let result = call(&fixture.root, "a/b/dir/target");
1250            assert_refused(
1251                &fixture,
1252                result,
1253                &before,
1254                label,
1255                "a/b/dir/target",
1256                "a/b",
1257                operation,
1258            );
1259            assert_eq!(
1260                std::fs::read(fixture.outside.join("dir/target")).unwrap(),
1261                SENTINEL
1262            );
1263        }
1264    }
1265
1266    #[test]
1267    fn every_operation_refuses_a_dangling_link_at_the_target() {
1268        for (label, operation, call) in operations() {
1269            let fixture = fixture();
1270            fixture.dangling_at_target("target");
1271            let before = fixture.outside_snapshot();
1272            let result = call(&fixture.root, "target");
1273            assert_refused(
1274                &fixture, result, &before, label, "target", "target", operation,
1275            );
1276            assert!(!fixture.outside.join("not-yet.txt").exists(), "{label}");
1277        }
1278    }
1279
1280    #[test]
1281    fn a_non_plain_component_is_refused_before_any_syscall() {
1282        let fixture = fixture();
1283        for rel in [
1284            "",
1285            "/etc/passwd",
1286            "../outside/sentinel.txt",
1287            "a/./b",
1288            "a//b",
1289            "a/",
1290            ".",
1291        ] {
1292            for (label, operation, call) in operations() {
1293                match call(&fixture.root, rel) {
1294                    Err(WorkdirError::Refused(refusal)) => {
1295                        assert_eq!(
1296                            refusal.reason,
1297                            RefusalReason::InvalidComponent,
1298                            "{label} {rel}"
1299                        );
1300                        assert_eq!(refusal.operation, operation);
1301                        assert_eq!(refusal.path, rel);
1302                    }
1303                    other => panic!("{label} {rel:?}: expected a refusal, got {other:?}"),
1304                }
1305                assert_eq!(fixture.root.take_refusals().len(), 1);
1306            }
1307        }
1308        assert_eq!(std::fs::read(&fixture.sentinel).unwrap(), SENTINEL);
1309        assert_eq!(std::fs::read_dir(&fixture.workdir).unwrap().count(), 0);
1310    }
1311
1312    #[test]
1313    fn a_fifo_is_refused_for_append_and_read_without_blocking() {
1314        let fixture = fixture();
1315        let fifo = fixture.workdir.join("pipe");
1316        assert!(std::process::Command::new("mkfifo")
1317            .arg(&fifo)
1318            .status()
1319            .unwrap()
1320            .success());
1321        let append = fixture.root.open_append("pipe", 0o644).map(|_| ());
1322        let read = fixture.root.read_file("pipe", u64::MAX).map(|_| ());
1323        for (result, operation) in [
1324            (append, WorkdirOperation::Append),
1325            (read, WorkdirOperation::Read),
1326        ] {
1327            match result {
1328                Err(WorkdirError::Refused(refusal)) => {
1329                    assert_eq!(refusal.reason, RefusalReason::NotARegularFile);
1330                    assert_eq!(refusal.operation, operation);
1331                    assert_eq!(refusal.refused_at, "pipe");
1332                }
1333                other => panic!("expected a refusal, got {other:?}"),
1334            }
1335        }
1336        assert_eq!(fixture.root.take_refusals().len(), 2);
1337    }
1338
1339    #[test]
1340    fn an_append_to_a_hard_link_into_outside_is_refused() {
1341        let fixture = fixture();
1342        std::fs::hard_link(&fixture.sentinel, fixture.workdir.join("log")).unwrap();
1343        let before = fixture.outside_snapshot();
1344        match fixture.root.open_append("log", 0o644) {
1345            Err(WorkdirError::Refused(refusal)) => {
1346                assert_eq!(refusal.reason, RefusalReason::HardLink);
1347                assert_eq!(refusal.operation, WorkdirOperation::Append);
1348                assert_eq!(refusal.path, "log");
1349            }
1350            other => panic!("expected a refusal, got {other:?}"),
1351        }
1352        assert_eq!(fixture.outside_snapshot(), before);
1353        assert_eq!(fixture.root.take_refusals().len(), 1);
1354    }
1355
1356    #[test]
1357    fn a_write_replaces_an_existing_file_and_leaves_no_temporary_sibling() {
1358        let fixture = fixture();
1359        std::fs::create_dir(fixture.workdir.join("out")).unwrap();
1360        std::fs::write(fixture.workdir.join("out/result.txt"), b"old").unwrap();
1361        fixture
1362            .root
1363            .write_file("out/result.txt", b"new", 0o600)
1364            .unwrap();
1365        assert_eq!(
1366            std::fs::read(fixture.workdir.join("out/result.txt")).unwrap(),
1367            b"new"
1368        );
1369        let names: Vec<_> = std::fs::read_dir(fixture.workdir.join("out"))
1370            .unwrap()
1371            .map(|entry| entry.unwrap().file_name())
1372            .collect();
1373        assert_eq!(names, vec![std::ffi::OsString::from("result.txt")]);
1374        let mode = std::fs::metadata(fixture.workdir.join("out/result.txt"))
1375            .unwrap()
1376            .permissions()
1377            .mode();
1378        assert_eq!(mode & 0o777, 0o600);
1379        assert!(fixture.root.take_refusals().is_empty());
1380    }
1381
1382    #[test]
1383    fn a_link_planted_between_the_check_and_the_rename_is_replaced_not_followed() {
1384        let fixture = fixture();
1385        let target = fixture.workdir.join("task.md");
1386        let sentinel = fixture.sentinel.clone();
1387        let planted = target.clone();
1388        before_next_rename(move || symlink(&sentinel, &planted).unwrap());
1389        let before = fixture.outside_snapshot();
1390        fixture
1391            .root
1392            .write_file("task.md", b"the task", 0o644)
1393            .unwrap();
1394        assert_eq!(fixture.outside_snapshot(), before);
1395        let meta = std::fs::symlink_metadata(&target).unwrap();
1396        assert!(meta.is_file(), "the rename replaced the planted link");
1397        assert_eq!(std::fs::read(&target).unwrap(), b"the task");
1398    }
1399
1400    #[test]
1401    fn a_murmur_link_planted_after_the_session_root_is_held_redirects_nothing() {
1402        let fixture = fixture();
1403        let session = fixture.root.session_beneath("ses_test").unwrap();
1404        let murmur = fixture.workdir.join(".murmur");
1405        std::fs::rename(&murmur, fixture.workdir.join("moved")).unwrap();
1406        symlink(&fixture.outside, &murmur).unwrap();
1407        let before = fixture.outside_snapshot();
1408        session
1409            .write_file("plans/plan-1.json", b"{}", 0o644)
1410            .unwrap();
1411        assert_eq!(fixture.outside_snapshot(), before);
1412        assert_eq!(
1413            std::fs::read(fixture.workdir.join("moved/ses_test/plans/plan-1.json")).unwrap(),
1414            b"{}"
1415        );
1416    }
1417
1418    #[test]
1419    fn a_session_root_refusal_names_the_path_from_the_accessible_workdir_and_shares_the_latch() {
1420        let fixture = fixture();
1421        let session = fixture.root.session_beneath("ses_test").unwrap();
1422        symlink(
1423            &fixture.outside,
1424            fixture.workdir.join(".murmur/ses_test/plans"),
1425        )
1426        .unwrap();
1427        let error = session
1428            .write_file("plans/plan-1.json", b"{}", 0o644)
1429            .unwrap_err();
1430        let refusal = refusal_of(&error);
1431        assert_eq!(refusal.path, ".murmur/ses_test/plans/plan-1.json");
1432        assert_eq!(refusal.refused_at, ".murmur/ses_test/plans");
1433        assert_eq!(
1434            error.to_string(),
1435            "refused to write .murmur/ses_test/plans/plan-1.json: .murmur/ses_test/plans is a \
1436             symbolic link inside the workdir"
1437        );
1438        assert_eq!(fixture.root.take_refusals(), vec![refusal.clone()]);
1439        assert!(session.take_refusals().is_empty());
1440    }
1441
1442    #[test]
1443    fn a_murmur_link_at_session_derivation_is_refused() {
1444        let fixture = fixture();
1445        symlink(&fixture.outside, fixture.workdir.join(".murmur")).unwrap();
1446        let error = fixture.root.session_beneath("ses_test").unwrap_err();
1447        let refusal = refusal_of(&error);
1448        assert_eq!(refusal.refused_at, ".murmur");
1449        assert_eq!(refusal.operation, WorkdirOperation::CreateDir);
1450        assert_eq!(std::fs::read_dir(&fixture.outside).unwrap().count(), 1);
1451    }
1452
1453    #[test]
1454    fn a_removed_session_directory_is_walked_to_again_before_a_write() {
1455        for nlink_unreliable in [false, true] {
1456            NLINK_UNRELIABLE.with(|cell| cell.set(nlink_unreliable));
1457            let fixture = fixture();
1458            let session = fixture.root.session_beneath("ses_test").unwrap();
1459            std::fs::remove_dir_all(fixture.workdir.join(".murmur")).unwrap();
1460
1461            session
1462                .write_file("out/result.txt", b"answer", 0o644)
1463                .unwrap();
1464            session
1465                .open_append("logs/bootstrap.log", 0o644)
1466                .unwrap()
1467                .write_all(b"line\n")
1468                .unwrap();
1469
1470            assert_eq!(
1471                std::fs::read(fixture.workdir.join(".murmur/ses_test/out/result.txt")).unwrap(),
1472                b"answer"
1473            );
1474            assert!(fixture
1475                .workdir
1476                .join(".murmur/ses_test/logs/bootstrap.log")
1477                .is_file());
1478            assert!(fixture.root.take_refusals().is_empty());
1479        }
1480        NLINK_UNRELIABLE.with(|cell| cell.set(false));
1481    }
1482
1483    /// A session directory moved aside, not removed, stays the one written to, with no probe
1484    /// left behind in it.
1485    #[test]
1486    fn a_moved_session_directory_is_still_the_one_written_to() {
1487        NLINK_UNRELIABLE.with(|cell| cell.set(true));
1488        let fixture = fixture();
1489        let session = fixture.root.session_beneath("ses_test").unwrap();
1490        std::fs::rename(
1491            fixture.workdir.join(".murmur"),
1492            fixture.workdir.join("moved"),
1493        )
1494        .unwrap();
1495
1496        session
1497            .write_file("out/result.txt", b"answer", 0o644)
1498            .unwrap();
1499
1500        NLINK_UNRELIABLE.with(|cell| cell.set(false));
1501        assert_eq!(
1502            std::fs::read(fixture.workdir.join("moved/ses_test/out/result.txt")).unwrap(),
1503            b"answer"
1504        );
1505        assert!(!fixture.workdir.join(".murmur").exists());
1506        let names: Vec<_> = std::fs::read_dir(fixture.workdir.join("moved/ses_test"))
1507            .unwrap()
1508            .map(|entry| entry.unwrap().file_name())
1509            .collect();
1510        assert_eq!(names, ["out"]);
1511    }
1512
1513    #[test]
1514    fn a_removed_session_directory_replaced_by_a_link_is_refused() {
1515        for nlink_unreliable in [false, true] {
1516            NLINK_UNRELIABLE.with(|cell| cell.set(nlink_unreliable));
1517            let fixture = fixture();
1518            let session = fixture.root.session_beneath("ses_test").unwrap();
1519            std::fs::remove_dir_all(fixture.workdir.join(".murmur")).unwrap();
1520            std::os::unix::fs::symlink(&fixture.outside, fixture.workdir.join(".murmur")).unwrap();
1521            let before = fixture.outside_snapshot();
1522
1523            let error = session
1524                .write_file("out/result.txt", b"answer", 0o644)
1525                .unwrap_err();
1526
1527            assert_eq!(refusal_of(&error).refused_at, ".murmur");
1528            assert_eq!(fixture.outside_snapshot(), before);
1529        }
1530        NLINK_UNRELIABLE.with(|cell| cell.set(false));
1531    }
1532
1533    #[test]
1534    fn absent_paths_read_as_none_with_no_refusal() {
1535        let fixture = fixture();
1536        assert_eq!(fixture.root.read_file("task.md", 10).unwrap(), None);
1537        assert_eq!(fixture.root.read_to_string("a/b/c").unwrap(), None);
1538        assert_eq!(fixture.root.entry_kind("a/b").unwrap(), None);
1539        assert_eq!(fixture.root.list_dir("tools").unwrap(), None);
1540        assert_eq!(fixture.root.file_len("logs/x.log").unwrap(), None);
1541        assert!(fixture.root.take_refusals().is_empty());
1542    }
1543
1544    #[test]
1545    fn create_dir_all_applies_the_mode_to_what_it_creates() {
1546        let fixture = fixture();
1547        fixture
1548            .root
1549            .create_dir_all(".process-driver/run", 0o700)
1550            .unwrap();
1551        let mode = std::fs::metadata(fixture.workdir.join(".process-driver/run"))
1552            .unwrap()
1553            .permissions()
1554            .mode();
1555        assert_eq!(mode & 0o777, 0o700);
1556        assert_eq!(
1557            fixture.root.entry_kind(".process-driver").unwrap(),
1558            Some(EntryKind::Directory)
1559        );
1560    }
1561
1562    #[test]
1563    fn remove_dir_all_unlinks_a_link_inside_without_following_it() {
1564        let fixture = fixture();
1565        std::fs::create_dir_all(fixture.workdir.join("tools/x/sub")).unwrap();
1566        std::fs::write(fixture.workdir.join("tools/x/sub/file"), b"x").unwrap();
1567        symlink(&fixture.outside, fixture.workdir.join("tools/x/escape")).unwrap();
1568        let before = fixture.outside_snapshot();
1569        fixture.root.remove_dir_all("tools/x").unwrap();
1570        assert!(!fixture.workdir.join("tools/x").exists());
1571        assert_eq!(fixture.outside_snapshot(), before);
1572    }
1573
1574    #[test]
1575    fn overwrite_in_place_keeps_the_inode_and_refuses_a_link() {
1576        let fixture = fixture();
1577        fixture
1578            .root
1579            .overwrite_in_place(".mur-etc/passwd", b"one\n", 0o644)
1580            .unwrap();
1581        let inode = std::fs::metadata(fixture.workdir.join(".mur-etc/passwd"))
1582            .unwrap()
1583            .ino();
1584        fixture
1585            .root
1586            .overwrite_in_place(".mur-etc/passwd", b"two\n", 0o644)
1587            .unwrap();
1588        let meta = std::fs::metadata(fixture.workdir.join(".mur-etc/passwd")).unwrap();
1589        assert_eq!(meta.ino(), inode);
1590        assert_eq!(
1591            std::fs::read(fixture.workdir.join(".mur-etc/passwd")).unwrap(),
1592            b"two\n"
1593        );
1594
1595        fixture.link_at_target(".mur-etc/group");
1596        let error = fixture
1597            .root
1598            .overwrite_in_place(".mur-etc/group", b"x", 0o644)
1599            .unwrap_err();
1600        assert_eq!(refusal_of(&error).reason, RefusalReason::Symlink);
1601        assert_eq!(std::fs::read(&fixture.sentinel).unwrap(), SENTINEL);
1602    }
1603
1604    #[test]
1605    fn list_dir_reports_a_link_inside_as_a_symlink() {
1606        let fixture = fixture();
1607        std::fs::create_dir_all(fixture.workdir.join("tools/a")).unwrap();
1608        symlink(&fixture.outside, fixture.workdir.join("tools/b")).unwrap();
1609        assert_eq!(
1610            fixture.root.list_dir("tools").unwrap().unwrap(),
1611            vec![
1612                ("a".to_string(), EntryKind::Directory),
1613                ("b".to_string(), EntryKind::Symlink)
1614            ]
1615        );
1616    }
1617
1618    #[test]
1619    fn a_refusal_serializes_with_the_wire_names() {
1620        let refusal = WorkdirRefusal {
1621            path: "task.md".into(),
1622            refused_at: "task.md".into(),
1623            operation: WorkdirOperation::CreateDir,
1624            reason: RefusalReason::NotARegularFile,
1625        };
1626        assert_eq!(
1627            serde_json::to_string(&refusal).unwrap(),
1628            r#"{"path":"task.md","refused_at":"task.md","operation":"create_dir","reason":"not_a_regular_file"}"#
1629        );
1630        for (op, wire) in [
1631            (WorkdirOperation::Write, "write"),
1632            (WorkdirOperation::Append, "append"),
1633            (WorkdirOperation::Read, "read"),
1634            (WorkdirOperation::CreateDir, "create_dir"),
1635            (WorkdirOperation::Remove, "remove"),
1636        ] {
1637            assert_eq!(serde_json::to_value(op).unwrap(), serde_json::json!(wire));
1638        }
1639        for (reason, wire) in [
1640            (RefusalReason::Symlink, "symlink"),
1641            (RefusalReason::HardLink, "hard_link"),
1642            (RefusalReason::NotARegularFile, "not_a_regular_file"),
1643            (RefusalReason::InvalidComponent, "invalid_component"),
1644        ] {
1645            assert_eq!(
1646                serde_json::to_value(reason).unwrap(),
1647                serde_json::json!(wire)
1648            );
1649        }
1650    }
1651}