Skip to main content

capsule_runtime/
runtime_dir.rs

1//! The session's runtime directory: every file the runtime writes for its own use, outside the
2//! workdir the capsule's commands can write.
3//!
4//! ```text
5//! ~/.murmur/sessions/                 0700
6//! └── <session-id>/                   0700, created at staging, removed when the session ends
7//!     ├── .lock                       flock held from creation until removal
8//!     ├── runtime/                    $MURMUR_RUNTIME_DIR: MURMUR.md, task.md, murmur.yaml,
9//!     │   │                           tools/, plans/ — readable by commands, never writable
10//!     │   └── delegations/            <delegation-id>.txt: the whole answer of a sub-capsule
11//!     │                               whose answer was cut in the report its parent was handed
12//!     ├── sealed-etc/                 the composed root's /etc/passwd and /etc/group
13//!     └── harness/<run-id>/           a `transport: process` harness's files
14//! ```
15//!
16//! On the Full tier a Landlock rule grants `runtime/` read-only to every command; on the Sealed
17//! tier it is also bound read-only into the composed root at its own path. `sealed-etc/` and
18//! `harness/` get no rule, so no command opens them. [`crate::containment::runtime_dir_report`]
19//! says per tier what holds.
20//!
21//! Every runtime read and write beneath the directory goes through a
22//! [`crate::workdir_fs::WorkdirRoot`] sharing the session's refusal latch, so a link is refused
23//! there exactly as it is in the workdir.
24
25use std::os::fd::OwnedFd;
26use std::path::{Path, PathBuf};
27use std::sync::{Arc, Mutex};
28use std::time::{Duration, SystemTime};
29
30use rustix::fs::{AtFlags, FileType, FlockOperation, Mode, OFlags};
31
32use crate::protected_paths::canonicalize_existing_prefix;
33use crate::workdir_fs::WorkdirRoot;
34
35/// The literal segment a reported path carries in place of a real session id, in
36/// `--explain-scope` and `session_start.effective_grants`.
37///
38/// Substituted by the consumer, from `session_start.session_id`. Never expanded here, so the
39/// report `--explain-scope` prints before a session exists and the one the trace carries are
40/// identical.
41pub const SESSION_ID_PLACEHOLDER: &str = "<session-id>";
42
43/// The variable naming the runtime directory in every shell command's, plan shell step's,
44/// `murmur:process` program's and `transport: process` harness's environment. Runtime-owned like
45/// `HOME`: no manifest, override or host value can set it.
46pub const RUNTIME_DIR_ENV: &str = "MURMUR_RUNTIME_DIR";
47
48/// The guest path a `runtime: tool` with no `capabilities.filesystem.scope` finds the runtime
49/// directory at, preopened read-only beside its `"."`.
50pub const RUNTIME_DIR_GUEST_PATH: &str = "murmur-runtime";
51
52/// Whether an artifact's guest gets the read-only [`RUNTIME_DIR_GUEST_PATH`] preopen: a
53/// `runtime: tool` with no `capabilities.filesystem.scope`, which also holds the whole workdir
54/// at `"."`. A driver, a hook and a tool confined to a subtree get none.
55#[must_use]
56pub(crate) fn gets_runtime_dir_preopen(
57    runtime: &murmur_artifact::ArtifactRuntime,
58    declares_filesystem_scope: bool,
59) -> bool {
60    *runtime == murmur_artifact::ArtifactRuntime::Tool && !declares_filesystem_scope
61}
62
63/// The tool names, sorted, of the artifacts in `installed` whose guest is preopened at
64/// [`RUNTIME_DIR_GUEST_PATH`]: the ones [`gets_runtime_dir_preopen`] holds for. `scoped`
65/// answers, by tool name, whether the operator's entry declares a `capabilities.filesystem.scope`.
66pub(crate) fn runtime_dir_preopened_tools<'a>(
67    installed: impl IntoIterator<Item = &'a crate::types::InstalledArtifactSummary>,
68    scoped: impl Fn(&str) -> bool,
69) -> Vec<String> {
70    let mut names: Vec<String> = installed
71        .into_iter()
72        .map(|artifact| {
73            (
74                murmur_artifact::tool_name_of(&artifact.name),
75                &artifact.runtime,
76            )
77        })
78        .filter(|(name, runtime)| gets_runtime_dir_preopen(runtime, scoped(name)))
79        .map(|(name, _)| name.to_string())
80        .collect();
81    names.sort();
82    names.dedup();
83    names
84}
85
86/// `~/.murmur/sessions`, relative to the murmur home.
87pub const SESSIONS_DIR_NAME: &str = "sessions";
88
89/// The directory beneath `<session-id>/` that `$MURMUR_RUNTIME_DIR` names.
90pub const RUNTIME_SUBDIR: &str = "runtime";
91
92/// The directory beneath `runtime/` holding `<delegation-id>.txt`, the whole answer of each
93/// sub-capsule whose answer was cut at [`crate::delegation_plane::MAX_OUTPUT_BYTES`] in what the
94/// parent was handed. Written only by [`crate::delegation::hand_over_answer`], and only for a cut
95/// answer.
96pub const DELEGATIONS_SUBDIR: &str = "delegations";
97
98/// The directory beneath `<session-id>/` holding the composed root's synthetic `/etc` files.
99pub const SEALED_ETC_SUBDIR: &str = "sealed-etc";
100
101/// The directory beneath `<session-id>/` holding one `<run-id>/` per `transport: process`
102/// harness run.
103pub const HARNESS_SUBDIR: &str = "harness";
104
105/// The lock file in `<session-id>/`, `flock`ed by the live session.
106pub const LOCK_FILE_NAME: &str = ".lock";
107
108/// How old a session directory nobody holds must be before staging removes it: long enough to
109/// cover the instant between a directory's creation and its lock.
110pub const STALE_AFTER: Duration = Duration::from_secs(60);
111
112/// The mode every directory beneath `~/.murmur/sessions` is created at.
113pub(crate) const PRIVATE_DIR_MODE: u32 = crate::murmur_home::MURMUR_HOME_DIR_MODE;
114
115/// Where the runtime directory and the records directory lie relative to a workdir, decided
116/// without creating anything.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct RuntimeDirPlacement {
119    /// `~/.murmur/sessions`, as `HOME` names it.
120    pub sessions_dir: PathBuf,
121    /// The records group of [`Self::workdir`], [`crate::records_dir::records_group`]'s answer:
122    /// every session of this workdir root keeps its records directory in it.
123    pub records_group: PathBuf,
124    /// The workdir root it was compared with: `--workdir`, else `<manifest-dir>/workdir`.
125    pub workdir: PathBuf,
126    /// Whether `sessions_dir` lies inside `workdir`, both resolved through their nearest
127    /// existing ancestor. When it does, the capsule's commands can change the runtime directory.
128    pub sessions_inside_workdir: bool,
129    /// Whether this session's records directory lies inside `workdir`, resolved the same way.
130    pub records_inside_workdir: bool,
131}
132
133impl RuntimeDirPlacement {
134    /// Whether either directory lies inside the workdir: the capsule's commands can change a
135    /// directory the runtime keeps out of their reach.
136    #[must_use]
137    pub fn inside_workdir(&self) -> bool {
138        self.sessions_inside_workdir || self.records_inside_workdir
139    }
140
141    /// `Some(self)` when the runtime directory or the records directory lies inside the workdir.
142    #[must_use]
143    pub fn inside(&self) -> Option<&Self> {
144        self.inside_workdir().then_some(self)
145    }
146
147    /// A runtime directory and a records group under `/home/test` and a workdir under `/work`,
148    /// apart, for a test that is not about where the directories lie.
149    #[cfg(test)]
150    pub(crate) fn outside_for_test() -> Self {
151        Self {
152            sessions_dir: PathBuf::from("/home/test/.murmur/sessions"),
153            records_group: PathBuf::from("/home/test/.murmur/records/work"),
154            workdir: PathBuf::from("/work"),
155            sessions_inside_workdir: false,
156            records_inside_workdir: false,
157        }
158    }
159
160    /// The runtime directory's path with the literal `<session-id>` segment, the form
161    /// `--explain-scope` and `session_start.effective_grants` both print.
162    #[must_use]
163    pub fn report_path(&self) -> String {
164        self.sessions_dir
165            .join(SESSION_ID_PLACEHOLDER)
166            .join(RUNTIME_SUBDIR)
167            .display()
168            .to_string()
169    }
170
171    /// The records directory's path with the literal `<session-id>` segment, on
172    /// [`Self::report_path`]'s terms.
173    #[must_use]
174    pub fn records_report_path(&self) -> String {
175        self.records_group
176            .join(SESSION_ID_PLACEHOLDER)
177            .display()
178            .to_string()
179    }
180
181    /// The directory, or both, that lies inside the workdir, and what the capsule's commands can
182    /// therefore change, as `(subject with its verb, what can be changed)`.
183    ///
184    /// Called only for a placement [`Self::inside_workdir`] holds for, so a placement with
185    /// neither directory inside reads as the records directory alone.
186    fn inside_subject(&self) -> (String, &'static str) {
187        debug_assert!(self.inside_workdir());
188        let runtime = format!("the runtime directory {}", self.sessions_dir.display());
189        let records = format!("the records directory {}", self.records_report_path());
190        match (self.sessions_inside_workdir, self.records_inside_workdir) {
191            (true, true) => (
192                format!("{runtime} and {records} lie"),
193                "the files the runtime owns — task.md, MURMUR.md, plans and installed tools — \
194                 and the records it keeps about the session — its trace, logs, eval scores and \
195                 delegation outcome",
196            ),
197            (true, false) => (
198                format!("{runtime} lies"),
199                "the files the runtime owns — task.md, MURMUR.md, plans and installed tools",
200            ),
201            (false, _) => (
202                format!("{records} lies"),
203                "the records the runtime keeps about the session — its trace, logs, eval scores \
204                 and delegation outcome",
205            ),
206        }
207    }
208
209    /// `E-CAP-003`'s reason for a placement inside the workdir.
210    #[must_use]
211    pub(crate) fn inside_shortfall(&self) -> String {
212        let (subject, changes) = self.inside_subject();
213        format!(
214            "{subject} inside the workdir {}, so this capsule's commands can change {changes}, \
215             and no host can back a class above advisory for it; run with a --workdir that does \
216             not contain ~/.murmur",
217            self.workdir.display()
218        )
219    }
220}
221
222/// The `W-SEC-033` line for a runtime or records directory inside the workdir, without a prefix:
223/// shared by `stage_session` and `mur doctor` so the two cannot word it differently.
224#[must_use]
225pub fn runtime_dir_inside_warning(placement: &RuntimeDirPlacement) -> String {
226    use murmur_artifact::{security_warning_link, W_SEC_033};
227    let (subject, changes) = placement.inside_subject();
228    format!(
229        "warning[{W_SEC_033}]: {subject} inside the workdir {}, so this capsule's commands can \
230         change {changes}; run with a --workdir that does not contain ~/.murmur ({})",
231        placement.workdir.display(),
232        security_warning_link(W_SEC_033)
233    )
234}
235
236/// The `sessions/` directory every runtime directory on this host is created in,
237/// `~/.murmur/sessions`, decided without creating anything. A `HOME` that cannot hold it is
238/// refused, never replaced by another location.
239pub fn sessions_dir() -> Result<PathBuf, crate::murmur_home::HomeUnusable> {
240    Ok(crate::murmur_home::usable_murmur_home()?.join(SESSIONS_DIR_NAME))
241}
242
243/// Where the runtime directory and the records directory would lie relative to `workdir`, the
244/// session's workdir root. Creates nothing.
245///
246/// Each path is resolved by canonicalizing its nearest existing ancestor and appending the rest,
247/// so a workdir that does not exist yet, or a home with no `.murmur` in it yet, is judged by
248/// where it would be created.
249pub fn runtime_dir_placement(
250    workdir: &Path,
251) -> Result<RuntimeDirPlacement, crate::murmur_home::HomeUnusable> {
252    let workdir = std::path::absolute(workdir).unwrap_or_else(|_| workdir.to_path_buf());
253    let sessions_dir = sessions_dir()?;
254    let records_group = crate::records_dir::records_group(&workdir)?;
255    let canonical_workdir = canonicalize_existing_prefix(&workdir);
256    let sessions_inside_workdir =
257        canonicalize_existing_prefix(&sessions_dir).starts_with(&canonical_workdir);
258    let records_inside_workdir =
259        canonicalize_existing_prefix(&records_group).starts_with(&canonical_workdir);
260    Ok(RuntimeDirPlacement {
261        sessions_dir,
262        records_group,
263        workdir,
264        sessions_inside_workdir,
265        records_inside_workdir,
266    })
267}
268
269/// The three held directories beneath one session's `<session-id>/`, and the session's records
270/// directory beside them, each sharing the session's refusal latch: every directory outside the
271/// workdir a command is told of or a sealed root is composed from. Cheap to clone.
272#[derive(Clone, Debug)]
273pub struct RuntimeDirRoots {
274    /// `runtime/`, which `$MURMUR_RUNTIME_DIR` names.
275    pub runtime: WorkdirRoot,
276    /// `sealed-etc/`.
277    pub sealed_etc: WorkdirRoot,
278    /// `harness/`.
279    pub harness: WorkdirRoot,
280    /// The session's records directory, which `$MURMUR_RECORDS_DIR` names, given the same
281    /// read-only rule and bind as `runtime/`. `None` for roots held outside a session, where no
282    /// records directory exists.
283    pub records: Option<WorkdirRoot>,
284    /// The session's temporary folder, `tmp/` in its records directory: `$TMPDIR` in every
285    /// command, and `/tmp` wherever the session maps it ([`crate::temp_dir`]). `None` for roots
286    /// held outside a session, whose commands are told of no temporary folder.
287    pub temp: Option<WorkdirRoot>,
288    /// The session's home folder, `home/` in its records directory: `$HOME` in every command,
289    /// granted exactly as [`Self::temp`] is. `None` for roots held outside a session, where no
290    /// command runs.
291    pub home: Option<WorkdirRoot>,
292}
293
294impl RuntimeDirRoots {
295    /// The three roots of a session's runtime directory, for a caller that holds them itself —
296    /// a test, or a plan run outside a session. No records directory.
297    #[must_use]
298    pub fn new(runtime: WorkdirRoot, sealed_etc: WorkdirRoot, harness: WorkdirRoot) -> Self {
299        Self {
300            runtime,
301            sealed_etc,
302            harness,
303            records: None,
304            temp: None,
305            home: None,
306        }
307    }
308
309    /// These roots with `records` as the session's records directory.
310    #[must_use]
311    pub fn with_records(mut self, records: WorkdirRoot) -> Self {
312        self.records = Some(records);
313        self
314    }
315
316    /// These roots with `temp` as the session's temporary folder.
317    #[must_use]
318    pub fn with_temp(mut self, temp: WorkdirRoot) -> Self {
319        self.temp = Some(temp);
320        self
321    }
322
323    /// These roots with `home` as the session's home folder.
324    #[must_use]
325    pub fn with_home(mut self, home: WorkdirRoot) -> Self {
326        self.home = Some(home);
327        self
328    }
329}
330
331/// One session's runtime directory, from its creation at staging to its removal when the value
332/// is dropped.
333pub struct RuntimeDir {
334    session_id: String,
335    /// `~/.murmur/sessions`, held; the session directory is removed through it.
336    sessions: WorkdirRoot,
337    /// `<session-id>/.lock`, `flock`ed for as long as this value lives.
338    _lock: OwnedFd,
339    roots: RuntimeDirRoots,
340}
341
342impl std::fmt::Debug for RuntimeDir {
343    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
344        f.debug_struct("RuntimeDir")
345            .field("path", &self.roots.runtime.path())
346            .finish()
347    }
348}
349
350impl RuntimeDir {
351    /// Creates `<sessions_path>/<session_id>/`, locks it, sweeps stale siblings and creates
352    /// `runtime/`, `sealed-etc/` and `harness/` beneath it at `0700`. Every refusal beneath the
353    /// three lands on `latch_of`'s latch and names its path absolutely.
354    ///
355    /// `sessions_path` is [`sessions_dir`]'s answer, the one the launch was judged against. The
356    /// murmur home is held through [`crate::murmur_home::ensure_murmur_home`] first.
357    pub fn create(
358        sessions_path: &Path,
359        session_id: &str,
360        latch_of: &WorkdirRoot,
361    ) -> Result<Self, String> {
362        let located = |path: &Path, reason: String| format!("{}: {reason}", path.display());
363        let home = crate::murmur_home::ensure_murmur_home()?;
364        if sessions_path.parent() != Some(home.as_path()) {
365            return Err(located(
366                sessions_path,
367                format!("is not beneath {}", home.display()),
368            ));
369        }
370        let sessions_path = sessions_path.to_path_buf();
371        crate::murmur_home::hold_private_dir(&sessions_path)
372            .map_err(|reason| located(&sessions_path, reason))?;
373        let sessions = WorkdirRoot::open(&sessions_path).map_err(|error| error.to_string())?;
374        sweep_stale(&sessions, SystemTime::now());
375
376        let session_path = sessions_path.join(session_id);
377        crate::murmur_home::hold_private_dir(&session_path)
378            .map_err(|reason| located(&session_path, reason))?;
379        let session_dir_root = sessions
380            .open_beneath(session_id)
381            .map_err(|error| error.to_string())?
382            .ok_or_else(|| located(&session_path, "vanished after its creation".to_string()))?;
383        let lock = take_lock(&session_dir_root, &session_path)?;
384        for sub in [RUNTIME_SUBDIR, SEALED_ETC_SUBDIR, HARNESS_SUBDIR] {
385            session_dir_root
386                .create_dir_all(sub, PRIVATE_DIR_MODE)
387                .map_err(|error| error.to_string())?;
388        }
389        let open = |sub: &str| {
390            WorkdirRoot::open_sharing_latch_absolute(&session_path.join(sub), latch_of)
391                .map_err(|error| error.to_string())
392        };
393        Ok(Self {
394            session_id: session_id.to_string(),
395            sessions,
396            _lock: lock,
397            roots: RuntimeDirRoots::new(
398                open(RUNTIME_SUBDIR)?,
399                open(SEALED_ETC_SUBDIR)?,
400                open(HARNESS_SUBDIR)?,
401            ),
402        })
403    }
404
405    /// `runtime/`'s absolute path, the value of `$MURMUR_RUNTIME_DIR`.
406    #[must_use]
407    pub fn path(&self) -> &Path {
408        self.roots.runtime.path()
409    }
410
411    /// The three held directories.
412    #[must_use]
413    pub fn roots(&self) -> &RuntimeDirRoots {
414        &self.roots
415    }
416}
417
418impl Drop for RuntimeDir {
419    /// Removes `<session-id>/` by descriptor from the held `sessions/`, before the lock is
420    /// released, so no sweep can race the removal.
421    fn drop(&mut self) {
422        if let Err(error) = self.sessions.remove_dir_all(&self.session_id) {
423            if !error.is_not_found() {
424                crate::runtime_err!(
425                    "[capsule-runtime] warning: failed to remove the runtime directory {}: {error}",
426                    self.sessions.host_path(&self.session_id).display()
427                );
428            }
429        }
430    }
431}
432
433/// The session's [`RuntimeDir`], shared between `launch` and the session's `ShellEnforcement`
434/// so the process exits that run no destructor can still remove it. Cheap to clone; every clone
435/// removes the one directory.
436#[derive(Clone, Default)]
437pub(crate) struct SharedRuntimeDir(Arc<Mutex<Option<RuntimeDir>>>);
438
439impl std::fmt::Debug for SharedRuntimeDir {
440    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
441        f.write_str("SharedRuntimeDir")
442    }
443}
444
445impl SharedRuntimeDir {
446    pub(crate) fn new(dir: Option<RuntimeDir>) -> Self {
447        Self(Arc::new(Mutex::new(dir)))
448    }
449
450    /// Removes the runtime directory now. Later calls do nothing.
451    pub(crate) fn remove(&self) {
452        let taken = self
453            .0
454            .lock()
455            .unwrap_or_else(std::sync::PoisonError::into_inner)
456            .take();
457        drop(taken);
458    }
459
460    /// A guard that calls [`Self::remove`] when dropped, whatever other clones still exist.
461    pub(crate) fn remove_on_drop(&self) -> RemoveRuntimeDirOnDrop {
462        RemoveRuntimeDirOnDrop(self.clone())
463    }
464}
465
466/// Removes a [`SharedRuntimeDir`] when dropped.
467pub(crate) struct RemoveRuntimeDirOnDrop(SharedRuntimeDir);
468
469impl Drop for RemoveRuntimeDirOnDrop {
470    fn drop(&mut self) {
471        self.0.remove();
472    }
473}
474
475/// Opens (creating it at `0600`) and exclusively locks `<session>/.lock`, following no link.
476fn take_lock(session_dir_root: &WorkdirRoot, session_path: &Path) -> Result<OwnedFd, String> {
477    let lock_path = session_path.join(LOCK_FILE_NAME);
478    let dir = session_dir_root
479        .dir_fd()
480        .map_err(|error| format!("{}: {error}", session_path.display()))?;
481    let fd = rustix::fs::openat(
482        &dir,
483        LOCK_FILE_NAME,
484        OFlags::RDWR | OFlags::CREATE | OFlags::NOFOLLOW | OFlags::CLOEXEC,
485        Mode::from_raw_mode(crate::murmur_home::PRIVATE_FILE_MODE as rustix::fs::RawMode),
486    )
487    .map_err(|errno| format!("{}: {}", lock_path.display(), std::io::Error::from(errno)))?;
488    rustix::fs::flock(&fd, FlockOperation::NonBlockingLockExclusive)
489        .map_err(|errno| format!("{}: {}", lock_path.display(), std::io::Error::from(errno)))?;
490    Ok(fd)
491}
492
493/// Removes every entry of `sessions` no live session holds and older than [`STALE_AFTER`]: a
494/// directory whose `.lock` can be taken and was last modified before then, and any entry with no
495/// `.lock` last modified before then. A live session's lock is never free, so its directory is
496/// never removed.
497fn sweep_stale(sessions: &WorkdirRoot, now: SystemTime) {
498    let Ok(Some(entries)) = sessions.list_dir("") else {
499        return;
500    };
501    let Ok(dir) = sessions.dir_fd() else {
502        return;
503    };
504    let older_than_floor = |stat: &rustix::fs::Stat| {
505        let modified = SystemTime::UNIX_EPOCH + Duration::from_secs(stat.st_mtime.max(0) as u64);
506        now.duration_since(modified)
507            .is_ok_and(|age| age > STALE_AFTER)
508    };
509    for (name, _) in entries {
510        let Ok(entry_stat) = rustix::fs::statat(&dir, name.as_str(), AtFlags::SYMLINK_NOFOLLOW)
511        else {
512            continue;
513        };
514        if FileType::from_raw_mode(entry_stat.st_mode) != FileType::Directory {
515            if older_than_floor(&entry_stat) {
516                let _ = sessions.remove_file(&name);
517            }
518            continue;
519        }
520        let lock = rustix::fs::openat(&dir, name.as_str(), DIR_NOFOLLOW, Mode::empty()).and_then(
521            |session| {
522                rustix::fs::openat(
523                    &session,
524                    LOCK_FILE_NAME,
525                    OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK,
526                    Mode::empty(),
527                )
528            },
529        );
530        match lock {
531            Ok(lock) => {
532                let free = rustix::fs::flock(&lock, FlockOperation::NonBlockingLockExclusive);
533                let stale = rustix::fs::fstat(&lock).is_ok_and(|stat| older_than_floor(&stat));
534                if free.is_ok() && stale {
535                    let _ = sessions.remove_dir_all(&name);
536                }
537            }
538            Err(rustix::io::Errno::NOENT) if older_than_floor(&entry_stat) => {
539                let _ = sessions.remove_dir_all(&name);
540            }
541            Err(_) => {}
542        }
543    }
544}
545
546const DIR_NOFOLLOW: OFlags = OFlags::RDONLY
547    .union(OFlags::DIRECTORY)
548    .union(OFlags::NOFOLLOW)
549    .union(OFlags::CLOEXEC);
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554    use std::os::unix::fs::PermissionsExt;
555
556    fn set_mtime(path: &Path, age: Duration) {
557        let file = std::fs::File::open(path).unwrap();
558        file.set_modified(SystemTime::now() - age).unwrap();
559    }
560
561    #[test]
562    fn runtime_dir_sweep_removes_unlocked_stale_and_keeps_locked_and_recent() {
563        let temp = tempfile::tempdir().unwrap();
564        let sessions = temp.path();
565        let make = |name: &str, age: Duration| {
566            let dir = sessions.join(name);
567            std::fs::create_dir_all(dir.join("runtime")).unwrap();
568            std::fs::write(dir.join(LOCK_FILE_NAME), b"").unwrap();
569            set_mtime(&dir.join(LOCK_FILE_NAME), age);
570            dir
571        };
572        let stale = make("ses_stale", Duration::from_secs(61));
573        let locked = make("ses_locked", Duration::from_secs(61));
574        let recent = make("ses_recent", Duration::from_secs(10));
575        let unlocked_dir = sessions.join("ses_nolock");
576        std::fs::create_dir_all(&unlocked_dir).unwrap();
577        set_mtime(&unlocked_dir, Duration::from_secs(61));
578
579        let held = std::fs::File::open(locked.join(LOCK_FILE_NAME)).unwrap();
580        rustix::fs::flock(&held, FlockOperation::NonBlockingLockExclusive).unwrap();
581
582        let root = WorkdirRoot::open(sessions).unwrap();
583        sweep_stale(&root, SystemTime::now());
584
585        assert!(
586            !stale.exists(),
587            "an unlocked 61-second-old directory is removed"
588        );
589        assert!(
590            !unlocked_dir.exists(),
591            "an old directory with no lock is removed"
592        );
593        assert!(locked.exists(), "a locked directory is kept");
594        assert!(recent.exists(), "a 10-second-old directory is kept");
595        drop(held);
596    }
597
598    #[test]
599    fn runtime_dir_placement_resolves_through_the_nearest_existing_ancestor() {
600        let temp = tempfile::tempdir().unwrap();
601        let real = temp.path().join("real");
602        std::fs::create_dir_all(&real).unwrap();
603        let link = temp.path().join("link");
604        std::os::unix::fs::symlink(&real, &link).unwrap();
605
606        let resolved = canonicalize_existing_prefix(&link.join("not-yet").join("deeper"));
607        assert_eq!(
608            resolved,
609            real.canonicalize().unwrap().join("not-yet").join("deeper")
610        );
611        assert!(canonicalize_existing_prefix(&link.join(".murmur/sessions"))
612            .starts_with(canonicalize_existing_prefix(&real)));
613    }
614
615    /// A workdir root that holds the murmur home holds both directories; one that holds
616    /// `~/.murmur/records` alone holds the records directory alone; one elsewhere holds neither.
617    #[test]
618    fn runtime_dir_placement_judges_the_records_directory_too() {
619        let home = tempfile::tempdir().unwrap();
620        crate::murmur_home::run_with_home(
621            "runtime_dir::tests::runtime_dir_placement_records_inner",
622            home.path(),
623        );
624    }
625
626    #[test]
627    #[ignore = "run by runtime_dir_placement_judges_the_records_directory_too"]
628    fn runtime_dir_placement_records_inner() {
629        if !crate::murmur_home::in_scratch_home() {
630            return;
631        }
632        let home = PathBuf::from(std::env::var_os("HOME").unwrap());
633        let both = runtime_dir_placement(&home).unwrap();
634        assert!(both.sessions_inside_workdir && both.records_inside_workdir);
635        assert!(both.inside_workdir());
636        assert!(runtime_dir_inside_warning(&both).contains("the records directory"));
637
638        let records = home
639            .join(".murmur")
640            .join(crate::records_dir::RECORDS_DIR_NAME);
641        let records_only = runtime_dir_placement(&records).unwrap();
642        assert!(!records_only.sessions_inside_workdir);
643        assert!(records_only.records_inside_workdir && records_only.inside_workdir());
644        let reason = records_only.inside_shortfall();
645        assert!(reason.starts_with("the records directory "), "{reason}");
646        assert!(reason.contains("run with a --workdir that does not contain ~/.murmur"));
647
648        let elsewhere = runtime_dir_placement(&home.join("project/workdir")).unwrap();
649        assert!(!elsewhere.inside_workdir());
650        assert_eq!(
651            elsewhere.records_report_path(),
652            crate::records_dir::records_group(&home.join("project/workdir"))
653                .unwrap()
654                .join("<session-id>")
655                .display()
656                .to_string()
657        );
658        assert!(!home.join(".murmur").exists(), "judging creates nothing");
659    }
660
661    #[test]
662    fn runtime_dir_lock_is_exclusive_across_open_file_descriptions() {
663        let temp = tempfile::tempdir().unwrap();
664        let session = temp.path().join("ses_x");
665        std::fs::create_dir_all(&session).unwrap();
666        let root = WorkdirRoot::open(&session).unwrap();
667        let held = take_lock(&root, &session).unwrap();
668        let mode = std::fs::metadata(session.join(LOCK_FILE_NAME))
669            .unwrap()
670            .permissions()
671            .mode()
672            & 0o777;
673        assert_eq!(mode & 0o077, 0, "the lock file is owner-only");
674        assert!(
675            take_lock(&root, &session).is_err(),
676            "a second lock is refused"
677        );
678        drop(held);
679        assert!(take_lock(&root, &session).is_ok());
680    }
681}