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