1use 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
35pub const SESSION_ID_PLACEHOLDER: &str = "<session-id>";
42
43pub const RUNTIME_DIR_ENV: &str = "MURMUR_RUNTIME_DIR";
47
48pub const RUNTIME_DIR_GUEST_PATH: &str = "murmur-runtime";
51
52#[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
63pub(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
90pub const SESSIONS_DIR_NAME: &str = "sessions";
92
93pub const RUNTIME_SUBDIR: &str = "runtime";
95
96pub const DELEGATIONS_SUBDIR: &str = "delegations";
101
102pub const SEALED_ETC_SUBDIR: &str = "sealed-etc";
104
105pub const HARNESS_SUBDIR: &str = "harness";
108
109pub const LOCK_FILE_NAME: &str = ".lock";
111
112pub const STALE_AFTER: Duration = Duration::from_secs(60);
115
116pub(crate) const PRIVATE_DIR_MODE: u32 = crate::murmur_home::MURMUR_HOME_DIR_MODE;
118
119#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct RuntimeDirPlacement {
123 pub sessions_dir: PathBuf,
125 pub records_group: PathBuf,
128 pub workdir: PathBuf,
130 pub sessions_inside_workdir: bool,
133 pub records_inside_workdir: bool,
135}
136
137impl RuntimeDirPlacement {
138 #[must_use]
141 pub fn inside_workdir(&self) -> bool {
142 self.sessions_inside_workdir || self.records_inside_workdir
143 }
144
145 #[must_use]
147 pub fn inside(&self) -> Option<&Self> {
148 self.inside_workdir().then_some(self)
149 }
150
151 #[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 #[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 #[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 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 #[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#[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
240pub fn sessions_dir() -> Result<PathBuf, crate::murmur_home::HomeUnusable> {
244 Ok(crate::murmur_home::usable_murmur_home()?.join(SESSIONS_DIR_NAME))
245}
246
247pub 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#[derive(Clone, Debug)]
277pub struct RuntimeDirRoots {
278 pub runtime: WorkdirRoot,
280 pub sealed_etc: WorkdirRoot,
282 pub harness: WorkdirRoot,
284 pub records: Option<WorkdirRoot>,
288 pub temp: Option<WorkdirRoot>,
292 pub home: Option<WorkdirRoot>,
296}
297
298impl RuntimeDirRoots {
299 #[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 #[must_use]
315 pub fn with_records(mut self, records: WorkdirRoot) -> Self {
316 self.records = Some(records);
317 self
318 }
319
320 #[must_use]
322 pub fn with_temp(mut self, temp: WorkdirRoot) -> Self {
323 self.temp = Some(temp);
324 self
325 }
326
327 #[must_use]
329 pub fn with_home(mut self, home: WorkdirRoot) -> Self {
330 self.home = Some(home);
331 self
332 }
333}
334
335pub struct RuntimeDir {
338 session_id: String,
339 sessions: WorkdirRoot,
341 _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 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 #[must_use]
411 pub fn path(&self) -> &Path {
412 self.roots.runtime.path()
413 }
414
415 #[must_use]
417 pub fn roots(&self) -> &RuntimeDirRoots {
418 &self.roots
419 }
420}
421
422impl Drop for RuntimeDir {
423 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#[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 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 pub(crate) fn remove_on_drop(&self) -> RemoveRuntimeDirOnDrop {
466 RemoveRuntimeDirOnDrop(self.clone())
467 }
468}
469
470pub(crate) struct RemoveRuntimeDirOnDrop(SharedRuntimeDir);
472
473impl Drop for RemoveRuntimeDirOnDrop {
474 fn drop(&mut self) {
475 self.0.remove();
476 }
477}
478
479fn 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
497fn 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 #[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}