1use 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
33pub const RUNTIME_DIR_ENV: &str = "MURMUR_RUNTIME_DIR";
37
38pub const RUNTIME_DIR_GUEST_PATH: &str = "murmur-runtime";
41
42#[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
53pub const SESSIONS_DIR_NAME: &str = "sessions";
55
56pub const RUNTIME_SUBDIR: &str = "runtime";
58
59pub const SEALED_ETC_SUBDIR: &str = "sealed-etc";
61
62pub const HARNESS_SUBDIR: &str = "harness";
65
66pub const LOCK_FILE_NAME: &str = ".lock";
68
69pub const STALE_AFTER: Duration = Duration::from_secs(60);
72
73const PRIVATE_DIR_MODE: u32 = crate::murmur_home::MURMUR_HOME_DIR_MODE;
75
76#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct RuntimeDirPlacement {
80 pub sessions_dir: PathBuf,
82 pub records_group: PathBuf,
85 pub workdir: PathBuf,
87 pub sessions_inside_workdir: bool,
90 pub records_inside_workdir: bool,
92 pub inside_workdir: bool,
95}
96
97impl RuntimeDirPlacement {
98 #[must_use]
100 pub fn inside(&self) -> Option<&Self> {
101 self.inside_workdir.then_some(self)
102 }
103
104 #[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 #[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 #[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 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 #[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#[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
191pub fn sessions_dir() -> Result<PathBuf, crate::murmur_home::HomeUnusable> {
195 Ok(crate::murmur_home::usable_murmur_home()?.join(SESSIONS_DIR_NAME))
196}
197
198pub 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#[derive(Clone, Debug)]
229pub struct RuntimeDirRoots {
230 pub runtime: WorkdirRoot,
232 pub sealed_etc: WorkdirRoot,
234 pub harness: WorkdirRoot,
236 pub records: Option<WorkdirRoot>,
240}
241
242impl RuntimeDirRoots {
243 #[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 #[must_use]
257 pub fn with_records(mut self, records: WorkdirRoot) -> Self {
258 self.records = Some(records);
259 self
260 }
261}
262
263pub struct RuntimeDir {
266 session_id: String,
267 sessions: WorkdirRoot,
269 _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 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 #[must_use]
340 pub fn path(&self) -> &Path {
341 self.roots.runtime.path()
342 }
343
344 #[must_use]
346 pub fn roots(&self) -> &RuntimeDirRoots {
347 &self.roots
348 }
349}
350
351impl Drop for RuntimeDir {
352 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#[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 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 pub(crate) fn remove_on_drop(&self) -> RemoveRuntimeDirOnDrop {
395 RemoveRuntimeDirOnDrop(self.clone())
396 }
397}
398
399pub(crate) struct RemoveRuntimeDirOnDrop(SharedRuntimeDir);
401
402impl Drop for RemoveRuntimeDirOnDrop {
403 fn drop(&mut self) {
404 self.0.remove();
405 }
406}
407
408fn 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
426fn 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 #[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}