use std::{
collections::BTreeMap,
path::{Path, PathBuf},
time::Duration,
};
use serde::{Deserialize, Serialize};
const RUNNING_DIR: &str = "running";
const RUNNING_DIR_MODE: u32 = 0o700;
const RECORD_FILE_MODE: u32 = 0o600;
const CONTROL_TOKEN_EXTENSION: &str = "control";
const PROBE_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
const PROBE_READ_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunningRecord {
pub session_id: String,
pub url: Option<String>,
pub pid: u32,
pub process_start: String,
pub capsule_name: String,
pub capsule_version: String,
pub workdir: PathBuf,
pub records_dir: PathBuf,
pub outlives_launcher: bool,
pub started_at: String,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "door_token_field"
)]
pub door_token: Option<crate::door_auth::DoorToken>,
#[serde(
default,
skip_serializing_if = "BTreeMap::is_empty",
with = "door_credentials_field"
)]
pub credentials: BTreeMap<String, crate::door_auth::DoorToken>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub formation_id: Option<crate::formation::FormationId>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "member_name_field::deserialize"
)]
pub formation_member: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub formation_entry: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub formation_lifeline: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub formation_launcher: Option<ProcessIdentity>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spawned_by: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub unconfined: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProcessIdentity {
pub pid: u32,
pub process_start: String,
}
impl ProcessIdentity {
pub fn of_this_process() -> Option<Self> {
let pid = std::process::id();
Some(Self {
pid,
process_start: process_start_token(pid)?,
})
}
}
mod door_token_field {
use crate::door_auth::DoorToken;
use serde::{Deserialize, Deserializer, Serializer};
pub(super) fn serialize<S: Serializer>(
token: &Option<DoorToken>,
serializer: S,
) -> Result<S::Ok, S::Error> {
match token {
Some(token) => serializer.serialize_some(token.expose()),
None => serializer.serialize_none(),
}
}
pub(super) fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Option<DoorToken>, D::Error> {
Ok(Option::<String>::deserialize(deserializer)?.map(DoorToken::new))
}
}
mod door_credentials_field {
use std::collections::BTreeMap;
use crate::door_auth::DoorToken;
use serde::{ser::SerializeMap, Deserialize, Deserializer, Serializer};
pub(super) fn serialize<S: Serializer>(
credentials: &BTreeMap<String, DoorToken>,
serializer: S,
) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(Some(credentials.len()))?;
for (name, token) in credentials {
map.serialize_entry(name, token.expose())?;
}
map.end()
}
pub(super) fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<BTreeMap<String, DoorToken>, D::Error> {
Ok(BTreeMap::<String, String>::deserialize(deserializer)?
.into_iter()
.map(|(name, token)| (name, DoorToken::new(token)))
.collect())
}
}
mod member_name_field {
use serde::{de::Error, Deserialize, Deserializer};
pub(super) fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Option<String>, D::Error> {
let Some(name) = Option::<String>::deserialize(deserializer)? else {
return Ok(None);
};
match murmur_artifact::member_name_format_error(&name) {
Some(reason) => Err(D::Error::custom(format!(
"formation_member {name:?} is not a member name: it {reason}"
))),
None => Ok(Some(name)),
}
}
}
pub fn running_dir() -> Result<PathBuf, String> {
let dir = crate::murmur_home::ensure_murmur_home()?.join(RUNNING_DIR);
crate::state_store::ensure_private_dir(&dir, RUNNING_DIR_MODE)
.map_err(|reason| format!("{}: {reason}", dir.display()))?;
Ok(dir)
}
pub fn running_dir_location() -> Result<PathBuf, String> {
Ok(crate::state_store::murmur_home_dir()?.join(RUNNING_DIR))
}
pub fn record_location(session_id: &str) -> Result<PathBuf, String> {
Ok(record_path(&running_dir_location()?, session_id))
}
#[derive(Debug, Default)]
pub struct RecordScan {
pub records: Vec<RunningRecord>,
pub older_mur: Vec<PathBuf>,
pub unreadable: Vec<PathBuf>,
}
#[derive(Debug)]
enum RecordRead {
Record(Box<RunningRecord>),
OlderMur,
Unreadable,
}
pub fn scan(dir: &Path) -> Result<RecordScan, String> {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(RecordScan::default()),
Err(err) => return Err(format!("{}: {err}", dir.display())),
};
let mut scan = RecordScan::default();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
continue;
}
match read_record(&path) {
RecordRead::Record(record) => scan.records.push(*record),
RecordRead::OlderMur => scan.older_mur.push(path),
RecordRead::Unreadable => scan.unreadable.push(path),
}
}
scan.records
.sort_by(|left, right| right.session_id.cmp(&left.session_id));
scan.older_mur.sort_by(|left, right| right.cmp(left));
Ok(scan)
}
pub fn list() -> Result<Vec<RunningRecord>, String> {
list_reporting().map(|listing| listing.records)
}
#[derive(Debug, Default)]
pub struct RecordListing {
pub records: Vec<RunningRecord>,
pub older_mur: Vec<PathBuf>,
pub removed_unreadable: Vec<PathBuf>,
}
pub fn list_reporting() -> Result<RecordListing, String> {
list_pruning(&running_dir()?)
}
fn list_pruning(dir: &Path) -> Result<RecordListing, String> {
let scan = scan(dir)?;
let removed_unreadable = scan
.unreadable
.into_iter()
.filter(|path| std::fs::remove_file(path).is_ok())
.collect();
Ok(RecordListing {
records: scan.records,
older_mur: scan.older_mur,
removed_unreadable,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProcessState {
Alive,
Unverified(String),
Gone(String),
}
impl ProcessState {
#[must_use]
pub fn is_running(&self) -> bool {
matches!(self, ProcessState::Alive | ProcessState::Unverified(_))
}
}
pub fn process_state(record: &RunningRecord) -> ProcessState {
process_state_of(record.pid, &record.process_start)
}
pub fn identity_state(identity: &ProcessIdentity) -> ProcessState {
process_state_of(identity.pid, &identity.process_start)
}
impl RunningRecord {
pub fn identity(&self) -> ProcessIdentity {
ProcessIdentity {
pid: self.pid,
process_start: self.process_start.clone(),
}
}
}
pub(crate) fn process_state_of(pid: u32, recorded_start: &str) -> ProcessState {
let alive = pid_is_alive(pid);
let token = alive.then(|| process_start_token(pid)).flatten();
classify(pid, recorded_start, alive, token.as_deref())
}
fn classify(
pid: u32,
recorded_start: &str,
pid_alive: bool,
fresh_token: Option<&str>,
) -> ProcessState {
if !pid_alive {
return ProcessState::Gone(format!("no process holds pid {pid}"));
}
match fresh_token {
Some(token) if token == recorded_start => ProcessState::Alive,
Some(_) => ProcessState::Gone(format!(
"pid {pid} is held by a process that started at another time"
)),
None => ProcessState::Unverified(format!("pid {pid}'s start time could not be read")),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Liveness {
Live,
Unreachable(String),
Gone(String),
}
impl Liveness {
#[must_use]
pub fn is_running(&self) -> bool {
!matches!(self, Liveness::Gone(_))
}
pub fn reason(&self) -> &str {
match self {
Liveness::Live => "",
Liveness::Unreachable(reason) | Liveness::Gone(reason) => reason,
}
}
}
pub fn verify(record: &RunningRecord) -> Liveness {
if let Some(liveness) = liveness_without_probe(process_state(record)) {
return liveness;
}
let Some(url) = record.url.as_deref() else {
return Liveness::Live;
};
match probe_door_session_id(url, record.door_token.as_ref()) {
Ok(session_id) if session_id == record.session_id => Liveness::Live,
Ok(other) => {
Liveness::Unreachable(format!("the capsule at {url} answers for session {other}"))
}
Err(reason) => Liveness::Unreachable(reason),
}
}
fn liveness_without_probe(state: ProcessState) -> Option<Liveness> {
match state {
ProcessState::Alive => None,
ProcessState::Unverified(reason) => Some(Liveness::Unreachable(reason)),
ProcessState::Gone(reason) => Some(Liveness::Gone(reason)),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SignalOutcome {
Sent,
AlreadyGone,
Refused(String),
}
pub fn signal_term(record: &RunningRecord) -> SignalOutcome {
signal(&record.identity(), libc::SIGTERM)
}
pub fn signal_kill(record: &RunningRecord) -> SignalOutcome {
signal(&record.identity(), libc::SIGKILL)
}
pub fn signal_term_process(identity: &ProcessIdentity) -> SignalOutcome {
signal(identity, libc::SIGTERM)
}
pub fn signal_kill_process(identity: &ProcessIdentity) -> SignalOutcome {
signal(identity, libc::SIGKILL)
}
#[allow(unsafe_code)]
fn signal(identity: &ProcessIdentity, signal: libc::c_int) -> SignalOutcome {
if let Some(outcome) = refusal_before_signal(&identity_state(identity)) {
return outcome;
}
if unsafe { libc::kill(identity.pid as libc::pid_t, signal) } == 0 {
return SignalOutcome::Sent;
}
let err = std::io::Error::last_os_error();
if err.raw_os_error() == Some(libc::ESRCH) {
return SignalOutcome::AlreadyGone;
}
SignalOutcome::Refused(err.to_string())
}
fn refusal_before_signal(state: &ProcessState) -> Option<SignalOutcome> {
match state {
ProcessState::Alive => None,
ProcessState::Unverified(reason) => Some(SignalOutcome::Refused(reason.clone())),
ProcessState::Gone(_) => Some(SignalOutcome::AlreadyGone),
}
}
pub fn prune(record: &RunningRecord) {
if let Ok(dir) = running_dir() {
let _ = std::fs::remove_file(record_path(&dir, &record.session_id));
let _ = std::fs::remove_file(token_path(&dir, &record.session_id));
}
}
pub fn control_token_path(session_id: &str) -> Result<PathBuf, String> {
Ok(token_path(&running_dir()?, session_id))
}
pub struct ControlTokenGuard {
path: PathBuf,
}
impl ControlTokenGuard {
pub fn write(session_id: &str, token: &str) -> Result<Self, String> {
let path = control_token_path(session_id)?;
crate::retention::StagedRewrite::stage_with_mode(
&path,
token.as_bytes(),
Some(RECORD_FILE_MODE),
)
.and_then(crate::retention::StagedRewrite::commit)
.map_err(|reason| format!("failed to write {}: {reason}", path.display()))?;
Ok(Self { path })
}
}
impl Drop for ControlTokenGuard {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
pub struct RunningGuard {
path: PathBuf,
}
impl RunningGuard {
pub fn write(record: &RunningRecord) -> Result<Self, String> {
let dir = running_dir()?;
let path = record_path(&dir, &record.session_id);
let body = serde_json::to_vec_pretty(record)
.map_err(|err| format!("the record could not be serialized: {err}"))?;
crate::retention::StagedRewrite::stage_with_mode(&path, &body, Some(RECORD_FILE_MODE))
.and_then(crate::retention::StagedRewrite::commit)
.map_err(|reason| format!("failed to write {}: {reason}", path.display()))?;
Ok(Self { path })
}
}
impl Drop for RunningGuard {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
let _ = std::fs::remove_file(self.path.with_extension(CONTROL_TOKEN_EXTENSION));
}
}
#[must_use]
pub fn has_controlling_terminal() -> bool {
std::fs::File::open("/dev/tty").is_ok()
}
fn record_path(dir: &Path, session_id: &str) -> PathBuf {
dir.join(format!("{session_id}.json"))
}
fn token_path(dir: &Path, session_id: &str) -> PathBuf {
dir.join(format!("{session_id}.{CONTROL_TOKEN_EXTENSION}"))
}
fn read_record(path: &Path) -> RecordRead {
let Ok(body) = std::fs::read_to_string(path) else {
return RecordRead::Unreadable;
};
if let Ok(record) = serde_json::from_str(&body) {
return RecordRead::Record(record);
}
match serde_json::from_str::<serde_json::Value>(&body) {
Ok(serde_json::Value::Object(object))
if object
.get("session_id")
.is_some_and(serde_json::Value::is_string)
&& !object.contains_key("records_dir") =>
{
RecordRead::OlderMur
}
_ => RecordRead::Unreadable,
}
}
#[allow(unsafe_code)]
pub(crate) fn pid_is_alive(pid: u32) -> bool {
let Ok(target) = libc::pid_t::try_from(pid) else {
return false;
};
if target == 0 {
return false;
}
let held = unsafe { libc::kill(target, 0) } == 0
|| std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM);
held && !platform::is_zombie(pid)
}
#[must_use]
pub fn process_start_token(pid: u32) -> Option<String> {
platform::process_start_token(pid)
}
#[cfg(target_os = "linux")]
mod platform {
const STARTTIME_OFFSET_AFTER_COMM: usize = 22 - 3;
pub(super) fn process_start_token(pid: u32) -> Option<String> {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
start_token_from_stat(&stat)
}
pub(super) fn start_token_from_stat(stat: &str) -> Option<String> {
fields_after_comm(stat)?
.nth(STARTTIME_OFFSET_AFTER_COMM)
.map(str::to_string)
}
fn fields_after_comm(stat: &str) -> Option<std::str::SplitWhitespace<'_>> {
Some(stat[stat.rfind(')')? + 1..].split_whitespace())
}
pub(super) fn is_zombie(pid: u32) -> bool {
let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
return false;
};
fields_after_comm(&stat).and_then(|mut fields| fields.next()) == Some("Z")
}
}
#[cfg(target_os = "macos")]
mod platform {
use std::mem::size_of;
pub(super) fn process_start_token(pid: u32) -> Option<String> {
let info = bsd_info(pid).ok()?;
Some(format!(
"{}.{:06}",
info.pbi_start_tvsec, info.pbi_start_tvusec
))
}
pub(super) fn is_zombie(pid: u32) -> bool {
match bsd_info(pid) {
Ok(info) => info.pbi_status == libc::SZOMB,
Err(err) => err.raw_os_error() == Some(libc::ESRCH),
}
}
#[allow(unsafe_code)]
fn bsd_info(pid: u32) -> std::io::Result<libc::proc_bsdinfo> {
let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
let size = size_of::<libc::proc_bsdinfo>();
let written = unsafe {
libc::proc_pidinfo(
pid as libc::c_int,
libc::PROC_PIDTBSDINFO,
0,
std::ptr::addr_of_mut!(info).cast(),
size as libc::c_int,
)
};
if written <= 0 {
return Err(std::io::Error::last_os_error());
}
if usize::try_from(written).ok() != Some(size) {
return Err(std::io::Error::other(format!(
"proc_pidinfo wrote {written} bytes, not one proc_bsdinfo of {size}"
)));
}
Ok(info)
}
}
pub(crate) fn probe_door_session_id(
url: &str,
token: Option<&crate::door_auth::DoorToken>,
) -> Result<String, String> {
let addr = url
.trim_start_matches("http://")
.trim_start_matches("https://");
let version = (
crate::a2a::A2A_VERSION_HEADER,
crate::a2a::A2A_PROTOCOL_VERSION,
);
let card = match token {
Some(token) => {
let body = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": crate::identity::DoorMethod::GetExtendedAgentCard.wire_name(),
"params": {},
})
.to_string();
let authorization = crate::door_auth::bearer_header(token);
let response = crate::http_client::http_json_with_timeouts(
"POST",
&format!("http://{addr}/"),
Some(&body),
&[("Authorization", authorization.as_str()), version],
PROBE_CONNECT_TIMEOUT,
PROBE_READ_TIMEOUT,
)?;
response.get("result").cloned().ok_or_else(|| {
format!("the capsule at {addr} did not return its extended agent card")
})?
}
None => crate::http_client::http_json_with_timeouts(
"GET",
&format!("http://{addr}/.well-known/agent-card.json"),
None,
&[("Accept", "application/json"), version],
PROBE_CONNECT_TIMEOUT,
PROBE_READ_TIMEOUT,
)?,
};
crate::identity::session_id_from_card(&card)
.map(str::to_string)
.ok_or_else(|| format!("the agent card from {addr} names no session"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn alive_and_unverified_are_running_and_gone_is_not() {
assert!(ProcessState::Alive.is_running());
assert!(ProcessState::Unverified("start time unreadable".to_string()).is_running());
assert!(!ProcessState::Gone("no process holds the pid".to_string()).is_running());
}
#[test]
fn prune_removes_the_record_and_its_control_token() {
let home = tempfile::tempdir().unwrap();
crate::murmur_home::run_with_home(
"running::tests::inner_prune_removes_the_record_and_its_control_token",
home.path(),
);
}
#[test]
#[ignore = "run by prune_removes_the_record_and_its_control_token"]
fn inner_prune_removes_the_record_and_its_control_token() {
if !crate::murmur_home::in_scratch_home() {
return;
}
let record = RunningRecord {
session_id: "ses_0199prunecontroltoken000000000000".to_string(),
url: Some("localhost:1".to_string()),
pid: std::process::id(),
process_start: String::new(),
capsule_name: "c".to_string(),
capsule_version: "0.1.0".to_string(),
workdir: PathBuf::from("/tmp"),
records_dir: PathBuf::from("/tmp").join("records"),
outlives_launcher: false,
started_at: "2026-01-01T00:00:00Z".to_string(),
door_token: None,
credentials: BTreeMap::new(),
formation_id: None,
formation_member: None,
formation_entry: false,
formation_lifeline: false,
formation_launcher: None,
spawned_by: None,
unconfined: false,
};
let token = ControlTokenGuard::write(&record.session_id, "ctl1.token.mac").unwrap();
let record_guard = RunningGuard::write(&record).unwrap();
let dir = running_dir().unwrap();
let token_file = control_token_path(&record.session_id).unwrap();
assert_eq!(
token_file,
dir.join(format!("{}.control", record.session_id))
);
assert_eq!(crate::murmur_home::mode_of(&token_file), RECORD_FILE_MODE);
assert_eq!(
std::fs::read_to_string(&token_file).unwrap(),
"ctl1.token.mac"
);
prune(&record);
assert!(!record_path(&dir, &record.session_id).exists());
assert!(!token_file.exists());
std::mem::forget(token);
std::mem::forget(record_guard);
let _token = ControlTokenGuard::write(&record.session_id, "ctl1.token.mac").unwrap();
drop(RunningGuard::write(&record).unwrap());
assert!(!token_file.exists());
}
fn record(pid: u32, process_start: &str) -> RunningRecord {
RunningRecord {
session_id: "ses_0199c4e2f1b7712a9d3e4f5061728394".to_string(),
url: Some("127.0.0.1:1".to_string()),
pid,
process_start: process_start.to_string(),
capsule_name: "demo".to_string(),
capsule_version: "0.1.0".to_string(),
workdir: PathBuf::from("/tmp/demo"),
records_dir: PathBuf::from("/tmp/demo").join("records"),
outlives_launcher: true,
started_at: "2026-01-01T00:00:00Z".to_string(),
door_token: None,
credentials: BTreeMap::new(),
formation_id: None,
formation_member: None,
formation_entry: false,
formation_lifeline: false,
formation_launcher: None,
spawned_by: None,
unconfined: false,
}
}
#[test]
fn a_record_serializes_exactly_the_ten_fields() {
let value = serde_json::to_value(record(1, "42")).unwrap();
let mut keys: Vec<&str> = value
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
keys.sort_unstable();
assert_eq!(
keys,
[
"capsule_name",
"capsule_version",
"outlives_launcher",
"pid",
"process_start",
"records_dir",
"session_id",
"started_at",
"url",
"workdir",
]
);
}
#[test]
fn a_record_with_a_door_token_serializes_it_beside_the_ten_fields() {
let mut with_token = record(1, "42");
with_token.door_token = Some(crate::door_auth::DoorToken::new(
"mdt1.payload.mac".to_string(),
));
let value = serde_json::to_value(&with_token).unwrap();
let object = value.as_object().unwrap();
assert_eq!(object.len(), 11);
assert_eq!(object["door_token"], "mdt1.payload.mac");
let back: RunningRecord = serde_json::from_value(value).unwrap();
assert_eq!(back, with_token);
}
#[test]
fn a_record_debug_prints_no_door_token() {
let mut with_token = record(1, "42");
with_token.door_token = Some(crate::door_auth::DoorToken::new(
"mdt1.secretpayload.secretmac".to_string(),
));
let debug = format!("{with_token:?}");
assert!(!debug.contains("secretpayload"), "{debug}");
assert!(debug.contains("DoorToken(<redacted>)"), "{debug}");
}
fn with_credentials() -> RunningRecord {
let mut authenticated = record(1, "42");
authenticated.door_token = Some(crate::door_auth::DoorToken::new(
"mdt1.operatorpayload.mac".to_string(),
));
for name in ["watcher", "reader"] {
authenticated.credentials.insert(
name.to_string(),
crate::door_auth::DoorToken::new(format!("mdt1.{name}secret.mac")),
);
}
authenticated
}
#[test]
fn a_record_with_credentials_serializes_them_as_an_object_by_name() {
let authenticated = with_credentials();
let value = serde_json::to_value(&authenticated).unwrap();
let object = value.as_object().unwrap();
assert_eq!(object.len(), 12);
assert_eq!(
object["credentials"],
serde_json::json!({
"reader": "mdt1.readersecret.mac",
"watcher": "mdt1.watchersecret.mac",
})
);
let back: RunningRecord = serde_json::from_value(value).unwrap();
assert_eq!(back, authenticated);
}
#[test]
fn a_record_debug_prints_no_credential_token() {
let debug = format!("{:?}", with_credentials());
assert!(!debug.contains("secret"), "{debug}");
assert!(!debug.contains("operatorpayload"), "{debug}");
assert!(
debug.contains("\"watcher\": DoorToken(<redacted>)"),
"{debug}"
);
}
#[test]
fn a_record_with_no_declared_credentials_omits_the_key() {
let mut operator_only = record(1, "42");
operator_only.door_token = Some(crate::door_auth::DoorToken::new(
"mdt1.payload.mac".to_string(),
));
let value = serde_json::to_value(&operator_only).unwrap();
assert!(value.get("credentials").is_none(), "{value}");
}
#[test]
fn a_record_without_a_credentials_key_parses_as_empty() {
let mut value = serde_json::to_value(with_credentials()).unwrap();
value.as_object_mut().unwrap().remove("credentials");
let parsed: RunningRecord = serde_json::from_value(value).unwrap();
assert!(parsed.credentials.is_empty());
assert!(parsed.door_token.is_some());
}
#[test]
fn a_record_without_a_door_token_key_parses() {
let value = serde_json::to_value(record(1, "42")).unwrap();
assert!(value.get("door_token").is_none());
let parsed: RunningRecord = serde_json::from_value(value).unwrap();
assert_eq!(parsed.door_token, None);
}
#[test]
fn a_member_record_serializes_its_formation_beside_the_ten_fields() {
let formation = crate::formation::FormationId::mint();
let mut member = record(1, "42");
member.formation_id = Some(formation.clone());
let value = serde_json::to_value(&member).unwrap();
let object = value.as_object().unwrap();
assert_eq!(object.len(), 11);
assert_eq!(object["formation_id"], formation.as_str());
let back: RunningRecord = serde_json::from_value(value).unwrap();
assert_eq!(back, member);
}
#[test]
fn a_standalone_record_has_no_formation_key() {
let body = serde_json::to_string_pretty(&record(1, "42")).unwrap();
assert!(!body.contains("formation"), "{body}");
let parsed: RunningRecord = serde_json::from_str(&body).unwrap();
assert_eq!(parsed.formation_id, None);
}
#[test]
fn a_record_with_a_malformed_formation_id_does_not_parse() {
let mut value = serde_json::to_value(record(1, "42")).unwrap();
value
.as_object_mut()
.unwrap()
.insert("formation_id".to_string(), "frm_ABC".into());
assert!(serde_json::from_value::<RunningRecord>(value).is_err());
}
#[test]
fn list_prunes_a_record_with_a_malformed_formation_id() {
let home = tempfile::tempdir().unwrap();
crate::murmur_home::run_with_home(
"running::tests::inner_list_prunes_a_record_with_a_malformed_formation_id",
home.path(),
);
}
#[test]
#[ignore = "run by list_prunes_a_record_with_a_malformed_formation_id"]
fn inner_list_prunes_a_record_with_a_malformed_formation_id() {
if !crate::murmur_home::in_scratch_home() {
return;
}
let dir = running_dir().unwrap();
let mut member = record(1, "42");
member.formation_id = Some(crate::formation::FormationId::mint());
let mut malformed = serde_json::to_value(record(2, "42")).unwrap();
malformed["session_id"] = "ses_0199c4e2f1b7712a9d3e4f5061728395".into();
malformed["formation_id"] = "frm_ABC".into();
let member_path = record_path(&dir, &member.session_id);
let malformed_path = dir.join("ses_0199c4e2f1b7712a9d3e4f5061728395.json");
std::fs::write(&member_path, serde_json::to_vec_pretty(&member).unwrap()).unwrap();
std::fs::write(
&malformed_path,
serde_json::to_vec_pretty(&malformed).unwrap(),
)
.unwrap();
assert_eq!(list().unwrap(), vec![member]);
assert!(member_path.exists());
assert!(!malformed_path.exists());
}
#[test]
fn a_member_record_serializes_its_roster_name_beside_its_formation() {
let mut member = record(1, "42");
member.formation_id = Some(crate::formation::FormationId::mint());
member.formation_member = Some("w1".to_string());
let value = serde_json::to_value(&member).unwrap();
let object = value.as_object().unwrap();
assert_eq!(object.len(), 12);
assert_eq!(object["formation_member"], "w1");
let back: RunningRecord = serde_json::from_value(value).unwrap();
assert_eq!(back, member);
}
#[test]
fn a_record_without_a_formation_member_or_with_a_null_one_parses_to_none() {
let mut value = serde_json::to_value(record(1, "42")).unwrap();
assert!(value.get("formation_member").is_none());
let parsed: RunningRecord = serde_json::from_value(value.clone()).unwrap();
assert_eq!(parsed.formation_member, None);
value["formation_member"] = serde_json::Value::Null;
let parsed: RunningRecord = serde_json::from_value(value).unwrap();
assert_eq!(parsed.formation_member, None);
}
#[test]
fn a_record_whose_formation_member_is_not_a_member_name_does_not_parse() {
let too_long = format!("w{}", "1".repeat(32));
let malformed: [serde_json::Value; 4] = [7.into(), "".into(), "W1".into(), too_long.into()];
for bad in malformed {
let mut value = serde_json::to_value(record(1, "42")).unwrap();
value["formation_member"] = bad.clone();
assert!(
serde_json::from_value::<RunningRecord>(value).is_err(),
"formation_member {bad} parsed"
);
}
}
#[test]
fn list_prunes_a_record_with_a_malformed_formation_member() {
let home = tempfile::tempdir().unwrap();
crate::murmur_home::run_with_home(
"running::tests::inner_list_prunes_a_record_with_a_malformed_formation_member",
home.path(),
);
}
#[test]
#[ignore = "run by list_prunes_a_record_with_a_malformed_formation_member"]
fn inner_list_prunes_a_record_with_a_malformed_formation_member() {
if !crate::murmur_home::in_scratch_home() {
return;
}
let dir = running_dir().unwrap();
let mut member = record(1, "42");
member.formation_id = Some(crate::formation::FormationId::mint());
member.formation_member = Some("w1".to_string());
let mut malformed = serde_json::to_value(&member).unwrap();
malformed["session_id"] = "ses_0199c4e2f1b7712a9d3e4f5061728395".into();
malformed["formation_member"] = 7.into();
let member_path = record_path(&dir, &member.session_id);
let malformed_path = dir.join("ses_0199c4e2f1b7712a9d3e4f5061728395.json");
std::fs::write(&member_path, serde_json::to_vec_pretty(&member).unwrap()).unwrap();
std::fs::write(
&malformed_path,
serde_json::to_vec_pretty(&malformed).unwrap(),
)
.unwrap();
assert_eq!(list().unwrap(), vec![member]);
assert!(member_path.exists());
assert!(!malformed_path.exists());
}
fn launched_member() -> RunningRecord {
let mut member = record(1, "42");
member.formation_id = Some(crate::formation::FormationId::mint());
member.formation_lifeline = true;
member.formation_launcher = Some(ProcessIdentity {
pid: 4242,
process_start: "987654".to_string(),
});
member.spawned_by = Some("ses_0199c4e2f1b7712a9d3e4f5061728390".to_string());
member
}
#[test]
fn a_launched_members_record_round_trips_its_launcher_and_lineage() {
let mut member = launched_member();
member.formation_entry = true;
let value = serde_json::to_value(&member).unwrap();
assert_eq!(value["formation_entry"], true);
assert_eq!(value["formation_lifeline"], true);
assert_eq!(
value["formation_launcher"],
serde_json::json!({"pid": 4242, "process_start": "987654"})
);
assert_eq!(value["spawned_by"], "ses_0199c4e2f1b7712a9d3e4f5061728390");
let back: RunningRecord = serde_json::from_value(value).unwrap();
assert_eq!(back, member);
}
#[test]
fn a_record_holding_no_entry_lifeline_launcher_or_spawner_omits_all_four_keys() {
let mut member = launched_member();
member.formation_entry = false;
member.formation_lifeline = false;
member.formation_launcher = None;
member.spawned_by = None;
let body = serde_json::to_string_pretty(&member).unwrap();
for key in [
"formation_entry",
"formation_lifeline",
"formation_launcher",
"spawned_by",
"unconfined",
] {
assert!(!body.contains(key), "{key} in {body}");
}
assert_eq!(
serde_json::from_str::<RunningRecord>(&body).unwrap(),
member
);
}
#[test]
fn liveness_is_running_exactly_when_its_process_state_is() {
for state in [
ProcessState::Alive,
ProcessState::Unverified("unreadable start time".to_string()),
ProcessState::Gone("no such process".to_string()),
] {
let reachable = match liveness_without_probe(state.clone()) {
Some(liveness) => vec![liveness],
None => vec![
Liveness::Live,
Liveness::Unreachable("the door did not answer".to_string()),
],
};
for liveness in reachable {
assert_eq!(
liveness.is_running(),
state.is_running(),
"{state:?} → {liveness:?}"
);
}
}
}
#[test]
fn a_record_with_a_malformed_formation_launcher_does_not_parse() {
for launcher in [
serde_json::json!(4242),
serde_json::json!({"pid": 4242}),
serde_json::json!({"process_start": "1"}),
serde_json::json!({"pid": -1, "process_start": "1"}),
serde_json::json!({"pid": "4242", "process_start": "1"}),
] {
let mut value = serde_json::to_value(record(1, "42")).unwrap();
value["formation_launcher"] = launcher.clone();
assert!(
serde_json::from_value::<RunningRecord>(value).is_err(),
"{launcher}"
);
}
}
#[test]
fn an_identity_reads_and_signals_as_the_record_naming_the_same_process() {
let pid = std::process::id();
let token = process_start_token(pid).unwrap();
for start in [token.as_str(), "not-this-process", ""] {
let record = record(pid, start);
let identity = ProcessIdentity {
pid,
process_start: start.to_string(),
};
assert_eq!(identity_state(&identity), process_state(&record), "{start}");
}
let stale = ProcessIdentity {
pid,
process_start: "not-this-process".to_string(),
};
assert_eq!(
signal_term_process(&stale),
signal_term(&record(pid, "not-this-process"))
);
assert_eq!(signal_kill_process(&stale), SignalOutcome::AlreadyGone);
let nobody = ProcessIdentity {
pid: 0,
process_start: "42".to_string(),
};
assert_eq!(signal_term_process(&nobody), signal_term(&record(0, "42")));
assert_eq!(
ProcessIdentity::of_this_process(),
Some(ProcessIdentity {
pid,
process_start: token
})
);
}
#[test]
fn a_term_sent_through_an_identity_reaches_the_process() {
let mut child = std::process::Command::new("sleep")
.arg("30")
.spawn()
.expect("sleep should start");
let pid = child.id();
let identity = ProcessIdentity {
pid,
process_start: process_start_token(pid).unwrap(),
};
assert_eq!(identity_state(&identity), ProcessState::Alive);
assert_eq!(signal_term_process(&identity), SignalOutcome::Sent);
let status = child.wait().unwrap();
use std::os::unix::process::ExitStatusExt;
assert_eq!(status.signal(), Some(libc::SIGTERM));
assert!(matches!(identity_state(&identity), ProcessState::Gone(_)));
}
#[test]
fn a_record_missing_a_field_does_not_parse() {
let mut value = serde_json::to_value(record(1, "42")).unwrap();
value.as_object_mut().unwrap().remove("process_start");
assert!(serde_json::from_value::<RunningRecord>(value).is_err());
}
#[test]
fn this_process_reads_as_alive() {
let pid = std::process::id();
let token = process_start_token(pid).expect("this process has a start time");
assert_eq!(process_state(&record(pid, &token)), ProcessState::Alive);
}
#[test]
fn a_start_time_that_disagrees_reads_as_gone() {
let pid = std::process::id();
let state = process_state(&record(pid, "not-this-process"));
match state {
ProcessState::Gone(reason) => assert!(
reason.contains("another time"),
"the reason must say the pid belongs to another process now: {reason}"
),
other => panic!("a mismatched start time must read as gone, not {other:?}"),
}
}
#[test]
fn a_pid_nothing_holds_reads_as_gone() {
let state = process_state(&record(0, "42"));
assert!(matches!(state, ProcessState::Gone(_)));
}
#[test]
fn the_start_token_is_stable_for_one_process() {
let pid = std::process::id();
assert_eq!(process_start_token(pid), process_start_token(pid));
assert!(process_start_token(pid).is_some_and(|token| !token.is_empty()));
}
#[test]
fn a_zombie_does_not_read_as_alive() {
let mut child = std::process::Command::new("sh")
.args(["-c", "exit 0"])
.spawn()
.expect("sh should start");
let pid = child.id();
let token = process_start_token(pid).expect("a live child has a start time");
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
if matches!(process_state(&record(pid, &token)), ProcessState::Gone(_)) {
break;
}
assert!(
std::time::Instant::now() < deadline,
"a zombie kept reading as alive"
);
std::thread::sleep(Duration::from_millis(50));
}
let _ = child.wait();
}
#[test]
fn a_signal_at_a_mismatched_start_time_is_not_sent() {
let record = record(std::process::id(), "not-this-process");
assert_eq!(signal_kill(&record), SignalOutcome::AlreadyGone);
assert_eq!(signal_term(&record), SignalOutcome::AlreadyGone);
}
#[test]
fn a_doorless_record_is_live_on_its_process_alone() {
let pid = std::process::id();
let token = process_start_token(pid).unwrap();
let mut doorless = record(pid, &token);
doorless.url = None;
assert_eq!(verify(&doorless), Liveness::Live);
let mut dead = record(0, "42");
dead.url = None;
assert!(matches!(verify(&dead), Liveness::Gone(_)));
}
#[test]
fn a_doorless_record_round_trips_with_a_null_url() {
let mut doorless = record(1, "42");
doorless.url = None;
let text = serde_json::to_string(&doorless).unwrap();
assert!(text.contains("\"url\":null"), "{text}");
let back: RunningRecord = serde_json::from_str(&text).unwrap();
assert_eq!(back, doorless);
let with_door = record(1, "42");
let text = serde_json::to_string(&with_door).unwrap();
assert!(text.contains("\"url\":\"127.0.0.1:1\""), "{text}");
let back: RunningRecord = serde_json::from_str(&text).unwrap();
assert_eq!(back.url.as_deref(), Some("127.0.0.1:1"));
}
#[test]
fn a_signal_at_a_pid_nothing_holds_is_already_gone() {
assert_eq!(signal_term(&record(0, "42")), SignalOutcome::AlreadyGone);
}
#[test]
fn a_live_process_behind_a_silent_port_is_unreachable() {
let pid = std::process::id();
let token = process_start_token(pid).unwrap();
let mut record = record(pid, &token);
let port = crate::pinned_port::claim();
record.url = Some(format!("127.0.0.1:{port}"));
assert!(matches!(verify(&record), Liveness::Unreachable(_)));
}
const PID: u32 = 4242;
#[test]
fn classify_a_dead_pid_is_gone_whatever_the_token() {
for token in [None, Some("123"), Some("")] {
assert_eq!(
classify(PID, "123", false, token),
ProcessState::Gone("no process holds pid 4242".to_string()),
"token {token:?}"
);
}
}
#[test]
fn classify_a_live_pid_with_the_recorded_token_is_alive() {
assert_eq!(classify(PID, "123", true, Some("123")), ProcessState::Alive);
}
#[test]
fn classify_a_live_pid_with_another_token_is_gone() {
assert_eq!(
classify(PID, "123", true, Some("456")),
ProcessState::Gone("pid 4242 is held by a process that started at another time".into())
);
}
#[test]
fn classify_a_recorded_empty_token_against_a_fresh_one_is_gone() {
assert!(matches!(
classify(PID, "", true, Some("123")),
ProcessState::Gone(reason) if reason.contains("another time")
));
}
#[test]
fn classify_a_live_pid_whose_token_cannot_be_read_is_unverified() {
assert_eq!(
classify(PID, "123", true, None),
ProcessState::Unverified("pid 4242's start time could not be read".into())
);
}
#[test]
fn nothing_is_refused_before_signalling_a_confirmed_process() {
assert_eq!(refusal_before_signal(&ProcessState::Alive), None);
}
#[test]
fn a_gone_process_is_already_gone_before_any_signal() {
assert_eq!(
refusal_before_signal(&ProcessState::Gone("no process holds pid 1".into())),
Some(SignalOutcome::AlreadyGone)
);
}
#[test]
fn an_unverified_process_is_refused_before_any_signal() {
let reason = "pid 1's start time could not be read".to_string();
assert_eq!(
refusal_before_signal(&ProcessState::Unverified(reason.clone())),
Some(SignalOutcome::Refused(reason))
);
}
#[test]
fn an_unverified_process_is_unreachable_without_a_probe() {
let reason = "pid 1's start time could not be read".to_string();
assert_eq!(
liveness_without_probe(ProcessState::Unverified(reason.clone())),
Some(Liveness::Unreachable(reason))
);
assert_eq!(
liveness_without_probe(ProcessState::Gone("gone".into())),
Some(Liveness::Gone("gone".into()))
);
assert_eq!(liveness_without_probe(ProcessState::Alive), None);
}
#[cfg(target_os = "linux")]
fn stat_line(comm: &str) -> String {
let fields: Vec<String> = (3..=21).map(|field| field.to_string()).collect();
format!("1234 ({comm}) {} 987654 23 24\n", fields.join(" "))
}
#[cfg(target_os = "linux")]
#[test]
fn the_start_token_is_field_22_of_a_plain_stat_line() {
assert_eq!(
platform::start_token_from_stat(&stat_line("sleep")),
Some("987654".to_string())
);
}
#[cfg(target_os = "linux")]
#[test]
fn the_start_token_is_counted_from_the_last_close_paren() {
assert_eq!(
platform::start_token_from_stat(&stat_line("a) (b c")),
Some("987654".to_string())
);
}
#[cfg(target_os = "linux")]
#[test]
fn a_stat_line_with_no_close_paren_has_no_start_token() {
assert_eq!(platform::start_token_from_stat("1234 sleep S 1 2 3"), None);
}
#[cfg(target_os = "linux")]
#[test]
fn a_stat_line_truncated_before_field_22_has_no_start_token() {
let line = stat_line("sleep");
let truncated = &line[..line.find(" 987654").unwrap()];
assert_eq!(platform::start_token_from_stat(truncated), None);
}
}
#[cfg(test)]
mod home_tests {
use super::*;
#[test]
fn running_dir_holds_a_wide_murmur_home_owner_only() {
let home = tempfile::tempdir().unwrap();
crate::murmur_home::wide_dir(&home.path().join(".murmur"), 0o755);
crate::murmur_home::run_with_home(
"running::home_tests::inner_running_dir_under_scratch_home",
home.path(),
);
assert_eq!(
crate::murmur_home::mode_of(&home.path().join(".murmur")),
0o700
);
assert_eq!(
crate::murmur_home::mode_of(&home.path().join(".murmur").join(RUNNING_DIR)),
RUNNING_DIR_MODE
);
}
#[test]
#[ignore = "run by running_dir_holds_a_wide_murmur_home_owner_only"]
fn inner_running_dir_under_scratch_home() {
if !crate::murmur_home::in_scratch_home() {
return;
}
running_dir().unwrap();
}
#[test]
fn running_dir_location_creates_nothing() {
let home = tempfile::tempdir().unwrap();
crate::murmur_home::run_with_home(
"running::home_tests::inner_running_dir_location_creates_nothing",
home.path(),
);
assert!(!home.path().join(".murmur").exists());
}
#[test]
#[ignore = "run by running_dir_location_creates_nothing"]
fn inner_running_dir_location_creates_nothing() {
if !crate::murmur_home::in_scratch_home() {
return;
}
let home = PathBuf::from(std::env::var_os("HOME").unwrap());
assert_eq!(
running_dir_location().unwrap(),
home.join(".murmur").join(RUNNING_DIR)
);
assert!(!home.join(".murmur").exists());
}
#[test]
fn the_door_probe_names_a2a_1_0_on_both_cards() {
use crate::http_client::capture::answer_one;
let card = serde_json::json!({"capabilities": {"extensions": [{
"uri": crate::identity::CAPSULE_EXTENSION_URI,
"params": {"sessionId": "ses_probe"}}]}});
let (addr, sent) =
answer_one(serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": card}).to_string());
let token = crate::door_auth::DoorToken::new("tok".to_string());
assert_eq!(
probe_door_session_id(&addr, Some(&token)).unwrap(),
"ses_probe"
);
let sent = sent.join().unwrap();
assert_eq!(sent.request_line, "POST / HTTP/1.1");
assert_eq!(sent.method(), "GetExtendedAgentCard");
assert_eq!(sent.header("a2a-version"), ["1.0"]);
let (addr, sent) = answer_one(card.to_string());
assert_eq!(probe_door_session_id(&addr, None).unwrap(), "ses_probe");
let sent = sent.join().unwrap();
assert_eq!(
sent.request_line,
"GET /.well-known/agent-card.json HTTP/1.1"
);
assert_eq!(sent.header("a2a-version"), ["1.0"]);
}
fn record(session_id: &str) -> RunningRecord {
RunningRecord {
session_id: session_id.to_string(),
url: Some("127.0.0.1:1".to_string()),
pid: 1,
process_start: "42".to_string(),
capsule_name: "demo".to_string(),
capsule_version: "0.1.0".to_string(),
workdir: PathBuf::from("/tmp/demo"),
records_dir: PathBuf::from("/tmp/demo").join("records"),
outlives_launcher: true,
started_at: "2026-01-01T00:00:00Z".to_string(),
door_token: None,
credentials: BTreeMap::new(),
formation_id: None,
formation_member: None,
formation_entry: false,
formation_lifeline: false,
formation_launcher: None,
spawned_by: None,
unconfined: false,
}
}
#[test]
fn scan_reports_an_unparseable_file_and_leaves_it_in_place() {
let dir = tempfile::tempdir().unwrap();
let older = record("ses_0199c4e2f1b7712a9d3e4f5061728390");
let newer = record("ses_0199c4e2f1b7712a9d3e4f5061728391");
for record in [&older, &newer] {
std::fs::write(
record_path(dir.path(), &record.session_id),
serde_json::to_vec(record).unwrap(),
)
.unwrap();
}
let broken = dir.path().join("ses_broken.json");
std::fs::write(&broken, "{").unwrap();
let staging = dir.path().join(".ses_x.json.1.2.tmp");
std::fs::write(&staging, "{").unwrap();
let control = token_path(dir.path(), &older.session_id);
std::fs::write(&control, "ctl1.token.mac").unwrap();
let scan = scan(dir.path()).unwrap();
assert_eq!(scan.records, vec![newer, older]);
assert_eq!(scan.unreadable, vec![broken.clone()]);
assert_eq!(std::fs::read_to_string(&broken).unwrap(), "{");
assert!(staging.exists());
assert!(control.exists());
}
fn mixed_running_dir() -> (tempfile::TempDir, RunningRecord, PathBuf, [PathBuf; 2]) {
let dir = tempfile::tempdir().unwrap();
let current = record("ses_0199c4e2f1b7712a9d3e4f5061728391");
std::fs::write(
record_path(dir.path(), ¤t.session_id),
serde_json::to_vec(¤t).unwrap(),
)
.unwrap();
let older_mur = record_path(dir.path(), "ses_0199c4e2f1b7712a9d3e4f5061728390");
std::fs::write(
&older_mur,
serde_json::json!({
"session_id": "ses_0199c4e2f1b7712a9d3e4f5061728390",
"url": "127.0.0.1:1",
"pid": 1,
"process_start": "42",
"capsule_name": "demo",
"capsule_version": "0.1.0",
"workdir": "/tmp/demo",
"outlives_launcher": true,
"started_at": "2026-01-01T00:00:00Z",
})
.to_string(),
)
.unwrap();
let truncated = dir.path().join("ses_broken.json");
std::fs::write(&truncated, "{").unwrap();
let mut bad_pid = serde_json::to_value(record("ses_bad_pid")).unwrap();
bad_pid["pid"] = serde_json::json!("one");
let bad_pid_path = dir.path().join("ses_bad_pid.json");
std::fs::write(&bad_pid_path, bad_pid.to_string()).unwrap();
(dir, current, older_mur, [truncated, bad_pid_path])
}
#[test]
fn scan_names_a_record_an_older_mur_wrote_apart_from_unreadable_files() {
let (dir, current, older_mur, unreadable) = mixed_running_dir();
let before = std::fs::read(&older_mur).unwrap();
let mut scan = scan(dir.path()).unwrap();
scan.unreadable.sort();
let mut expected_unreadable = unreadable.to_vec();
expected_unreadable.sort();
assert_eq!(scan.records, vec![current]);
assert_eq!(scan.older_mur, vec![older_mur.clone()]);
assert_eq!(scan.unreadable, expected_unreadable);
assert_eq!(std::fs::read(&older_mur).unwrap(), before);
assert!(unreadable.iter().all(|path| path.exists()));
}
#[test]
fn listing_unlinks_only_the_unreadable_files() {
let (dir, current, older_mur, unreadable) = mixed_running_dir();
let before = std::fs::read(&older_mur).unwrap();
let mut listing = list_pruning(dir.path()).unwrap();
listing.removed_unreadable.sort();
let mut expected_removed = unreadable.to_vec();
expected_removed.sort();
assert_eq!(listing.records, vec![current]);
assert_eq!(listing.older_mur, vec![older_mur.clone()]);
assert_eq!(listing.removed_unreadable, expected_removed);
assert_eq!(std::fs::read(&older_mur).unwrap(), before);
assert!(unreadable.iter().all(|path| !path.exists()));
}
#[test]
fn an_object_without_a_string_session_id_is_unreadable() {
let dir = tempfile::tempdir().unwrap();
for (name, body) in [
("ses_array.json", "[]"),
("ses_no_id.json", r#"{"pid": 1}"#),
("ses_number_id.json", r#"{"session_id": 7}"#),
] {
std::fs::write(dir.path().join(name), body).unwrap();
}
let scan = scan(dir.path()).unwrap();
assert!(scan.older_mur.is_empty());
assert_eq!(scan.unreadable.len(), 3);
}
#[test]
fn scan_of_a_missing_directory_is_empty_and_creates_nothing() {
let parent = tempfile::tempdir().unwrap();
let dir = parent.path().join(".murmur").join(RUNNING_DIR);
let scan = scan(&dir).unwrap();
assert!(scan.records.is_empty());
assert!(scan.unreadable.is_empty());
assert!(!parent.path().join(".murmur").exists());
}
#[test]
fn scan_of_a_regular_file_errs_naming_it() {
let parent = tempfile::tempdir().unwrap();
let file = parent.path().join(RUNNING_DIR);
std::fs::write(&file, "").unwrap();
let err = scan(&file).unwrap_err();
assert!(err.starts_with(&format!("{}: ", file.display())), "{err}");
}
}