1use std::{
37 collections::BTreeMap,
38 path::{Path, PathBuf},
39 time::Duration,
40};
41
42use serde::{Deserialize, Serialize};
43
44const RUNNING_DIR: &str = "running";
46
47const RUNNING_DIR_MODE: u32 = 0o700;
49
50const RECORD_FILE_MODE: u32 = 0o600;
52
53const CONTROL_TOKEN_EXTENSION: &str = "control";
55
56const PROBE_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
59
60const PROBE_READ_TIMEOUT: Duration = Duration::from_secs(5);
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct RunningRecord {
75 pub session_id: String,
77 pub url: String,
79 pub pid: u32,
81 pub process_start: String,
83 pub capsule_name: String,
84 pub capsule_version: String,
85 pub workdir: PathBuf,
88 pub records_dir: PathBuf,
94 pub outlives_launcher: bool,
97 pub started_at: String,
99 #[serde(
103 default,
104 skip_serializing_if = "Option::is_none",
105 with = "door_token_field"
106 )]
107 pub door_token: Option<crate::door_auth::DoorToken>,
108 #[serde(
114 default,
115 skip_serializing_if = "BTreeMap::is_empty",
116 with = "door_credentials_field"
117 )]
118 pub credentials: BTreeMap<String, crate::door_auth::DoorToken>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub formation_id: Option<crate::formation::FormationId>,
125 #[serde(
132 default,
133 skip_serializing_if = "Option::is_none",
134 deserialize_with = "member_name_field::deserialize"
135 )]
136 pub formation_member: Option<String>,
137 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
140 pub formation_lifeline: bool,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub formation_launcher: Option<ProcessIdentity>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub spawned_by: Option<String>,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156pub struct ProcessIdentity {
157 pub pid: u32,
158 pub process_start: String,
159}
160
161impl ProcessIdentity {
162 pub fn of_this_process() -> Option<Self> {
165 let pid = std::process::id();
166 Some(Self {
167 pid,
168 process_start: process_start_token(pid)?,
169 })
170 }
171}
172
173mod door_token_field {
176 use crate::door_auth::DoorToken;
177 use serde::{Deserialize, Deserializer, Serializer};
178
179 pub(super) fn serialize<S: Serializer>(
180 token: &Option<DoorToken>,
181 serializer: S,
182 ) -> Result<S::Ok, S::Error> {
183 match token {
184 Some(token) => serializer.serialize_some(token.expose()),
185 None => serializer.serialize_none(),
186 }
187 }
188
189 pub(super) fn deserialize<'de, D: Deserializer<'de>>(
190 deserializer: D,
191 ) -> Result<Option<DoorToken>, D::Error> {
192 Ok(Option::<String>::deserialize(deserializer)?.map(DoorToken::new))
193 }
194}
195
196mod door_credentials_field {
200 use std::collections::BTreeMap;
201
202 use crate::door_auth::DoorToken;
203 use serde::{ser::SerializeMap, Deserialize, Deserializer, Serializer};
204
205 pub(super) fn serialize<S: Serializer>(
206 credentials: &BTreeMap<String, DoorToken>,
207 serializer: S,
208 ) -> Result<S::Ok, S::Error> {
209 let mut map = serializer.serialize_map(Some(credentials.len()))?;
210 for (name, token) in credentials {
211 map.serialize_entry(name, token.expose())?;
212 }
213 map.end()
214 }
215
216 pub(super) fn deserialize<'de, D: Deserializer<'de>>(
217 deserializer: D,
218 ) -> Result<BTreeMap<String, DoorToken>, D::Error> {
219 Ok(BTreeMap::<String, String>::deserialize(deserializer)?
220 .into_iter()
221 .map(|(name, token)| (name, DoorToken::new(token)))
222 .collect())
223 }
224}
225
226mod member_name_field {
229 use serde::{de::Error, Deserialize, Deserializer};
230
231 pub(super) fn deserialize<'de, D: Deserializer<'de>>(
232 deserializer: D,
233 ) -> Result<Option<String>, D::Error> {
234 let Some(name) = Option::<String>::deserialize(deserializer)? else {
235 return Ok(None);
236 };
237 match murmur_artifact::member_name_format_error(&name) {
238 Some(reason) => Err(D::Error::custom(format!(
239 "formation_member {name:?} is not a member name: it {reason}"
240 ))),
241 None => Ok(Some(name)),
242 }
243 }
244}
245
246pub fn running_dir() -> Result<PathBuf, String> {
253 let dir = crate::murmur_home::ensure_murmur_home()?.join(RUNNING_DIR);
254 crate::state_store::ensure_private_dir(&dir, RUNNING_DIR_MODE)
255 .map_err(|reason| format!("{}: {reason}", dir.display()))?;
256 Ok(dir)
257}
258
259pub fn running_dir_location() -> Result<PathBuf, String> {
266 Ok(crate::state_store::murmur_home_dir()?.join(RUNNING_DIR))
267}
268
269#[derive(Debug, Default)]
271pub struct RecordScan {
272 pub records: Vec<RunningRecord>,
274 pub older_mur: Vec<PathBuf>,
278 pub unreadable: Vec<PathBuf>,
280}
281
282#[derive(Debug)]
284enum RecordRead {
285 Record(Box<RunningRecord>),
286 OlderMur,
288 Unreadable,
289}
290
291pub fn scan(dir: &Path) -> Result<RecordScan, String> {
302 let entries = match std::fs::read_dir(dir) {
303 Ok(entries) => entries,
304 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(RecordScan::default()),
305 Err(err) => return Err(format!("{}: {err}", dir.display())),
306 };
307
308 let mut scan = RecordScan::default();
309 for entry in entries.flatten() {
310 let path = entry.path();
311 if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
312 continue;
313 }
314 match read_record(&path) {
315 RecordRead::Record(record) => scan.records.push(*record),
316 RecordRead::OlderMur => scan.older_mur.push(path),
317 RecordRead::Unreadable => scan.unreadable.push(path),
318 }
319 }
320 scan.records
321 .sort_by(|left, right| right.session_id.cmp(&left.session_id));
322 scan.older_mur.sort_by(|left, right| right.cmp(left));
323 Ok(scan)
324}
325
326pub fn list() -> Result<Vec<RunningRecord>, String> {
337 list_reporting().map(|listing| listing.records)
338}
339
340#[derive(Debug, Default)]
342pub struct RecordListing {
343 pub records: Vec<RunningRecord>,
345 pub older_mur: Vec<PathBuf>,
348 pub removed_unreadable: Vec<PathBuf>,
351}
352
353pub fn list_reporting() -> Result<RecordListing, String> {
358 list_pruning(&running_dir()?)
359}
360
361fn list_pruning(dir: &Path) -> Result<RecordListing, String> {
363 let scan = scan(dir)?;
364 let removed_unreadable = scan
365 .unreadable
366 .into_iter()
367 .filter(|path| std::fs::remove_file(path).is_ok())
368 .collect();
369 Ok(RecordListing {
370 records: scan.records,
371 older_mur: scan.older_mur,
372 removed_unreadable,
373 })
374}
375
376#[derive(Debug, Clone, PartialEq, Eq)]
381pub enum ProcessState {
382 Alive,
384 Unverified(String),
387 Gone(String),
390}
391
392pub fn process_state(record: &RunningRecord) -> ProcessState {
398 process_state_of(record.pid, &record.process_start)
399}
400
401pub fn identity_state(identity: &ProcessIdentity) -> ProcessState {
403 process_state_of(identity.pid, &identity.process_start)
404}
405
406impl RunningRecord {
407 pub fn identity(&self) -> ProcessIdentity {
409 ProcessIdentity {
410 pid: self.pid,
411 process_start: self.process_start.clone(),
412 }
413 }
414}
415
416pub(crate) fn process_state_of(pid: u32, recorded_start: &str) -> ProcessState {
419 let alive = pid_is_alive(pid);
420 let token = alive.then(|| process_start_token(pid)).flatten();
421 classify(pid, recorded_start, alive, token.as_deref())
422}
423
424fn classify(
430 pid: u32,
431 recorded_start: &str,
432 pid_alive: bool,
433 fresh_token: Option<&str>,
434) -> ProcessState {
435 if !pid_alive {
436 return ProcessState::Gone(format!("no process holds pid {pid}"));
437 }
438 match fresh_token {
439 Some(token) if token == recorded_start => ProcessState::Alive,
440 Some(_) => ProcessState::Gone(format!(
441 "pid {pid} is held by a process that started at another time"
442 )),
443 None => ProcessState::Unverified(format!("pid {pid}'s start time could not be read")),
444 }
445}
446
447#[derive(Debug, Clone, PartialEq, Eq)]
449pub enum Liveness {
450 Live,
452 Unreachable(String),
455 Gone(String),
457}
458
459pub fn verify(record: &RunningRecord) -> Liveness {
465 if let Some(liveness) = liveness_without_probe(process_state(record)) {
466 return liveness;
467 }
468 match probe_session_id(record) {
469 Ok(session_id) if session_id == record.session_id => Liveness::Live,
470 Ok(other) => Liveness::Unreachable(format!(
471 "the capsule at {} answers for session {other}",
472 record.url
473 )),
474 Err(reason) => Liveness::Unreachable(reason),
475 }
476}
477
478fn liveness_without_probe(state: ProcessState) -> Option<Liveness> {
481 match state {
482 ProcessState::Alive => None,
483 ProcessState::Unverified(reason) => Some(Liveness::Unreachable(reason)),
484 ProcessState::Gone(reason) => Some(Liveness::Gone(reason)),
485 }
486}
487
488#[derive(Debug, Clone, PartialEq, Eq)]
490pub enum SignalOutcome {
491 Sent,
493 AlreadyGone,
495 Refused(String),
499}
500
501pub fn signal_term(record: &RunningRecord) -> SignalOutcome {
503 signal(&record.identity(), libc::SIGTERM)
504}
505
506pub fn signal_kill(record: &RunningRecord) -> SignalOutcome {
508 signal(&record.identity(), libc::SIGKILL)
509}
510
511pub fn signal_term_process(identity: &ProcessIdentity) -> SignalOutcome {
513 signal(identity, libc::SIGTERM)
514}
515
516pub fn signal_kill_process(identity: &ProcessIdentity) -> SignalOutcome {
519 signal(identity, libc::SIGKILL)
520}
521
522#[allow(unsafe_code)]
530fn signal(identity: &ProcessIdentity, signal: libc::c_int) -> SignalOutcome {
531 if let Some(outcome) = refusal_before_signal(&identity_state(identity)) {
532 return outcome;
533 }
534 if unsafe { libc::kill(identity.pid as libc::pid_t, signal) } == 0 {
539 return SignalOutcome::Sent;
540 }
541 let err = std::io::Error::last_os_error();
542 if err.raw_os_error() == Some(libc::ESRCH) {
545 return SignalOutcome::AlreadyGone;
546 }
547 SignalOutcome::Refused(err.to_string())
548}
549
550fn refusal_before_signal(state: &ProcessState) -> Option<SignalOutcome> {
556 match state {
557 ProcessState::Alive => None,
558 ProcessState::Unverified(reason) => Some(SignalOutcome::Refused(reason.clone())),
559 ProcessState::Gone(_) => Some(SignalOutcome::AlreadyGone),
560 }
561}
562
563pub fn prune(record: &RunningRecord) {
566 if let Ok(dir) = running_dir() {
567 let _ = std::fs::remove_file(record_path(&dir, &record.session_id));
568 let _ = std::fs::remove_file(token_path(&dir, &record.session_id));
569 }
570}
571
572pub fn control_token_path(session_id: &str) -> Result<PathBuf, String> {
577 Ok(token_path(&running_dir()?, session_id))
578}
579
580pub struct ControlTokenGuard {
585 path: PathBuf,
586}
587
588impl ControlTokenGuard {
589 pub fn write(session_id: &str, token: &str) -> Result<Self, String> {
594 let path = control_token_path(session_id)?;
595 crate::retention::StagedRewrite::stage_with_mode(
596 &path,
597 token.as_bytes(),
598 Some(RECORD_FILE_MODE),
599 )
600 .and_then(crate::retention::StagedRewrite::commit)
601 .map_err(|reason| format!("failed to write {}: {reason}", path.display()))?;
602 Ok(Self { path })
603 }
604}
605
606impl Drop for ControlTokenGuard {
607 fn drop(&mut self) {
608 let _ = std::fs::remove_file(&self.path);
609 }
610}
611
612pub struct RunningGuard {
618 path: PathBuf,
619}
620
621impl RunningGuard {
622 pub fn write(record: &RunningRecord) -> Result<Self, String> {
628 let dir = running_dir()?;
629 let path = record_path(&dir, &record.session_id);
630 let body = serde_json::to_vec_pretty(record)
631 .map_err(|err| format!("the record could not be serialized: {err}"))?;
632
633 crate::retention::StagedRewrite::stage_with_mode(&path, &body, Some(RECORD_FILE_MODE))
637 .and_then(crate::retention::StagedRewrite::commit)
638 .map_err(|reason| format!("failed to write {}: {reason}", path.display()))?;
639 Ok(Self { path })
640 }
641}
642
643impl Drop for RunningGuard {
644 fn drop(&mut self) {
645 let _ = std::fs::remove_file(&self.path);
646 let _ = std::fs::remove_file(self.path.with_extension(CONTROL_TOKEN_EXTENSION));
648 }
649}
650
651#[must_use]
656pub fn has_controlling_terminal() -> bool {
657 std::fs::File::open("/dev/tty").is_ok()
658}
659
660fn record_path(dir: &Path, session_id: &str) -> PathBuf {
661 dir.join(format!("{session_id}.json"))
662}
663
664fn token_path(dir: &Path, session_id: &str) -> PathBuf {
665 dir.join(format!("{session_id}.{CONTROL_TOKEN_EXTENSION}"))
666}
667
668fn read_record(path: &Path) -> RecordRead {
669 let Ok(body) = std::fs::read_to_string(path) else {
670 return RecordRead::Unreadable;
671 };
672 if let Ok(record) = serde_json::from_str(&body) {
673 return RecordRead::Record(record);
674 }
675 match serde_json::from_str::<serde_json::Value>(&body) {
676 Ok(serde_json::Value::Object(object))
677 if object
678 .get("session_id")
679 .is_some_and(serde_json::Value::is_string)
680 && !object.contains_key("records_dir") =>
681 {
682 RecordRead::OlderMur
683 }
684 _ => RecordRead::Unreadable,
685 }
686}
687
688#[allow(unsafe_code)]
700pub(crate) fn pid_is_alive(pid: u32) -> bool {
701 let Ok(target) = libc::pid_t::try_from(pid) else {
702 return false;
703 };
704 if target == 0 {
705 return false;
706 }
707 let held = unsafe { libc::kill(target, 0) } == 0
710 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM);
711 held && !platform::is_zombie(pid)
712}
713
714#[must_use]
725pub fn process_start_token(pid: u32) -> Option<String> {
726 platform::process_start_token(pid)
727}
728
729#[cfg(target_os = "linux")]
730mod platform {
731 const STARTTIME_OFFSET_AFTER_COMM: usize = 22 - 3;
737
738 pub(super) fn process_start_token(pid: u32) -> Option<String> {
739 let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
740 start_token_from_stat(&stat)
741 }
742
743 pub(super) fn start_token_from_stat(stat: &str) -> Option<String> {
746 fields_after_comm(stat)?
747 .nth(STARTTIME_OFFSET_AFTER_COMM)
748 .map(str::to_string)
749 }
750
751 fn fields_after_comm(stat: &str) -> Option<std::str::SplitWhitespace<'_>> {
753 Some(stat[stat.rfind(')')? + 1..].split_whitespace())
754 }
755
756 pub(super) fn is_zombie(pid: u32) -> bool {
762 let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
763 return false;
764 };
765 fields_after_comm(&stat).and_then(|mut fields| fields.next()) == Some("Z")
766 }
767}
768
769#[cfg(target_os = "macos")]
770mod platform {
771 use std::mem::size_of;
772
773 pub(super) fn process_start_token(pid: u32) -> Option<String> {
775 let info = bsd_info(pid).ok()?;
776 Some(format!(
777 "{}.{:06}",
778 info.pbi_start_tvsec, info.pbi_start_tvusec
779 ))
780 }
781
782 pub(super) fn is_zombie(pid: u32) -> bool {
790 match bsd_info(pid) {
791 Ok(info) => info.pbi_status == libc::SZOMB,
792 Err(err) => err.raw_os_error() == Some(libc::ESRCH),
793 }
794 }
795
796 #[allow(unsafe_code)]
799 fn bsd_info(pid: u32) -> std::io::Result<libc::proc_bsdinfo> {
800 let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
803 let size = size_of::<libc::proc_bsdinfo>();
804
805 let written = unsafe {
809 libc::proc_pidinfo(
810 pid as libc::c_int,
811 libc::PROC_PIDTBSDINFO,
812 0,
813 std::ptr::addr_of_mut!(info).cast(),
814 size as libc::c_int,
815 )
816 };
817 if written <= 0 {
818 return Err(std::io::Error::last_os_error());
819 }
820 if usize::try_from(written).ok() != Some(size) {
821 return Err(std::io::Error::other(format!(
822 "proc_pidinfo wrote {written} bytes, not one proc_bsdinfo of {size}"
823 )));
824 }
825 Ok(info)
826 }
827}
828
829fn probe_session_id(record: &RunningRecord) -> Result<String, String> {
836 probe_door_session_id(&record.url, record.door_token.as_ref())
837}
838
839pub(crate) fn probe_door_session_id(
845 url: &str,
846 token: Option<&crate::door_auth::DoorToken>,
847) -> Result<String, String> {
848 let addr = url
849 .trim_start_matches("http://")
850 .trim_start_matches("https://");
851 let version = (
852 crate::a2a::A2A_VERSION_HEADER,
853 crate::a2a::A2A_PROTOCOL_VERSION,
854 );
855 let card = match token {
856 Some(token) => {
857 let body = serde_json::json!({
858 "jsonrpc": "2.0",
859 "id": 1,
860 "method": crate::identity::DoorMethod::GetExtendedAgentCard.wire_name(),
861 "params": {},
862 })
863 .to_string();
864 let authorization = crate::door_auth::bearer_header(token);
865 let response = crate::http_client::http_json_with_timeouts(
866 "POST",
867 &format!("http://{addr}/"),
868 Some(&body),
869 &[("Authorization", authorization.as_str()), version],
870 PROBE_CONNECT_TIMEOUT,
871 PROBE_READ_TIMEOUT,
872 )?;
873 response.get("result").cloned().ok_or_else(|| {
874 format!("the capsule at {addr} did not return its extended agent card")
875 })?
876 }
877 None => crate::http_client::http_json_with_timeouts(
878 "GET",
879 &format!("http://{addr}/.well-known/agent-card.json"),
880 None,
881 &[("Accept", "application/json"), version],
882 PROBE_CONNECT_TIMEOUT,
883 PROBE_READ_TIMEOUT,
884 )?,
885 };
886 crate::identity::session_id_from_card(&card)
887 .map(str::to_string)
888 .ok_or_else(|| format!("the agent card from {addr} names no session"))
889}
890
891#[cfg(test)]
892mod tests {
893 use super::*;
894
895 #[test]
896 fn prune_removes_the_record_and_its_control_token() {
897 let home = tempfile::tempdir().unwrap();
898 crate::murmur_home::run_with_home(
899 "running::tests::inner_prune_removes_the_record_and_its_control_token",
900 home.path(),
901 );
902 }
903
904 #[test]
905 #[ignore = "run by prune_removes_the_record_and_its_control_token"]
906 fn inner_prune_removes_the_record_and_its_control_token() {
907 if !crate::murmur_home::in_scratch_home() {
908 return;
909 }
910 let record = RunningRecord {
911 session_id: "ses_0199prunecontroltoken000000000000".to_string(),
912 url: "localhost:1".to_string(),
913 pid: std::process::id(),
914 process_start: String::new(),
915 capsule_name: "c".to_string(),
916 capsule_version: "0.1.0".to_string(),
917 workdir: PathBuf::from("/tmp"),
918 records_dir: PathBuf::from("/tmp").join("records"),
919 outlives_launcher: false,
920 started_at: "2026-01-01T00:00:00Z".to_string(),
921 door_token: None,
922 credentials: BTreeMap::new(),
923 formation_id: None,
924 formation_member: None,
925 formation_lifeline: false,
926 formation_launcher: None,
927 spawned_by: None,
928 };
929 let token = ControlTokenGuard::write(&record.session_id, "ctl1.token.mac").unwrap();
930 let record_guard = RunningGuard::write(&record).unwrap();
931 let dir = running_dir().unwrap();
932 let token_file = control_token_path(&record.session_id).unwrap();
933 assert_eq!(
934 token_file,
935 dir.join(format!("{}.control", record.session_id))
936 );
937 assert_eq!(crate::murmur_home::mode_of(&token_file), RECORD_FILE_MODE);
938 assert_eq!(
939 std::fs::read_to_string(&token_file).unwrap(),
940 "ctl1.token.mac"
941 );
942
943 prune(&record);
944 assert!(!record_path(&dir, &record.session_id).exists());
945 assert!(!token_file.exists());
946
947 std::mem::forget(token);
949 std::mem::forget(record_guard);
950 let _token = ControlTokenGuard::write(&record.session_id, "ctl1.token.mac").unwrap();
951 drop(RunningGuard::write(&record).unwrap());
952 assert!(!token_file.exists());
953 }
954
955 fn record(pid: u32, process_start: &str) -> RunningRecord {
956 RunningRecord {
957 session_id: "ses_0199c4e2f1b7712a9d3e4f5061728394".to_string(),
958 url: "127.0.0.1:1".to_string(),
959 pid,
960 process_start: process_start.to_string(),
961 capsule_name: "demo".to_string(),
962 capsule_version: "0.1.0".to_string(),
963 workdir: PathBuf::from("/tmp/demo"),
964 records_dir: PathBuf::from("/tmp/demo").join("records"),
965 outlives_launcher: true,
966 started_at: "2026-01-01T00:00:00Z".to_string(),
967 door_token: None,
968 credentials: BTreeMap::new(),
969 formation_id: None,
970 formation_member: None,
971 formation_lifeline: false,
972 formation_launcher: None,
973 spawned_by: None,
974 }
975 }
976
977 #[test]
980 fn a_record_serializes_exactly_the_ten_fields() {
981 let value = serde_json::to_value(record(1, "42")).unwrap();
982 let mut keys: Vec<&str> = value
983 .as_object()
984 .unwrap()
985 .keys()
986 .map(String::as_str)
987 .collect();
988 keys.sort_unstable();
989 assert_eq!(
990 keys,
991 [
992 "capsule_name",
993 "capsule_version",
994 "outlives_launcher",
995 "pid",
996 "process_start",
997 "records_dir",
998 "session_id",
999 "started_at",
1000 "url",
1001 "workdir",
1002 ]
1003 );
1004 }
1005
1006 #[test]
1007 fn a_record_with_a_door_token_serializes_it_beside_the_ten_fields() {
1008 let mut with_token = record(1, "42");
1009 with_token.door_token = Some(crate::door_auth::DoorToken::new(
1010 "mdt1.payload.mac".to_string(),
1011 ));
1012 let value = serde_json::to_value(&with_token).unwrap();
1013 let object = value.as_object().unwrap();
1014 assert_eq!(object.len(), 11);
1015 assert_eq!(object["door_token"], "mdt1.payload.mac");
1016 let back: RunningRecord = serde_json::from_value(value).unwrap();
1017 assert_eq!(back, with_token);
1018 }
1019
1020 #[test]
1021 fn a_record_debug_prints_no_door_token() {
1022 let mut with_token = record(1, "42");
1023 with_token.door_token = Some(crate::door_auth::DoorToken::new(
1024 "mdt1.secretpayload.secretmac".to_string(),
1025 ));
1026 let debug = format!("{with_token:?}");
1027 assert!(!debug.contains("secretpayload"), "{debug}");
1028 assert!(debug.contains("DoorToken(<redacted>)"), "{debug}");
1029 }
1030
1031 fn with_credentials() -> RunningRecord {
1032 let mut authenticated = record(1, "42");
1033 authenticated.door_token = Some(crate::door_auth::DoorToken::new(
1034 "mdt1.operatorpayload.mac".to_string(),
1035 ));
1036 for name in ["watcher", "reader"] {
1037 authenticated.credentials.insert(
1038 name.to_string(),
1039 crate::door_auth::DoorToken::new(format!("mdt1.{name}secret.mac")),
1040 );
1041 }
1042 authenticated
1043 }
1044
1045 #[test]
1046 fn a_record_with_credentials_serializes_them_as_an_object_by_name() {
1047 let authenticated = with_credentials();
1048 let value = serde_json::to_value(&authenticated).unwrap();
1049 let object = value.as_object().unwrap();
1050 assert_eq!(object.len(), 12);
1051 assert_eq!(
1052 object["credentials"],
1053 serde_json::json!({
1054 "reader": "mdt1.readersecret.mac",
1055 "watcher": "mdt1.watchersecret.mac",
1056 })
1057 );
1058 let back: RunningRecord = serde_json::from_value(value).unwrap();
1059 assert_eq!(back, authenticated);
1060 }
1061
1062 #[test]
1063 fn a_record_debug_prints_no_credential_token() {
1064 let debug = format!("{:?}", with_credentials());
1065 assert!(!debug.contains("secret"), "{debug}");
1066 assert!(!debug.contains("operatorpayload"), "{debug}");
1067 assert!(
1068 debug.contains("\"watcher\": DoorToken(<redacted>)"),
1069 "{debug}"
1070 );
1071 }
1072
1073 #[test]
1075 fn a_record_with_no_declared_credentials_omits_the_key() {
1076 let mut operator_only = record(1, "42");
1077 operator_only.door_token = Some(crate::door_auth::DoorToken::new(
1078 "mdt1.payload.mac".to_string(),
1079 ));
1080 let value = serde_json::to_value(&operator_only).unwrap();
1081 assert!(value.get("credentials").is_none(), "{value}");
1082 }
1083
1084 #[test]
1086 fn a_record_without_a_credentials_key_parses_as_empty() {
1087 let mut value = serde_json::to_value(with_credentials()).unwrap();
1088 value.as_object_mut().unwrap().remove("credentials");
1089 let parsed: RunningRecord = serde_json::from_value(value).unwrap();
1090 assert!(parsed.credentials.is_empty());
1091 assert!(parsed.door_token.is_some());
1092 }
1093
1094 #[test]
1096 fn a_record_without_a_door_token_key_parses() {
1097 let value = serde_json::to_value(record(1, "42")).unwrap();
1098 assert!(value.get("door_token").is_none());
1099 let parsed: RunningRecord = serde_json::from_value(value).unwrap();
1100 assert_eq!(parsed.door_token, None);
1101 }
1102
1103 #[test]
1104 fn a_member_record_serializes_its_formation_beside_the_ten_fields() {
1105 let formation = crate::formation::FormationId::mint();
1106 let mut member = record(1, "42");
1107 member.formation_id = Some(formation.clone());
1108 let value = serde_json::to_value(&member).unwrap();
1109 let object = value.as_object().unwrap();
1110 assert_eq!(object.len(), 11);
1111 assert_eq!(object["formation_id"], formation.as_str());
1112 let back: RunningRecord = serde_json::from_value(value).unwrap();
1113 assert_eq!(back, member);
1114 }
1115
1116 #[test]
1118 fn a_standalone_record_has_no_formation_key() {
1119 let body = serde_json::to_string_pretty(&record(1, "42")).unwrap();
1120 assert!(!body.contains("formation"), "{body}");
1121 let parsed: RunningRecord = serde_json::from_str(&body).unwrap();
1122 assert_eq!(parsed.formation_id, None);
1123 }
1124
1125 #[test]
1126 fn a_record_with_a_malformed_formation_id_does_not_parse() {
1127 let mut value = serde_json::to_value(record(1, "42")).unwrap();
1128 value
1129 .as_object_mut()
1130 .unwrap()
1131 .insert("formation_id".to_string(), "frm_ABC".into());
1132 assert!(serde_json::from_value::<RunningRecord>(value).is_err());
1133 }
1134
1135 #[test]
1136 fn list_prunes_a_record_with_a_malformed_formation_id() {
1137 let home = tempfile::tempdir().unwrap();
1138 crate::murmur_home::run_with_home(
1139 "running::tests::inner_list_prunes_a_record_with_a_malformed_formation_id",
1140 home.path(),
1141 );
1142 }
1143
1144 #[test]
1145 #[ignore = "run by list_prunes_a_record_with_a_malformed_formation_id"]
1146 fn inner_list_prunes_a_record_with_a_malformed_formation_id() {
1147 if !crate::murmur_home::in_scratch_home() {
1148 return;
1149 }
1150 let dir = running_dir().unwrap();
1151 let mut member = record(1, "42");
1152 member.formation_id = Some(crate::formation::FormationId::mint());
1153 let mut malformed = serde_json::to_value(record(2, "42")).unwrap();
1154 malformed["session_id"] = "ses_0199c4e2f1b7712a9d3e4f5061728395".into();
1155 malformed["formation_id"] = "frm_ABC".into();
1156 let member_path = record_path(&dir, &member.session_id);
1157 let malformed_path = dir.join("ses_0199c4e2f1b7712a9d3e4f5061728395.json");
1158 std::fs::write(&member_path, serde_json::to_vec_pretty(&member).unwrap()).unwrap();
1159 std::fs::write(
1160 &malformed_path,
1161 serde_json::to_vec_pretty(&malformed).unwrap(),
1162 )
1163 .unwrap();
1164
1165 assert_eq!(list().unwrap(), vec![member]);
1166 assert!(member_path.exists());
1167 assert!(!malformed_path.exists());
1168 }
1169
1170 #[test]
1171 fn a_member_record_serializes_its_roster_name_beside_its_formation() {
1172 let mut member = record(1, "42");
1173 member.formation_id = Some(crate::formation::FormationId::mint());
1174 member.formation_member = Some("w1".to_string());
1175 let value = serde_json::to_value(&member).unwrap();
1176 let object = value.as_object().unwrap();
1177 assert_eq!(object.len(), 12);
1178 assert_eq!(object["formation_member"], "w1");
1179 let back: RunningRecord = serde_json::from_value(value).unwrap();
1180 assert_eq!(back, member);
1181 }
1182
1183 #[test]
1184 fn a_record_without_a_formation_member_or_with_a_null_one_parses_to_none() {
1185 let mut value = serde_json::to_value(record(1, "42")).unwrap();
1186 assert!(value.get("formation_member").is_none());
1187 let parsed: RunningRecord = serde_json::from_value(value.clone()).unwrap();
1188 assert_eq!(parsed.formation_member, None);
1189 value["formation_member"] = serde_json::Value::Null;
1190 let parsed: RunningRecord = serde_json::from_value(value).unwrap();
1191 assert_eq!(parsed.formation_member, None);
1192 }
1193
1194 #[test]
1195 fn a_record_whose_formation_member_is_not_a_member_name_does_not_parse() {
1196 let too_long = format!("w{}", "1".repeat(32));
1197 let malformed: [serde_json::Value; 4] = [7.into(), "".into(), "W1".into(), too_long.into()];
1198 for bad in malformed {
1199 let mut value = serde_json::to_value(record(1, "42")).unwrap();
1200 value["formation_member"] = bad.clone();
1201 assert!(
1202 serde_json::from_value::<RunningRecord>(value).is_err(),
1203 "formation_member {bad} parsed"
1204 );
1205 }
1206 }
1207
1208 #[test]
1209 fn list_prunes_a_record_with_a_malformed_formation_member() {
1210 let home = tempfile::tempdir().unwrap();
1211 crate::murmur_home::run_with_home(
1212 "running::tests::inner_list_prunes_a_record_with_a_malformed_formation_member",
1213 home.path(),
1214 );
1215 }
1216
1217 #[test]
1218 #[ignore = "run by list_prunes_a_record_with_a_malformed_formation_member"]
1219 fn inner_list_prunes_a_record_with_a_malformed_formation_member() {
1220 if !crate::murmur_home::in_scratch_home() {
1221 return;
1222 }
1223 let dir = running_dir().unwrap();
1224 let mut member = record(1, "42");
1225 member.formation_id = Some(crate::formation::FormationId::mint());
1226 member.formation_member = Some("w1".to_string());
1227 let mut malformed = serde_json::to_value(&member).unwrap();
1228 malformed["session_id"] = "ses_0199c4e2f1b7712a9d3e4f5061728395".into();
1229 malformed["formation_member"] = 7.into();
1230 let member_path = record_path(&dir, &member.session_id);
1231 let malformed_path = dir.join("ses_0199c4e2f1b7712a9d3e4f5061728395.json");
1232 std::fs::write(&member_path, serde_json::to_vec_pretty(&member).unwrap()).unwrap();
1233 std::fs::write(
1234 &malformed_path,
1235 serde_json::to_vec_pretty(&malformed).unwrap(),
1236 )
1237 .unwrap();
1238
1239 assert_eq!(list().unwrap(), vec![member]);
1240 assert!(member_path.exists());
1241 assert!(!malformed_path.exists());
1242 }
1243
1244 fn launched_member() -> RunningRecord {
1245 let mut member = record(1, "42");
1246 member.formation_id = Some(crate::formation::FormationId::mint());
1247 member.formation_lifeline = true;
1248 member.formation_launcher = Some(ProcessIdentity {
1249 pid: 4242,
1250 process_start: "987654".to_string(),
1251 });
1252 member.spawned_by = Some("ses_0199c4e2f1b7712a9d3e4f5061728390".to_string());
1253 member
1254 }
1255
1256 #[test]
1257 fn a_launched_members_record_round_trips_its_launcher_and_lineage() {
1258 let member = launched_member();
1259 let value = serde_json::to_value(&member).unwrap();
1260 assert_eq!(value["formation_lifeline"], true);
1261 assert_eq!(
1262 value["formation_launcher"],
1263 serde_json::json!({"pid": 4242, "process_start": "987654"})
1264 );
1265 assert_eq!(value["spawned_by"], "ses_0199c4e2f1b7712a9d3e4f5061728390");
1266 let back: RunningRecord = serde_json::from_value(value).unwrap();
1267 assert_eq!(back, member);
1268 }
1269
1270 #[test]
1273 fn a_record_holding_no_lifeline_launcher_or_spawner_omits_all_three_keys() {
1274 let mut member = launched_member();
1275 member.formation_lifeline = false;
1276 member.formation_launcher = None;
1277 member.spawned_by = None;
1278 let body = serde_json::to_string_pretty(&member).unwrap();
1279 for key in ["formation_lifeline", "formation_launcher", "spawned_by"] {
1280 assert!(!body.contains(key), "{key} in {body}");
1281 }
1282 assert_eq!(
1283 serde_json::from_str::<RunningRecord>(&body).unwrap(),
1284 member
1285 );
1286 }
1287
1288 #[test]
1289 fn a_record_with_a_malformed_formation_launcher_does_not_parse() {
1290 for launcher in [
1291 serde_json::json!(4242),
1292 serde_json::json!({"pid": 4242}),
1293 serde_json::json!({"process_start": "1"}),
1294 serde_json::json!({"pid": -1, "process_start": "1"}),
1295 serde_json::json!({"pid": "4242", "process_start": "1"}),
1296 ] {
1297 let mut value = serde_json::to_value(record(1, "42")).unwrap();
1298 value["formation_launcher"] = launcher.clone();
1299 assert!(
1300 serde_json::from_value::<RunningRecord>(value).is_err(),
1301 "{launcher}"
1302 );
1303 }
1304 }
1305
1306 #[test]
1309 fn an_identity_reads_and_signals_as_the_record_naming_the_same_process() {
1310 let pid = std::process::id();
1311 let token = process_start_token(pid).unwrap();
1312 for start in [token.as_str(), "not-this-process", ""] {
1313 let record = record(pid, start);
1314 let identity = ProcessIdentity {
1315 pid,
1316 process_start: start.to_string(),
1317 };
1318 assert_eq!(identity_state(&identity), process_state(&record), "{start}");
1319 }
1320 let stale = ProcessIdentity {
1322 pid,
1323 process_start: "not-this-process".to_string(),
1324 };
1325 assert_eq!(
1326 signal_term_process(&stale),
1327 signal_term(&record(pid, "not-this-process"))
1328 );
1329 assert_eq!(signal_kill_process(&stale), SignalOutcome::AlreadyGone);
1330 let nobody = ProcessIdentity {
1331 pid: 0,
1332 process_start: "42".to_string(),
1333 };
1334 assert_eq!(signal_term_process(&nobody), signal_term(&record(0, "42")));
1335 assert_eq!(
1336 ProcessIdentity::of_this_process(),
1337 Some(ProcessIdentity {
1338 pid,
1339 process_start: token
1340 })
1341 );
1342 }
1343
1344 #[test]
1346 fn a_term_sent_through_an_identity_reaches_the_process() {
1347 let mut child = std::process::Command::new("sleep")
1348 .arg("30")
1349 .spawn()
1350 .expect("sleep should start");
1351 let pid = child.id();
1352 let identity = ProcessIdentity {
1353 pid,
1354 process_start: process_start_token(pid).unwrap(),
1355 };
1356 assert_eq!(identity_state(&identity), ProcessState::Alive);
1357 assert_eq!(signal_term_process(&identity), SignalOutcome::Sent);
1358 let status = child.wait().unwrap();
1359 use std::os::unix::process::ExitStatusExt;
1360 assert_eq!(status.signal(), Some(libc::SIGTERM));
1361 assert!(matches!(identity_state(&identity), ProcessState::Gone(_)));
1362 }
1363
1364 #[test]
1367 fn a_record_missing_a_field_does_not_parse() {
1368 let mut value = serde_json::to_value(record(1, "42")).unwrap();
1369 value.as_object_mut().unwrap().remove("process_start");
1370 assert!(serde_json::from_value::<RunningRecord>(value).is_err());
1371 }
1372
1373 #[test]
1375 fn this_process_reads_as_alive() {
1376 let pid = std::process::id();
1377 let token = process_start_token(pid).expect("this process has a start time");
1378 assert_eq!(process_state(&record(pid, &token)), ProcessState::Alive);
1379 }
1380
1381 #[test]
1383 fn a_start_time_that_disagrees_reads_as_gone() {
1384 let pid = std::process::id();
1385 let state = process_state(&record(pid, "not-this-process"));
1386 match state {
1387 ProcessState::Gone(reason) => assert!(
1388 reason.contains("another time"),
1389 "the reason must say the pid belongs to another process now: {reason}"
1390 ),
1391 other => panic!("a mismatched start time must read as gone, not {other:?}"),
1392 }
1393 }
1394
1395 #[test]
1397 fn a_pid_nothing_holds_reads_as_gone() {
1398 let state = process_state(&record(0, "42"));
1400 assert!(matches!(state, ProcessState::Gone(_)));
1401 }
1402
1403 #[test]
1405 fn the_start_token_is_stable_for_one_process() {
1406 let pid = std::process::id();
1407 assert_eq!(process_start_token(pid), process_start_token(pid));
1408 assert!(process_start_token(pid).is_some_and(|token| !token.is_empty()));
1409 }
1410
1411 #[test]
1414 fn a_zombie_does_not_read_as_alive() {
1415 let mut child = std::process::Command::new("sh")
1416 .args(["-c", "exit 0"])
1417 .spawn()
1418 .expect("sh should start");
1419 let pid = child.id();
1420 let token = process_start_token(pid).expect("a live child has a start time");
1421
1422 let deadline = std::time::Instant::now() + Duration::from_secs(10);
1424 loop {
1425 if matches!(process_state(&record(pid, &token)), ProcessState::Gone(_)) {
1426 break;
1427 }
1428 assert!(
1429 std::time::Instant::now() < deadline,
1430 "a zombie kept reading as alive"
1431 );
1432 std::thread::sleep(Duration::from_millis(50));
1433 }
1434 let _ = child.wait();
1435 }
1436
1437 #[test]
1440 fn a_signal_at_a_mismatched_start_time_is_not_sent() {
1441 let record = record(std::process::id(), "not-this-process");
1443 assert_eq!(signal_kill(&record), SignalOutcome::AlreadyGone);
1444 assert_eq!(signal_term(&record), SignalOutcome::AlreadyGone);
1445 }
1446
1447 #[test]
1450 fn a_signal_at_a_pid_nothing_holds_is_already_gone() {
1451 assert_eq!(signal_term(&record(0, "42")), SignalOutcome::AlreadyGone);
1452 }
1453
1454 #[test]
1457 fn a_live_process_behind_a_silent_port_is_unreachable() {
1458 let pid = std::process::id();
1459 let token = process_start_token(pid).unwrap();
1460 let mut record = record(pid, &token);
1461 let port = crate::pinned_port::claim();
1463 record.url = format!("127.0.0.1:{port}");
1464 assert!(matches!(verify(&record), Liveness::Unreachable(_)));
1465 }
1466
1467 const PID: u32 = 4242;
1468
1469 #[test]
1470 fn classify_a_dead_pid_is_gone_whatever_the_token() {
1471 for token in [None, Some("123"), Some("")] {
1472 assert_eq!(
1473 classify(PID, "123", false, token),
1474 ProcessState::Gone("no process holds pid 4242".to_string()),
1475 "token {token:?}"
1476 );
1477 }
1478 }
1479
1480 #[test]
1481 fn classify_a_live_pid_with_the_recorded_token_is_alive() {
1482 assert_eq!(classify(PID, "123", true, Some("123")), ProcessState::Alive);
1483 }
1484
1485 #[test]
1486 fn classify_a_live_pid_with_another_token_is_gone() {
1487 assert_eq!(
1488 classify(PID, "123", true, Some("456")),
1489 ProcessState::Gone("pid 4242 is held by a process that started at another time".into())
1490 );
1491 }
1492
1493 #[test]
1495 fn classify_a_recorded_empty_token_against_a_fresh_one_is_gone() {
1496 assert!(matches!(
1497 classify(PID, "", true, Some("123")),
1498 ProcessState::Gone(reason) if reason.contains("another time")
1499 ));
1500 }
1501
1502 #[test]
1503 fn classify_a_live_pid_whose_token_cannot_be_read_is_unverified() {
1504 assert_eq!(
1505 classify(PID, "123", true, None),
1506 ProcessState::Unverified("pid 4242's start time could not be read".into())
1507 );
1508 }
1509
1510 #[test]
1511 fn nothing_is_refused_before_signalling_a_confirmed_process() {
1512 assert_eq!(refusal_before_signal(&ProcessState::Alive), None);
1513 }
1514
1515 #[test]
1516 fn a_gone_process_is_already_gone_before_any_signal() {
1517 assert_eq!(
1518 refusal_before_signal(&ProcessState::Gone("no process holds pid 1".into())),
1519 Some(SignalOutcome::AlreadyGone)
1520 );
1521 }
1522
1523 #[test]
1524 fn an_unverified_process_is_refused_before_any_signal() {
1525 let reason = "pid 1's start time could not be read".to_string();
1526 assert_eq!(
1527 refusal_before_signal(&ProcessState::Unverified(reason.clone())),
1528 Some(SignalOutcome::Refused(reason))
1529 );
1530 }
1531
1532 #[test]
1535 fn an_unverified_process_is_unreachable_without_a_probe() {
1536 let reason = "pid 1's start time could not be read".to_string();
1537 assert_eq!(
1538 liveness_without_probe(ProcessState::Unverified(reason.clone())),
1539 Some(Liveness::Unreachable(reason))
1540 );
1541 assert_eq!(
1542 liveness_without_probe(ProcessState::Gone("gone".into())),
1543 Some(Liveness::Gone("gone".into()))
1544 );
1545 assert_eq!(liveness_without_probe(ProcessState::Alive), None);
1546 }
1547
1548 #[cfg(target_os = "linux")]
1550 fn stat_line(comm: &str) -> String {
1551 let fields: Vec<String> = (3..=21).map(|field| field.to_string()).collect();
1552 format!("1234 ({comm}) {} 987654 23 24\n", fields.join(" "))
1553 }
1554
1555 #[cfg(target_os = "linux")]
1556 #[test]
1557 fn the_start_token_is_field_22_of_a_plain_stat_line() {
1558 assert_eq!(
1559 platform::start_token_from_stat(&stat_line("sleep")),
1560 Some("987654".to_string())
1561 );
1562 }
1563
1564 #[cfg(target_os = "linux")]
1565 #[test]
1566 fn the_start_token_is_counted_from_the_last_close_paren() {
1567 assert_eq!(
1568 platform::start_token_from_stat(&stat_line("a) (b c")),
1569 Some("987654".to_string())
1570 );
1571 }
1572
1573 #[cfg(target_os = "linux")]
1574 #[test]
1575 fn a_stat_line_with_no_close_paren_has_no_start_token() {
1576 assert_eq!(platform::start_token_from_stat("1234 sleep S 1 2 3"), None);
1577 }
1578
1579 #[cfg(target_os = "linux")]
1580 #[test]
1581 fn a_stat_line_truncated_before_field_22_has_no_start_token() {
1582 let line = stat_line("sleep");
1583 let truncated = &line[..line.find(" 987654").unwrap()];
1584 assert_eq!(platform::start_token_from_stat(truncated), None);
1585 }
1586}
1587
1588#[cfg(test)]
1589mod home_tests {
1590 use super::*;
1591
1592 #[test]
1593 fn running_dir_holds_a_wide_murmur_home_owner_only() {
1594 let home = tempfile::tempdir().unwrap();
1595 crate::murmur_home::wide_dir(&home.path().join(".murmur"), 0o755);
1596 crate::murmur_home::run_with_home(
1597 "running::home_tests::inner_running_dir_under_scratch_home",
1598 home.path(),
1599 );
1600 assert_eq!(
1601 crate::murmur_home::mode_of(&home.path().join(".murmur")),
1602 0o700
1603 );
1604 assert_eq!(
1605 crate::murmur_home::mode_of(&home.path().join(".murmur").join(RUNNING_DIR)),
1606 RUNNING_DIR_MODE
1607 );
1608 }
1609
1610 #[test]
1611 #[ignore = "run by running_dir_holds_a_wide_murmur_home_owner_only"]
1612 fn inner_running_dir_under_scratch_home() {
1613 if !crate::murmur_home::in_scratch_home() {
1614 return;
1615 }
1616 running_dir().unwrap();
1617 }
1618
1619 #[test]
1620 fn running_dir_location_creates_nothing() {
1621 let home = tempfile::tempdir().unwrap();
1622 crate::murmur_home::run_with_home(
1623 "running::home_tests::inner_running_dir_location_creates_nothing",
1624 home.path(),
1625 );
1626 assert!(!home.path().join(".murmur").exists());
1627 }
1628
1629 #[test]
1630 #[ignore = "run by running_dir_location_creates_nothing"]
1631 fn inner_running_dir_location_creates_nothing() {
1632 if !crate::murmur_home::in_scratch_home() {
1633 return;
1634 }
1635 let home = PathBuf::from(std::env::var_os("HOME").unwrap());
1636 assert_eq!(
1637 running_dir_location().unwrap(),
1638 home.join(".murmur").join(RUNNING_DIR)
1639 );
1640 assert!(!home.join(".murmur").exists());
1641 }
1642
1643 #[test]
1646 fn the_door_probe_names_a2a_1_0_on_both_cards() {
1647 use crate::http_client::capture::answer_one;
1648
1649 let card = serde_json::json!({"capabilities": {"extensions": [{
1650 "uri": crate::identity::CAPSULE_EXTENSION_URI,
1651 "params": {"sessionId": "ses_probe"}}]}});
1652 let (addr, sent) =
1653 answer_one(serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": card}).to_string());
1654 let token = crate::door_auth::DoorToken::new("tok".to_string());
1655 assert_eq!(
1656 probe_door_session_id(&addr, Some(&token)).unwrap(),
1657 "ses_probe"
1658 );
1659 let sent = sent.join().unwrap();
1660 assert_eq!(sent.request_line, "POST / HTTP/1.1");
1661 assert_eq!(sent.method(), "GetExtendedAgentCard");
1662 assert_eq!(sent.header("a2a-version"), ["1.0"]);
1663
1664 let (addr, sent) = answer_one(card.to_string());
1665 assert_eq!(probe_door_session_id(&addr, None).unwrap(), "ses_probe");
1666 let sent = sent.join().unwrap();
1667 assert_eq!(
1668 sent.request_line,
1669 "GET /.well-known/agent-card.json HTTP/1.1"
1670 );
1671 assert_eq!(sent.header("a2a-version"), ["1.0"]);
1672 }
1673
1674 fn record(session_id: &str) -> RunningRecord {
1675 RunningRecord {
1676 session_id: session_id.to_string(),
1677 url: "127.0.0.1:1".to_string(),
1678 pid: 1,
1679 process_start: "42".to_string(),
1680 capsule_name: "demo".to_string(),
1681 capsule_version: "0.1.0".to_string(),
1682 workdir: PathBuf::from("/tmp/demo"),
1683 records_dir: PathBuf::from("/tmp/demo").join("records"),
1684 outlives_launcher: true,
1685 started_at: "2026-01-01T00:00:00Z".to_string(),
1686 door_token: None,
1687 credentials: BTreeMap::new(),
1688 formation_id: None,
1689 formation_member: None,
1690 formation_lifeline: false,
1691 formation_launcher: None,
1692 spawned_by: None,
1693 }
1694 }
1695
1696 #[test]
1697 fn scan_reports_an_unparseable_file_and_leaves_it_in_place() {
1698 let dir = tempfile::tempdir().unwrap();
1699 let older = record("ses_0199c4e2f1b7712a9d3e4f5061728390");
1700 let newer = record("ses_0199c4e2f1b7712a9d3e4f5061728391");
1701 for record in [&older, &newer] {
1702 std::fs::write(
1703 record_path(dir.path(), &record.session_id),
1704 serde_json::to_vec(record).unwrap(),
1705 )
1706 .unwrap();
1707 }
1708 let broken = dir.path().join("ses_broken.json");
1709 std::fs::write(&broken, "{").unwrap();
1710 let staging = dir.path().join(".ses_x.json.1.2.tmp");
1711 std::fs::write(&staging, "{").unwrap();
1712 let control = token_path(dir.path(), &older.session_id);
1713 std::fs::write(&control, "ctl1.token.mac").unwrap();
1714
1715 let scan = scan(dir.path()).unwrap();
1716
1717 assert_eq!(scan.records, vec![newer, older]);
1718 assert_eq!(scan.unreadable, vec![broken.clone()]);
1719 assert_eq!(std::fs::read_to_string(&broken).unwrap(), "{");
1720 assert!(staging.exists());
1721 assert!(control.exists());
1722 }
1723
1724 fn mixed_running_dir() -> (tempfile::TempDir, RunningRecord, PathBuf, [PathBuf; 2]) {
1727 let dir = tempfile::tempdir().unwrap();
1728 let current = record("ses_0199c4e2f1b7712a9d3e4f5061728391");
1729 std::fs::write(
1730 record_path(dir.path(), ¤t.session_id),
1731 serde_json::to_vec(¤t).unwrap(),
1732 )
1733 .unwrap();
1734 let older_mur = record_path(dir.path(), "ses_0199c4e2f1b7712a9d3e4f5061728390");
1735 std::fs::write(
1736 &older_mur,
1737 serde_json::json!({
1738 "session_id": "ses_0199c4e2f1b7712a9d3e4f5061728390",
1739 "url": "127.0.0.1:1",
1740 "pid": 1,
1741 "process_start": "42",
1742 "capsule_name": "demo",
1743 "capsule_version": "0.1.0",
1744 "workdir": "/tmp/demo",
1745 "outlives_launcher": true,
1746 "started_at": "2026-01-01T00:00:00Z",
1747 })
1748 .to_string(),
1749 )
1750 .unwrap();
1751 let truncated = dir.path().join("ses_broken.json");
1752 std::fs::write(&truncated, "{").unwrap();
1753 let mut bad_pid = serde_json::to_value(record("ses_bad_pid")).unwrap();
1754 bad_pid["pid"] = serde_json::json!("one");
1755 let bad_pid_path = dir.path().join("ses_bad_pid.json");
1756 std::fs::write(&bad_pid_path, bad_pid.to_string()).unwrap();
1757 (dir, current, older_mur, [truncated, bad_pid_path])
1758 }
1759
1760 #[test]
1761 fn scan_names_a_record_an_older_mur_wrote_apart_from_unreadable_files() {
1762 let (dir, current, older_mur, unreadable) = mixed_running_dir();
1763 let before = std::fs::read(&older_mur).unwrap();
1764
1765 let mut scan = scan(dir.path()).unwrap();
1766
1767 scan.unreadable.sort();
1768 let mut expected_unreadable = unreadable.to_vec();
1769 expected_unreadable.sort();
1770 assert_eq!(scan.records, vec![current]);
1771 assert_eq!(scan.older_mur, vec![older_mur.clone()]);
1772 assert_eq!(scan.unreadable, expected_unreadable);
1773 assert_eq!(std::fs::read(&older_mur).unwrap(), before);
1774 assert!(unreadable.iter().all(|path| path.exists()));
1775 }
1776
1777 #[test]
1778 fn listing_unlinks_only_the_unreadable_files() {
1779 let (dir, current, older_mur, unreadable) = mixed_running_dir();
1780 let before = std::fs::read(&older_mur).unwrap();
1781
1782 let mut listing = list_pruning(dir.path()).unwrap();
1783
1784 listing.removed_unreadable.sort();
1785 let mut expected_removed = unreadable.to_vec();
1786 expected_removed.sort();
1787 assert_eq!(listing.records, vec![current]);
1788 assert_eq!(listing.older_mur, vec![older_mur.clone()]);
1789 assert_eq!(listing.removed_unreadable, expected_removed);
1790 assert_eq!(std::fs::read(&older_mur).unwrap(), before);
1791 assert!(unreadable.iter().all(|path| !path.exists()));
1792 }
1793
1794 #[test]
1795 fn an_object_without_a_string_session_id_is_unreadable() {
1796 let dir = tempfile::tempdir().unwrap();
1797 for (name, body) in [
1798 ("ses_array.json", "[]"),
1799 ("ses_no_id.json", r#"{"pid": 1}"#),
1800 ("ses_number_id.json", r#"{"session_id": 7}"#),
1801 ] {
1802 std::fs::write(dir.path().join(name), body).unwrap();
1803 }
1804
1805 let scan = scan(dir.path()).unwrap();
1806
1807 assert!(scan.older_mur.is_empty());
1808 assert_eq!(scan.unreadable.len(), 3);
1809 }
1810
1811 #[test]
1812 fn scan_of_a_missing_directory_is_empty_and_creates_nothing() {
1813 let parent = tempfile::tempdir().unwrap();
1814 let dir = parent.path().join(".murmur").join(RUNNING_DIR);
1815
1816 let scan = scan(&dir).unwrap();
1817
1818 assert!(scan.records.is_empty());
1819 assert!(scan.unreadable.is_empty());
1820 assert!(!parent.path().join(".murmur").exists());
1821 }
1822
1823 #[test]
1824 fn scan_of_a_regular_file_errs_naming_it() {
1825 let parent = tempfile::tempdir().unwrap();
1826 let file = parent.path().join(RUNNING_DIR);
1827 std::fs::write(&file, "").unwrap();
1828
1829 let err = scan(&file).unwrap_err();
1830
1831 assert!(err.starts_with(&format!("{}: ", file.display())), "{err}");
1832 }
1833}