use std::os::fd::OwnedFd;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime};
use rustix::fs::{AtFlags, FileType, FlockOperation, Mode, OFlags};
use crate::protected_paths::canonicalize_existing_prefix;
use crate::workdir_fs::WorkdirRoot;
pub const SESSION_ID_PLACEHOLDER: &str = "<session-id>";
pub const RUNTIME_DIR_ENV: &str = "MURMUR_RUNTIME_DIR";
pub const RUNTIME_DIR_GUEST_PATH: &str = "murmur-runtime";
#[must_use]
pub(crate) fn gets_runtime_dir_preopen(
runtime: &murmur_artifact::ArtifactRuntime,
declares_filesystem_scope: bool,
) -> bool {
*runtime == murmur_artifact::ArtifactRuntime::Tool && !declares_filesystem_scope
}
pub(crate) fn runtime_dir_preopened_tools<'a>(
installed: impl IntoIterator<Item = &'a crate::types::InstalledArtifactSummary>,
scoped: impl Fn(&str) -> bool,
) -> Vec<String> {
let mut names: Vec<String> = installed
.into_iter()
.map(|artifact| {
(
murmur_artifact::tool_name_of(&artifact.name),
&artifact.runtime,
)
})
.filter(|(name, runtime)| gets_runtime_dir_preopen(runtime, scoped(name)))
.map(|(name, _)| name.to_string())
.collect();
names.sort();
names.dedup();
names
}
pub const SESSIONS_DIR_NAME: &str = "sessions";
pub const RUNTIME_SUBDIR: &str = "runtime";
pub const DELEGATIONS_SUBDIR: &str = "delegations";
pub const SEALED_ETC_SUBDIR: &str = "sealed-etc";
pub const HARNESS_SUBDIR: &str = "harness";
pub const LOCK_FILE_NAME: &str = ".lock";
pub const STALE_AFTER: Duration = Duration::from_secs(60);
pub(crate) const PRIVATE_DIR_MODE: u32 = crate::murmur_home::MURMUR_HOME_DIR_MODE;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeDirPlacement {
pub sessions_dir: PathBuf,
pub records_group: PathBuf,
pub workdir: PathBuf,
pub sessions_inside_workdir: bool,
pub records_inside_workdir: bool,
}
impl RuntimeDirPlacement {
#[must_use]
pub fn inside_workdir(&self) -> bool {
self.sessions_inside_workdir || self.records_inside_workdir
}
#[must_use]
pub fn inside(&self) -> Option<&Self> {
self.inside_workdir().then_some(self)
}
#[cfg(test)]
pub(crate) fn outside_for_test() -> Self {
Self {
sessions_dir: PathBuf::from("/home/test/.murmur/sessions"),
records_group: PathBuf::from("/home/test/.murmur/records/work"),
workdir: PathBuf::from("/work"),
sessions_inside_workdir: false,
records_inside_workdir: false,
}
}
#[must_use]
pub fn report_path(&self) -> String {
self.sessions_dir
.join(SESSION_ID_PLACEHOLDER)
.join(RUNTIME_SUBDIR)
.display()
.to_string()
}
#[must_use]
pub fn records_report_path(&self) -> String {
self.records_group
.join(SESSION_ID_PLACEHOLDER)
.display()
.to_string()
}
fn inside_subject(&self) -> (String, &'static str) {
debug_assert!(self.inside_workdir());
let runtime = format!("the runtime directory {}", self.sessions_dir.display());
let records = format!("the records directory {}", self.records_report_path());
match (self.sessions_inside_workdir, self.records_inside_workdir) {
(true, true) => (
format!("{runtime} and {records} lie"),
"the files the runtime owns — task.md, MURMUR.md, plans and installed tools — \
and the records it keeps about the session — its trace, logs, eval scores and \
delegation outcome",
),
(true, false) => (
format!("{runtime} lies"),
"the files the runtime owns — task.md, MURMUR.md, plans and installed tools",
),
(false, _) => (
format!("{records} lies"),
"the records the runtime keeps about the session — its trace, logs, eval scores \
and delegation outcome",
),
}
}
#[must_use]
pub(crate) fn inside_shortfall(&self) -> String {
let (subject, changes) = self.inside_subject();
format!(
"{subject} inside the workdir {}, so this capsule's commands can change {changes}, \
and no host can back a class above advisory for it; run with a --workdir that does \
not contain ~/.murmur",
self.workdir.display()
)
}
}
#[must_use]
pub fn runtime_dir_inside_warning(placement: &RuntimeDirPlacement) -> String {
use murmur_artifact::{security_warning_link, W_SEC_033};
let (subject, changes) = placement.inside_subject();
format!(
"warning[{W_SEC_033}]: {subject} inside the workdir {}, so this capsule's commands can \
change {changes}; run with a --workdir that does not contain ~/.murmur ({})",
placement.workdir.display(),
security_warning_link(W_SEC_033)
)
}
pub fn sessions_dir() -> Result<PathBuf, crate::murmur_home::HomeUnusable> {
Ok(crate::murmur_home::usable_murmur_home()?.join(SESSIONS_DIR_NAME))
}
pub fn runtime_dir_placement(
workdir: &Path,
) -> Result<RuntimeDirPlacement, crate::murmur_home::HomeUnusable> {
let workdir = std::path::absolute(workdir).unwrap_or_else(|_| workdir.to_path_buf());
let sessions_dir = sessions_dir()?;
let records_group = crate::records_dir::records_group(&workdir)?;
let canonical_workdir = canonicalize_existing_prefix(&workdir);
let sessions_inside_workdir =
canonicalize_existing_prefix(&sessions_dir).starts_with(&canonical_workdir);
let records_inside_workdir =
canonicalize_existing_prefix(&records_group).starts_with(&canonical_workdir);
Ok(RuntimeDirPlacement {
sessions_dir,
records_group,
workdir,
sessions_inside_workdir,
records_inside_workdir,
})
}
#[derive(Clone, Debug)]
pub struct RuntimeDirRoots {
pub runtime: WorkdirRoot,
pub sealed_etc: WorkdirRoot,
pub harness: WorkdirRoot,
pub records: Option<WorkdirRoot>,
pub temp: Option<WorkdirRoot>,
pub home: Option<WorkdirRoot>,
}
impl RuntimeDirRoots {
#[must_use]
pub fn new(runtime: WorkdirRoot, sealed_etc: WorkdirRoot, harness: WorkdirRoot) -> Self {
Self {
runtime,
sealed_etc,
harness,
records: None,
temp: None,
home: None,
}
}
#[must_use]
pub fn with_records(mut self, records: WorkdirRoot) -> Self {
self.records = Some(records);
self
}
#[must_use]
pub fn with_temp(mut self, temp: WorkdirRoot) -> Self {
self.temp = Some(temp);
self
}
#[must_use]
pub fn with_home(mut self, home: WorkdirRoot) -> Self {
self.home = Some(home);
self
}
}
pub struct RuntimeDir {
session_id: String,
sessions: WorkdirRoot,
_lock: OwnedFd,
roots: RuntimeDirRoots,
}
impl std::fmt::Debug for RuntimeDir {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RuntimeDir")
.field("path", &self.roots.runtime.path())
.finish()
}
}
impl RuntimeDir {
pub fn create(
sessions_path: &Path,
session_id: &str,
latch_of: &WorkdirRoot,
) -> Result<Self, String> {
let located = |path: &Path, reason: String| format!("{}: {reason}", path.display());
let home = crate::murmur_home::ensure_murmur_home()?;
if sessions_path.parent() != Some(home.as_path()) {
return Err(located(
sessions_path,
format!("is not beneath {}", home.display()),
));
}
let sessions_path = sessions_path.to_path_buf();
crate::murmur_home::hold_private_dir(&sessions_path)
.map_err(|reason| located(&sessions_path, reason))?;
let sessions = WorkdirRoot::open(&sessions_path).map_err(|error| error.to_string())?;
sweep_stale(&sessions, SystemTime::now());
let session_path = sessions_path.join(session_id);
crate::murmur_home::hold_private_dir(&session_path)
.map_err(|reason| located(&session_path, reason))?;
let session_dir_root = sessions
.open_beneath(session_id)
.map_err(|error| error.to_string())?
.ok_or_else(|| located(&session_path, "vanished after its creation".to_string()))?;
let lock = take_lock(&session_dir_root, &session_path)?;
for sub in [RUNTIME_SUBDIR, SEALED_ETC_SUBDIR, HARNESS_SUBDIR] {
session_dir_root
.create_dir_all(sub, PRIVATE_DIR_MODE)
.map_err(|error| error.to_string())?;
}
let open = |sub: &str| {
WorkdirRoot::open_sharing_latch_absolute(&session_path.join(sub), latch_of)
.map_err(|error| error.to_string())
};
Ok(Self {
session_id: session_id.to_string(),
sessions,
_lock: lock,
roots: RuntimeDirRoots::new(
open(RUNTIME_SUBDIR)?,
open(SEALED_ETC_SUBDIR)?,
open(HARNESS_SUBDIR)?,
),
})
}
#[must_use]
pub fn path(&self) -> &Path {
self.roots.runtime.path()
}
#[must_use]
pub fn roots(&self) -> &RuntimeDirRoots {
&self.roots
}
}
impl Drop for RuntimeDir {
fn drop(&mut self) {
if let Err(error) = self.sessions.remove_dir_all(&self.session_id) {
if !error.is_not_found() {
crate::runtime_err!(
"[capsule-runtime] warning: failed to remove the runtime directory {}: {error}",
self.sessions.host_path(&self.session_id).display()
);
}
}
}
}
#[derive(Clone, Default)]
pub(crate) struct SharedRuntimeDir(Arc<Mutex<Option<RuntimeDir>>>);
impl std::fmt::Debug for SharedRuntimeDir {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("SharedRuntimeDir")
}
}
impl SharedRuntimeDir {
pub(crate) fn new(dir: Option<RuntimeDir>) -> Self {
Self(Arc::new(Mutex::new(dir)))
}
pub(crate) fn remove(&self) {
let taken = self
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
drop(taken);
}
pub(crate) fn remove_on_drop(&self) -> RemoveRuntimeDirOnDrop {
RemoveRuntimeDirOnDrop(self.clone())
}
}
pub(crate) struct RemoveRuntimeDirOnDrop(SharedRuntimeDir);
impl Drop for RemoveRuntimeDirOnDrop {
fn drop(&mut self) {
self.0.remove();
}
}
fn take_lock(session_dir_root: &WorkdirRoot, session_path: &Path) -> Result<OwnedFd, String> {
let lock_path = session_path.join(LOCK_FILE_NAME);
let dir = session_dir_root
.dir_fd()
.map_err(|error| format!("{}: {error}", session_path.display()))?;
let fd = rustix::fs::openat(
&dir,
LOCK_FILE_NAME,
OFlags::RDWR | OFlags::CREATE | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::from_raw_mode(crate::murmur_home::PRIVATE_FILE_MODE as rustix::fs::RawMode),
)
.map_err(|errno| format!("{}: {}", lock_path.display(), std::io::Error::from(errno)))?;
rustix::fs::flock(&fd, FlockOperation::NonBlockingLockExclusive)
.map_err(|errno| format!("{}: {}", lock_path.display(), std::io::Error::from(errno)))?;
Ok(fd)
}
fn sweep_stale(sessions: &WorkdirRoot, now: SystemTime) {
let Ok(Some(entries)) = sessions.list_dir("") else {
return;
};
let Ok(dir) = sessions.dir_fd() else {
return;
};
let older_than_floor = |stat: &rustix::fs::Stat| {
let modified = SystemTime::UNIX_EPOCH + Duration::from_secs(stat.st_mtime.max(0) as u64);
now.duration_since(modified)
.is_ok_and(|age| age > STALE_AFTER)
};
for (name, _) in entries {
let Ok(entry_stat) = rustix::fs::statat(&dir, name.as_str(), AtFlags::SYMLINK_NOFOLLOW)
else {
continue;
};
if FileType::from_raw_mode(entry_stat.st_mode) != FileType::Directory {
if older_than_floor(&entry_stat) {
let _ = sessions.remove_file(&name);
}
continue;
}
let lock = rustix::fs::openat(&dir, name.as_str(), DIR_NOFOLLOW, Mode::empty()).and_then(
|session| {
rustix::fs::openat(
&session,
LOCK_FILE_NAME,
OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK,
Mode::empty(),
)
},
);
match lock {
Ok(lock) => {
let free = rustix::fs::flock(&lock, FlockOperation::NonBlockingLockExclusive);
let stale = rustix::fs::fstat(&lock).is_ok_and(|stat| older_than_floor(&stat));
if free.is_ok() && stale {
let _ = sessions.remove_dir_all(&name);
}
}
Err(rustix::io::Errno::NOENT) if older_than_floor(&entry_stat) => {
let _ = sessions.remove_dir_all(&name);
}
Err(_) => {}
}
}
}
const DIR_NOFOLLOW: OFlags = OFlags::RDONLY
.union(OFlags::DIRECTORY)
.union(OFlags::NOFOLLOW)
.union(OFlags::CLOEXEC);
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
fn set_mtime(path: &Path, age: Duration) {
let file = std::fs::File::open(path).unwrap();
file.set_modified(SystemTime::now() - age).unwrap();
}
#[test]
fn runtime_dir_sweep_removes_unlocked_stale_and_keeps_locked_and_recent() {
let temp = tempfile::tempdir().unwrap();
let sessions = temp.path();
let make = |name: &str, age: Duration| {
let dir = sessions.join(name);
std::fs::create_dir_all(dir.join("runtime")).unwrap();
std::fs::write(dir.join(LOCK_FILE_NAME), b"").unwrap();
set_mtime(&dir.join(LOCK_FILE_NAME), age);
dir
};
let stale = make("ses_stale", Duration::from_secs(61));
let locked = make("ses_locked", Duration::from_secs(61));
let recent = make("ses_recent", Duration::from_secs(10));
let unlocked_dir = sessions.join("ses_nolock");
std::fs::create_dir_all(&unlocked_dir).unwrap();
set_mtime(&unlocked_dir, Duration::from_secs(61));
let held = std::fs::File::open(locked.join(LOCK_FILE_NAME)).unwrap();
rustix::fs::flock(&held, FlockOperation::NonBlockingLockExclusive).unwrap();
let root = WorkdirRoot::open(sessions).unwrap();
sweep_stale(&root, SystemTime::now());
assert!(
!stale.exists(),
"an unlocked 61-second-old directory is removed"
);
assert!(
!unlocked_dir.exists(),
"an old directory with no lock is removed"
);
assert!(locked.exists(), "a locked directory is kept");
assert!(recent.exists(), "a 10-second-old directory is kept");
drop(held);
}
#[test]
fn runtime_dir_placement_resolves_through_the_nearest_existing_ancestor() {
let temp = tempfile::tempdir().unwrap();
let real = temp.path().join("real");
std::fs::create_dir_all(&real).unwrap();
let link = temp.path().join("link");
std::os::unix::fs::symlink(&real, &link).unwrap();
let resolved = canonicalize_existing_prefix(&link.join("not-yet").join("deeper"));
assert_eq!(
resolved,
real.canonicalize().unwrap().join("not-yet").join("deeper")
);
assert!(canonicalize_existing_prefix(&link.join(".murmur/sessions"))
.starts_with(canonicalize_existing_prefix(&real)));
}
#[test]
fn runtime_dir_placement_judges_the_records_directory_too() {
let home = tempfile::tempdir().unwrap();
crate::murmur_home::run_with_home(
"runtime_dir::tests::runtime_dir_placement_records_inner",
home.path(),
);
}
#[test]
#[ignore = "run by runtime_dir_placement_judges_the_records_directory_too"]
fn runtime_dir_placement_records_inner() {
if !crate::murmur_home::in_scratch_home() {
return;
}
let home = PathBuf::from(std::env::var_os("HOME").unwrap());
let both = runtime_dir_placement(&home).unwrap();
assert!(both.sessions_inside_workdir && both.records_inside_workdir);
assert!(both.inside_workdir());
assert!(runtime_dir_inside_warning(&both).contains("the records directory"));
let records = home
.join(".murmur")
.join(crate::records_dir::RECORDS_DIR_NAME);
let records_only = runtime_dir_placement(&records).unwrap();
assert!(!records_only.sessions_inside_workdir);
assert!(records_only.records_inside_workdir && records_only.inside_workdir());
let reason = records_only.inside_shortfall();
assert!(reason.starts_with("the records directory "), "{reason}");
assert!(reason.contains("run with a --workdir that does not contain ~/.murmur"));
let elsewhere = runtime_dir_placement(&home.join("project/workdir")).unwrap();
assert!(!elsewhere.inside_workdir());
assert_eq!(
elsewhere.records_report_path(),
crate::records_dir::records_group(&home.join("project/workdir"))
.unwrap()
.join("<session-id>")
.display()
.to_string()
);
assert!(!home.join(".murmur").exists(), "judging creates nothing");
}
#[test]
fn runtime_dir_lock_is_exclusive_across_open_file_descriptions() {
let temp = tempfile::tempdir().unwrap();
let session = temp.path().join("ses_x");
std::fs::create_dir_all(&session).unwrap();
let root = WorkdirRoot::open(&session).unwrap();
let held = take_lock(&root, &session).unwrap();
let mode = std::fs::metadata(session.join(LOCK_FILE_NAME))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode & 0o077, 0, "the lock file is owner-only");
assert!(
take_lock(&root, &session).is_err(),
"a second lock is refused"
);
drop(held);
assert!(take_lock(&root, &session).is_ok());
}
}