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