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,
87 pub records_dir: PathBuf,
93 pub outlives_launcher: bool,
96 pub started_at: String,
98 #[serde(
102 default,
103 skip_serializing_if = "Option::is_none",
104 with = "door_token_field"
105 )]
106 pub door_token: Option<crate::door_auth::DoorToken>,
107 #[serde(
113 default,
114 skip_serializing_if = "BTreeMap::is_empty",
115 with = "door_credentials_field"
116 )]
117 pub credentials: BTreeMap<String, crate::door_auth::DoorToken>,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub formation_id: Option<crate::formation::FormationId>,
124 #[serde(
131 default,
132 skip_serializing_if = "Option::is_none",
133 deserialize_with = "member_name_field::deserialize"
134 )]
135 pub formation_member: Option<String>,
136 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
141 pub formation_entry: bool,
142 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
145 pub formation_lifeline: bool,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub formation_launcher: Option<ProcessIdentity>,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub spawned_by: Option<String>,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct ProcessIdentity {
162 pub pid: u32,
163 pub process_start: String,
164}
165
166impl ProcessIdentity {
167 pub fn of_this_process() -> Option<Self> {
170 let pid = std::process::id();
171 Some(Self {
172 pid,
173 process_start: process_start_token(pid)?,
174 })
175 }
176}
177
178mod door_token_field {
181 use crate::door_auth::DoorToken;
182 use serde::{Deserialize, Deserializer, Serializer};
183
184 pub(super) fn serialize<S: Serializer>(
185 token: &Option<DoorToken>,
186 serializer: S,
187 ) -> Result<S::Ok, S::Error> {
188 match token {
189 Some(token) => serializer.serialize_some(token.expose()),
190 None => serializer.serialize_none(),
191 }
192 }
193
194 pub(super) fn deserialize<'de, D: Deserializer<'de>>(
195 deserializer: D,
196 ) -> Result<Option<DoorToken>, D::Error> {
197 Ok(Option::<String>::deserialize(deserializer)?.map(DoorToken::new))
198 }
199}
200
201mod door_credentials_field {
205 use std::collections::BTreeMap;
206
207 use crate::door_auth::DoorToken;
208 use serde::{ser::SerializeMap, Deserialize, Deserializer, Serializer};
209
210 pub(super) fn serialize<S: Serializer>(
211 credentials: &BTreeMap<String, DoorToken>,
212 serializer: S,
213 ) -> Result<S::Ok, S::Error> {
214 let mut map = serializer.serialize_map(Some(credentials.len()))?;
215 for (name, token) in credentials {
216 map.serialize_entry(name, token.expose())?;
217 }
218 map.end()
219 }
220
221 pub(super) fn deserialize<'de, D: Deserializer<'de>>(
222 deserializer: D,
223 ) -> Result<BTreeMap<String, DoorToken>, D::Error> {
224 Ok(BTreeMap::<String, String>::deserialize(deserializer)?
225 .into_iter()
226 .map(|(name, token)| (name, DoorToken::new(token)))
227 .collect())
228 }
229}
230
231mod member_name_field {
234 use serde::{de::Error, Deserialize, Deserializer};
235
236 pub(super) fn deserialize<'de, D: Deserializer<'de>>(
237 deserializer: D,
238 ) -> Result<Option<String>, D::Error> {
239 let Some(name) = Option::<String>::deserialize(deserializer)? else {
240 return Ok(None);
241 };
242 match murmur_artifact::member_name_format_error(&name) {
243 Some(reason) => Err(D::Error::custom(format!(
244 "formation_member {name:?} is not a member name: it {reason}"
245 ))),
246 None => Ok(Some(name)),
247 }
248 }
249}
250
251pub fn running_dir() -> Result<PathBuf, String> {
258 let dir = crate::murmur_home::ensure_murmur_home()?.join(RUNNING_DIR);
259 crate::state_store::ensure_private_dir(&dir, RUNNING_DIR_MODE)
260 .map_err(|reason| format!("{}: {reason}", dir.display()))?;
261 Ok(dir)
262}
263
264pub fn running_dir_location() -> Result<PathBuf, String> {
271 Ok(crate::state_store::murmur_home_dir()?.join(RUNNING_DIR))
272}
273
274pub fn record_location(session_id: &str) -> Result<PathBuf, String> {
279 Ok(record_path(&running_dir_location()?, session_id))
280}
281
282#[derive(Debug, Default)]
284pub struct RecordScan {
285 pub records: Vec<RunningRecord>,
287 pub older_mur: Vec<PathBuf>,
291 pub unreadable: Vec<PathBuf>,
293}
294
295#[derive(Debug)]
297enum RecordRead {
298 Record(Box<RunningRecord>),
299 OlderMur,
301 Unreadable,
302}
303
304pub fn scan(dir: &Path) -> Result<RecordScan, String> {
315 let entries = match std::fs::read_dir(dir) {
316 Ok(entries) => entries,
317 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(RecordScan::default()),
318 Err(err) => return Err(format!("{}: {err}", dir.display())),
319 };
320
321 let mut scan = RecordScan::default();
322 for entry in entries.flatten() {
323 let path = entry.path();
324 if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
325 continue;
326 }
327 match read_record(&path) {
328 RecordRead::Record(record) => scan.records.push(*record),
329 RecordRead::OlderMur => scan.older_mur.push(path),
330 RecordRead::Unreadable => scan.unreadable.push(path),
331 }
332 }
333 scan.records
334 .sort_by(|left, right| right.session_id.cmp(&left.session_id));
335 scan.older_mur.sort_by(|left, right| right.cmp(left));
336 Ok(scan)
337}
338
339pub fn list() -> Result<Vec<RunningRecord>, String> {
350 list_reporting().map(|listing| listing.records)
351}
352
353#[derive(Debug, Default)]
355pub struct RecordListing {
356 pub records: Vec<RunningRecord>,
358 pub older_mur: Vec<PathBuf>,
361 pub removed_unreadable: Vec<PathBuf>,
364}
365
366pub fn list_reporting() -> Result<RecordListing, String> {
371 list_pruning(&running_dir()?)
372}
373
374fn list_pruning(dir: &Path) -> Result<RecordListing, String> {
376 let scan = scan(dir)?;
377 let removed_unreadable = scan
378 .unreadable
379 .into_iter()
380 .filter(|path| std::fs::remove_file(path).is_ok())
381 .collect();
382 Ok(RecordListing {
383 records: scan.records,
384 older_mur: scan.older_mur,
385 removed_unreadable,
386 })
387}
388
389#[derive(Debug, Clone, PartialEq, Eq)]
394pub enum ProcessState {
395 Alive,
397 Unverified(String),
400 Gone(String),
403}
404
405impl ProcessState {
406 #[must_use]
412 pub fn is_running(&self) -> bool {
413 matches!(self, ProcessState::Alive | ProcessState::Unverified(_))
414 }
415}
416
417pub fn process_state(record: &RunningRecord) -> ProcessState {
423 process_state_of(record.pid, &record.process_start)
424}
425
426pub fn identity_state(identity: &ProcessIdentity) -> ProcessState {
428 process_state_of(identity.pid, &identity.process_start)
429}
430
431impl RunningRecord {
432 pub fn identity(&self) -> ProcessIdentity {
434 ProcessIdentity {
435 pid: self.pid,
436 process_start: self.process_start.clone(),
437 }
438 }
439}
440
441pub(crate) fn process_state_of(pid: u32, recorded_start: &str) -> ProcessState {
444 let alive = pid_is_alive(pid);
445 let token = alive.then(|| process_start_token(pid)).flatten();
446 classify(pid, recorded_start, alive, token.as_deref())
447}
448
449fn classify(
455 pid: u32,
456 recorded_start: &str,
457 pid_alive: bool,
458 fresh_token: Option<&str>,
459) -> ProcessState {
460 if !pid_alive {
461 return ProcessState::Gone(format!("no process holds pid {pid}"));
462 }
463 match fresh_token {
464 Some(token) if token == recorded_start => ProcessState::Alive,
465 Some(_) => ProcessState::Gone(format!(
466 "pid {pid} is held by a process that started at another time"
467 )),
468 None => ProcessState::Unverified(format!("pid {pid}'s start time could not be read")),
469 }
470}
471
472#[derive(Debug, Clone, PartialEq, Eq)]
474pub enum Liveness {
475 Live,
477 Unreachable(String),
480 Gone(String),
482}
483
484impl Liveness {
485 #[must_use]
491 pub fn is_running(&self) -> bool {
492 !matches!(self, Liveness::Gone(_))
493 }
494
495 pub fn reason(&self) -> &str {
497 match self {
498 Liveness::Live => "",
499 Liveness::Unreachable(reason) | Liveness::Gone(reason) => reason,
500 }
501 }
502}
503
504pub fn verify(record: &RunningRecord) -> Liveness {
510 if let Some(liveness) = liveness_without_probe(process_state(record)) {
511 return liveness;
512 }
513 match probe_session_id(record) {
514 Ok(session_id) if session_id == record.session_id => Liveness::Live,
515 Ok(other) => Liveness::Unreachable(format!(
516 "the capsule at {} answers for session {other}",
517 record.url
518 )),
519 Err(reason) => Liveness::Unreachable(reason),
520 }
521}
522
523fn liveness_without_probe(state: ProcessState) -> Option<Liveness> {
526 match state {
527 ProcessState::Alive => None,
528 ProcessState::Unverified(reason) => Some(Liveness::Unreachable(reason)),
529 ProcessState::Gone(reason) => Some(Liveness::Gone(reason)),
530 }
531}
532
533#[derive(Debug, Clone, PartialEq, Eq)]
535pub enum SignalOutcome {
536 Sent,
538 AlreadyGone,
540 Refused(String),
544}
545
546pub fn signal_term(record: &RunningRecord) -> SignalOutcome {
548 signal(&record.identity(), libc::SIGTERM)
549}
550
551pub fn signal_kill(record: &RunningRecord) -> SignalOutcome {
553 signal(&record.identity(), libc::SIGKILL)
554}
555
556pub fn signal_term_process(identity: &ProcessIdentity) -> SignalOutcome {
558 signal(identity, libc::SIGTERM)
559}
560
561pub fn signal_kill_process(identity: &ProcessIdentity) -> SignalOutcome {
564 signal(identity, libc::SIGKILL)
565}
566
567#[allow(unsafe_code)]
575fn signal(identity: &ProcessIdentity, signal: libc::c_int) -> SignalOutcome {
576 if let Some(outcome) = refusal_before_signal(&identity_state(identity)) {
577 return outcome;
578 }
579 if unsafe { libc::kill(identity.pid as libc::pid_t, signal) } == 0 {
584 return SignalOutcome::Sent;
585 }
586 let err = std::io::Error::last_os_error();
587 if err.raw_os_error() == Some(libc::ESRCH) {
590 return SignalOutcome::AlreadyGone;
591 }
592 SignalOutcome::Refused(err.to_string())
593}
594
595fn refusal_before_signal(state: &ProcessState) -> Option<SignalOutcome> {
601 match state {
602 ProcessState::Alive => None,
603 ProcessState::Unverified(reason) => Some(SignalOutcome::Refused(reason.clone())),
604 ProcessState::Gone(_) => Some(SignalOutcome::AlreadyGone),
605 }
606}
607
608pub fn prune(record: &RunningRecord) {
611 if let Ok(dir) = running_dir() {
612 let _ = std::fs::remove_file(record_path(&dir, &record.session_id));
613 let _ = std::fs::remove_file(token_path(&dir, &record.session_id));
614 }
615}
616
617pub fn control_token_path(session_id: &str) -> Result<PathBuf, String> {
622 Ok(token_path(&running_dir()?, session_id))
623}
624
625pub struct ControlTokenGuard {
630 path: PathBuf,
631}
632
633impl ControlTokenGuard {
634 pub fn write(session_id: &str, token: &str) -> Result<Self, String> {
639 let path = control_token_path(session_id)?;
640 crate::retention::StagedRewrite::stage_with_mode(
641 &path,
642 token.as_bytes(),
643 Some(RECORD_FILE_MODE),
644 )
645 .and_then(crate::retention::StagedRewrite::commit)
646 .map_err(|reason| format!("failed to write {}: {reason}", path.display()))?;
647 Ok(Self { path })
648 }
649}
650
651impl Drop for ControlTokenGuard {
652 fn drop(&mut self) {
653 let _ = std::fs::remove_file(&self.path);
654 }
655}
656
657pub struct RunningGuard {
663 path: PathBuf,
664}
665
666impl RunningGuard {
667 pub fn write(record: &RunningRecord) -> Result<Self, String> {
673 let dir = running_dir()?;
674 let path = record_path(&dir, &record.session_id);
675 let body = serde_json::to_vec_pretty(record)
676 .map_err(|err| format!("the record could not be serialized: {err}"))?;
677
678 crate::retention::StagedRewrite::stage_with_mode(&path, &body, Some(RECORD_FILE_MODE))
682 .and_then(crate::retention::StagedRewrite::commit)
683 .map_err(|reason| format!("failed to write {}: {reason}", path.display()))?;
684 Ok(Self { path })
685 }
686}
687
688impl Drop for RunningGuard {
689 fn drop(&mut self) {
690 let _ = std::fs::remove_file(&self.path);
691 let _ = std::fs::remove_file(self.path.with_extension(CONTROL_TOKEN_EXTENSION));
693 }
694}
695
696#[must_use]
701pub fn has_controlling_terminal() -> bool {
702 std::fs::File::open("/dev/tty").is_ok()
703}
704
705fn record_path(dir: &Path, session_id: &str) -> PathBuf {
706 dir.join(format!("{session_id}.json"))
707}
708
709fn token_path(dir: &Path, session_id: &str) -> PathBuf {
710 dir.join(format!("{session_id}.{CONTROL_TOKEN_EXTENSION}"))
711}
712
713fn read_record(path: &Path) -> RecordRead {
714 let Ok(body) = std::fs::read_to_string(path) else {
715 return RecordRead::Unreadable;
716 };
717 if let Ok(record) = serde_json::from_str(&body) {
718 return RecordRead::Record(record);
719 }
720 match serde_json::from_str::<serde_json::Value>(&body) {
721 Ok(serde_json::Value::Object(object))
722 if object
723 .get("session_id")
724 .is_some_and(serde_json::Value::is_string)
725 && !object.contains_key("records_dir") =>
726 {
727 RecordRead::OlderMur
728 }
729 _ => RecordRead::Unreadable,
730 }
731}
732
733#[allow(unsafe_code)]
745pub(crate) fn pid_is_alive(pid: u32) -> bool {
746 let Ok(target) = libc::pid_t::try_from(pid) else {
747 return false;
748 };
749 if target == 0 {
750 return false;
751 }
752 let held = unsafe { libc::kill(target, 0) } == 0
755 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM);
756 held && !platform::is_zombie(pid)
757}
758
759#[must_use]
770pub fn process_start_token(pid: u32) -> Option<String> {
771 platform::process_start_token(pid)
772}
773
774#[cfg(target_os = "linux")]
775mod platform {
776 const STARTTIME_OFFSET_AFTER_COMM: usize = 22 - 3;
782
783 pub(super) fn process_start_token(pid: u32) -> Option<String> {
784 let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
785 start_token_from_stat(&stat)
786 }
787
788 pub(super) fn start_token_from_stat(stat: &str) -> Option<String> {
791 fields_after_comm(stat)?
792 .nth(STARTTIME_OFFSET_AFTER_COMM)
793 .map(str::to_string)
794 }
795
796 fn fields_after_comm(stat: &str) -> Option<std::str::SplitWhitespace<'_>> {
798 Some(stat[stat.rfind(')')? + 1..].split_whitespace())
799 }
800
801 pub(super) fn is_zombie(pid: u32) -> bool {
807 let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
808 return false;
809 };
810 fields_after_comm(&stat).and_then(|mut fields| fields.next()) == Some("Z")
811 }
812}
813
814#[cfg(target_os = "macos")]
815mod platform {
816 use std::mem::size_of;
817
818 pub(super) fn process_start_token(pid: u32) -> Option<String> {
820 let info = bsd_info(pid).ok()?;
821 Some(format!(
822 "{}.{:06}",
823 info.pbi_start_tvsec, info.pbi_start_tvusec
824 ))
825 }
826
827 pub(super) fn is_zombie(pid: u32) -> bool {
835 match bsd_info(pid) {
836 Ok(info) => info.pbi_status == libc::SZOMB,
837 Err(err) => err.raw_os_error() == Some(libc::ESRCH),
838 }
839 }
840
841 #[allow(unsafe_code)]
844 fn bsd_info(pid: u32) -> std::io::Result<libc::proc_bsdinfo> {
845 let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
848 let size = size_of::<libc::proc_bsdinfo>();
849
850 let written = unsafe {
854 libc::proc_pidinfo(
855 pid as libc::c_int,
856 libc::PROC_PIDTBSDINFO,
857 0,
858 std::ptr::addr_of_mut!(info).cast(),
859 size as libc::c_int,
860 )
861 };
862 if written <= 0 {
863 return Err(std::io::Error::last_os_error());
864 }
865 if usize::try_from(written).ok() != Some(size) {
866 return Err(std::io::Error::other(format!(
867 "proc_pidinfo wrote {written} bytes, not one proc_bsdinfo of {size}"
868 )));
869 }
870 Ok(info)
871 }
872}
873
874fn probe_session_id(record: &RunningRecord) -> Result<String, String> {
881 probe_door_session_id(&record.url, record.door_token.as_ref())
882}
883
884pub(crate) fn probe_door_session_id(
890 url: &str,
891 token: Option<&crate::door_auth::DoorToken>,
892) -> Result<String, String> {
893 let addr = url
894 .trim_start_matches("http://")
895 .trim_start_matches("https://");
896 let version = (
897 crate::a2a::A2A_VERSION_HEADER,
898 crate::a2a::A2A_PROTOCOL_VERSION,
899 );
900 let card = match token {
901 Some(token) => {
902 let body = serde_json::json!({
903 "jsonrpc": "2.0",
904 "id": 1,
905 "method": crate::identity::DoorMethod::GetExtendedAgentCard.wire_name(),
906 "params": {},
907 })
908 .to_string();
909 let authorization = crate::door_auth::bearer_header(token);
910 let response = crate::http_client::http_json_with_timeouts(
911 "POST",
912 &format!("http://{addr}/"),
913 Some(&body),
914 &[("Authorization", authorization.as_str()), version],
915 PROBE_CONNECT_TIMEOUT,
916 PROBE_READ_TIMEOUT,
917 )?;
918 response.get("result").cloned().ok_or_else(|| {
919 format!("the capsule at {addr} did not return its extended agent card")
920 })?
921 }
922 None => crate::http_client::http_json_with_timeouts(
923 "GET",
924 &format!("http://{addr}/.well-known/agent-card.json"),
925 None,
926 &[("Accept", "application/json"), version],
927 PROBE_CONNECT_TIMEOUT,
928 PROBE_READ_TIMEOUT,
929 )?,
930 };
931 crate::identity::session_id_from_card(&card)
932 .map(str::to_string)
933 .ok_or_else(|| format!("the agent card from {addr} names no session"))
934}
935
936#[cfg(test)]
937mod tests {
938 use super::*;
939
940 #[test]
941 fn alive_and_unverified_are_running_and_gone_is_not() {
942 assert!(ProcessState::Alive.is_running());
943 assert!(ProcessState::Unverified("start time unreadable".to_string()).is_running());
944 assert!(!ProcessState::Gone("no process holds the pid".to_string()).is_running());
945 }
946
947 #[test]
948 fn prune_removes_the_record_and_its_control_token() {
949 let home = tempfile::tempdir().unwrap();
950 crate::murmur_home::run_with_home(
951 "running::tests::inner_prune_removes_the_record_and_its_control_token",
952 home.path(),
953 );
954 }
955
956 #[test]
957 #[ignore = "run by prune_removes_the_record_and_its_control_token"]
958 fn inner_prune_removes_the_record_and_its_control_token() {
959 if !crate::murmur_home::in_scratch_home() {
960 return;
961 }
962 let record = RunningRecord {
963 session_id: "ses_0199prunecontroltoken000000000000".to_string(),
964 url: "localhost:1".to_string(),
965 pid: std::process::id(),
966 process_start: String::new(),
967 capsule_name: "c".to_string(),
968 capsule_version: "0.1.0".to_string(),
969 workdir: PathBuf::from("/tmp"),
970 records_dir: PathBuf::from("/tmp").join("records"),
971 outlives_launcher: false,
972 started_at: "2026-01-01T00:00:00Z".to_string(),
973 door_token: None,
974 credentials: BTreeMap::new(),
975 formation_id: None,
976 formation_member: None,
977 formation_entry: false,
978 formation_lifeline: false,
979 formation_launcher: None,
980 spawned_by: None,
981 };
982 let token = ControlTokenGuard::write(&record.session_id, "ctl1.token.mac").unwrap();
983 let record_guard = RunningGuard::write(&record).unwrap();
984 let dir = running_dir().unwrap();
985 let token_file = control_token_path(&record.session_id).unwrap();
986 assert_eq!(
987 token_file,
988 dir.join(format!("{}.control", record.session_id))
989 );
990 assert_eq!(crate::murmur_home::mode_of(&token_file), RECORD_FILE_MODE);
991 assert_eq!(
992 std::fs::read_to_string(&token_file).unwrap(),
993 "ctl1.token.mac"
994 );
995
996 prune(&record);
997 assert!(!record_path(&dir, &record.session_id).exists());
998 assert!(!token_file.exists());
999
1000 std::mem::forget(token);
1002 std::mem::forget(record_guard);
1003 let _token = ControlTokenGuard::write(&record.session_id, "ctl1.token.mac").unwrap();
1004 drop(RunningGuard::write(&record).unwrap());
1005 assert!(!token_file.exists());
1006 }
1007
1008 fn record(pid: u32, process_start: &str) -> RunningRecord {
1009 RunningRecord {
1010 session_id: "ses_0199c4e2f1b7712a9d3e4f5061728394".to_string(),
1011 url: "127.0.0.1:1".to_string(),
1012 pid,
1013 process_start: process_start.to_string(),
1014 capsule_name: "demo".to_string(),
1015 capsule_version: "0.1.0".to_string(),
1016 workdir: PathBuf::from("/tmp/demo"),
1017 records_dir: PathBuf::from("/tmp/demo").join("records"),
1018 outlives_launcher: true,
1019 started_at: "2026-01-01T00:00:00Z".to_string(),
1020 door_token: None,
1021 credentials: BTreeMap::new(),
1022 formation_id: None,
1023 formation_member: None,
1024 formation_entry: false,
1025 formation_lifeline: false,
1026 formation_launcher: None,
1027 spawned_by: None,
1028 }
1029 }
1030
1031 #[test]
1034 fn a_record_serializes_exactly_the_ten_fields() {
1035 let value = serde_json::to_value(record(1, "42")).unwrap();
1036 let mut keys: Vec<&str> = value
1037 .as_object()
1038 .unwrap()
1039 .keys()
1040 .map(String::as_str)
1041 .collect();
1042 keys.sort_unstable();
1043 assert_eq!(
1044 keys,
1045 [
1046 "capsule_name",
1047 "capsule_version",
1048 "outlives_launcher",
1049 "pid",
1050 "process_start",
1051 "records_dir",
1052 "session_id",
1053 "started_at",
1054 "url",
1055 "workdir",
1056 ]
1057 );
1058 }
1059
1060 #[test]
1061 fn a_record_with_a_door_token_serializes_it_beside_the_ten_fields() {
1062 let mut with_token = record(1, "42");
1063 with_token.door_token = Some(crate::door_auth::DoorToken::new(
1064 "mdt1.payload.mac".to_string(),
1065 ));
1066 let value = serde_json::to_value(&with_token).unwrap();
1067 let object = value.as_object().unwrap();
1068 assert_eq!(object.len(), 11);
1069 assert_eq!(object["door_token"], "mdt1.payload.mac");
1070 let back: RunningRecord = serde_json::from_value(value).unwrap();
1071 assert_eq!(back, with_token);
1072 }
1073
1074 #[test]
1075 fn a_record_debug_prints_no_door_token() {
1076 let mut with_token = record(1, "42");
1077 with_token.door_token = Some(crate::door_auth::DoorToken::new(
1078 "mdt1.secretpayload.secretmac".to_string(),
1079 ));
1080 let debug = format!("{with_token:?}");
1081 assert!(!debug.contains("secretpayload"), "{debug}");
1082 assert!(debug.contains("DoorToken(<redacted>)"), "{debug}");
1083 }
1084
1085 fn with_credentials() -> RunningRecord {
1086 let mut authenticated = record(1, "42");
1087 authenticated.door_token = Some(crate::door_auth::DoorToken::new(
1088 "mdt1.operatorpayload.mac".to_string(),
1089 ));
1090 for name in ["watcher", "reader"] {
1091 authenticated.credentials.insert(
1092 name.to_string(),
1093 crate::door_auth::DoorToken::new(format!("mdt1.{name}secret.mac")),
1094 );
1095 }
1096 authenticated
1097 }
1098
1099 #[test]
1100 fn a_record_with_credentials_serializes_them_as_an_object_by_name() {
1101 let authenticated = with_credentials();
1102 let value = serde_json::to_value(&authenticated).unwrap();
1103 let object = value.as_object().unwrap();
1104 assert_eq!(object.len(), 12);
1105 assert_eq!(
1106 object["credentials"],
1107 serde_json::json!({
1108 "reader": "mdt1.readersecret.mac",
1109 "watcher": "mdt1.watchersecret.mac",
1110 })
1111 );
1112 let back: RunningRecord = serde_json::from_value(value).unwrap();
1113 assert_eq!(back, authenticated);
1114 }
1115
1116 #[test]
1117 fn a_record_debug_prints_no_credential_token() {
1118 let debug = format!("{:?}", with_credentials());
1119 assert!(!debug.contains("secret"), "{debug}");
1120 assert!(!debug.contains("operatorpayload"), "{debug}");
1121 assert!(
1122 debug.contains("\"watcher\": DoorToken(<redacted>)"),
1123 "{debug}"
1124 );
1125 }
1126
1127 #[test]
1129 fn a_record_with_no_declared_credentials_omits_the_key() {
1130 let mut operator_only = record(1, "42");
1131 operator_only.door_token = Some(crate::door_auth::DoorToken::new(
1132 "mdt1.payload.mac".to_string(),
1133 ));
1134 let value = serde_json::to_value(&operator_only).unwrap();
1135 assert!(value.get("credentials").is_none(), "{value}");
1136 }
1137
1138 #[test]
1140 fn a_record_without_a_credentials_key_parses_as_empty() {
1141 let mut value = serde_json::to_value(with_credentials()).unwrap();
1142 value.as_object_mut().unwrap().remove("credentials");
1143 let parsed: RunningRecord = serde_json::from_value(value).unwrap();
1144 assert!(parsed.credentials.is_empty());
1145 assert!(parsed.door_token.is_some());
1146 }
1147
1148 #[test]
1150 fn a_record_without_a_door_token_key_parses() {
1151 let value = serde_json::to_value(record(1, "42")).unwrap();
1152 assert!(value.get("door_token").is_none());
1153 let parsed: RunningRecord = serde_json::from_value(value).unwrap();
1154 assert_eq!(parsed.door_token, None);
1155 }
1156
1157 #[test]
1158 fn a_member_record_serializes_its_formation_beside_the_ten_fields() {
1159 let formation = crate::formation::FormationId::mint();
1160 let mut member = record(1, "42");
1161 member.formation_id = Some(formation.clone());
1162 let value = serde_json::to_value(&member).unwrap();
1163 let object = value.as_object().unwrap();
1164 assert_eq!(object.len(), 11);
1165 assert_eq!(object["formation_id"], formation.as_str());
1166 let back: RunningRecord = serde_json::from_value(value).unwrap();
1167 assert_eq!(back, member);
1168 }
1169
1170 #[test]
1172 fn a_standalone_record_has_no_formation_key() {
1173 let body = serde_json::to_string_pretty(&record(1, "42")).unwrap();
1174 assert!(!body.contains("formation"), "{body}");
1175 let parsed: RunningRecord = serde_json::from_str(&body).unwrap();
1176 assert_eq!(parsed.formation_id, None);
1177 }
1178
1179 #[test]
1180 fn a_record_with_a_malformed_formation_id_does_not_parse() {
1181 let mut value = serde_json::to_value(record(1, "42")).unwrap();
1182 value
1183 .as_object_mut()
1184 .unwrap()
1185 .insert("formation_id".to_string(), "frm_ABC".into());
1186 assert!(serde_json::from_value::<RunningRecord>(value).is_err());
1187 }
1188
1189 #[test]
1190 fn list_prunes_a_record_with_a_malformed_formation_id() {
1191 let home = tempfile::tempdir().unwrap();
1192 crate::murmur_home::run_with_home(
1193 "running::tests::inner_list_prunes_a_record_with_a_malformed_formation_id",
1194 home.path(),
1195 );
1196 }
1197
1198 #[test]
1199 #[ignore = "run by list_prunes_a_record_with_a_malformed_formation_id"]
1200 fn inner_list_prunes_a_record_with_a_malformed_formation_id() {
1201 if !crate::murmur_home::in_scratch_home() {
1202 return;
1203 }
1204 let dir = running_dir().unwrap();
1205 let mut member = record(1, "42");
1206 member.formation_id = Some(crate::formation::FormationId::mint());
1207 let mut malformed = serde_json::to_value(record(2, "42")).unwrap();
1208 malformed["session_id"] = "ses_0199c4e2f1b7712a9d3e4f5061728395".into();
1209 malformed["formation_id"] = "frm_ABC".into();
1210 let member_path = record_path(&dir, &member.session_id);
1211 let malformed_path = dir.join("ses_0199c4e2f1b7712a9d3e4f5061728395.json");
1212 std::fs::write(&member_path, serde_json::to_vec_pretty(&member).unwrap()).unwrap();
1213 std::fs::write(
1214 &malformed_path,
1215 serde_json::to_vec_pretty(&malformed).unwrap(),
1216 )
1217 .unwrap();
1218
1219 assert_eq!(list().unwrap(), vec![member]);
1220 assert!(member_path.exists());
1221 assert!(!malformed_path.exists());
1222 }
1223
1224 #[test]
1225 fn a_member_record_serializes_its_roster_name_beside_its_formation() {
1226 let mut member = record(1, "42");
1227 member.formation_id = Some(crate::formation::FormationId::mint());
1228 member.formation_member = Some("w1".to_string());
1229 let value = serde_json::to_value(&member).unwrap();
1230 let object = value.as_object().unwrap();
1231 assert_eq!(object.len(), 12);
1232 assert_eq!(object["formation_member"], "w1");
1233 let back: RunningRecord = serde_json::from_value(value).unwrap();
1234 assert_eq!(back, member);
1235 }
1236
1237 #[test]
1238 fn a_record_without_a_formation_member_or_with_a_null_one_parses_to_none() {
1239 let mut value = serde_json::to_value(record(1, "42")).unwrap();
1240 assert!(value.get("formation_member").is_none());
1241 let parsed: RunningRecord = serde_json::from_value(value.clone()).unwrap();
1242 assert_eq!(parsed.formation_member, None);
1243 value["formation_member"] = serde_json::Value::Null;
1244 let parsed: RunningRecord = serde_json::from_value(value).unwrap();
1245 assert_eq!(parsed.formation_member, None);
1246 }
1247
1248 #[test]
1249 fn a_record_whose_formation_member_is_not_a_member_name_does_not_parse() {
1250 let too_long = format!("w{}", "1".repeat(32));
1251 let malformed: [serde_json::Value; 4] = [7.into(), "".into(), "W1".into(), too_long.into()];
1252 for bad in malformed {
1253 let mut value = serde_json::to_value(record(1, "42")).unwrap();
1254 value["formation_member"] = bad.clone();
1255 assert!(
1256 serde_json::from_value::<RunningRecord>(value).is_err(),
1257 "formation_member {bad} parsed"
1258 );
1259 }
1260 }
1261
1262 #[test]
1263 fn list_prunes_a_record_with_a_malformed_formation_member() {
1264 let home = tempfile::tempdir().unwrap();
1265 crate::murmur_home::run_with_home(
1266 "running::tests::inner_list_prunes_a_record_with_a_malformed_formation_member",
1267 home.path(),
1268 );
1269 }
1270
1271 #[test]
1272 #[ignore = "run by list_prunes_a_record_with_a_malformed_formation_member"]
1273 fn inner_list_prunes_a_record_with_a_malformed_formation_member() {
1274 if !crate::murmur_home::in_scratch_home() {
1275 return;
1276 }
1277 let dir = running_dir().unwrap();
1278 let mut member = record(1, "42");
1279 member.formation_id = Some(crate::formation::FormationId::mint());
1280 member.formation_member = Some("w1".to_string());
1281 let mut malformed = serde_json::to_value(&member).unwrap();
1282 malformed["session_id"] = "ses_0199c4e2f1b7712a9d3e4f5061728395".into();
1283 malformed["formation_member"] = 7.into();
1284 let member_path = record_path(&dir, &member.session_id);
1285 let malformed_path = dir.join("ses_0199c4e2f1b7712a9d3e4f5061728395.json");
1286 std::fs::write(&member_path, serde_json::to_vec_pretty(&member).unwrap()).unwrap();
1287 std::fs::write(
1288 &malformed_path,
1289 serde_json::to_vec_pretty(&malformed).unwrap(),
1290 )
1291 .unwrap();
1292
1293 assert_eq!(list().unwrap(), vec![member]);
1294 assert!(member_path.exists());
1295 assert!(!malformed_path.exists());
1296 }
1297
1298 fn launched_member() -> RunningRecord {
1299 let mut member = record(1, "42");
1300 member.formation_id = Some(crate::formation::FormationId::mint());
1301 member.formation_lifeline = true;
1302 member.formation_launcher = Some(ProcessIdentity {
1303 pid: 4242,
1304 process_start: "987654".to_string(),
1305 });
1306 member.spawned_by = Some("ses_0199c4e2f1b7712a9d3e4f5061728390".to_string());
1307 member
1308 }
1309
1310 #[test]
1311 fn a_launched_members_record_round_trips_its_launcher_and_lineage() {
1312 let mut member = launched_member();
1313 member.formation_entry = true;
1314 let value = serde_json::to_value(&member).unwrap();
1315 assert_eq!(value["formation_entry"], true);
1316 assert_eq!(value["formation_lifeline"], true);
1317 assert_eq!(
1318 value["formation_launcher"],
1319 serde_json::json!({"pid": 4242, "process_start": "987654"})
1320 );
1321 assert_eq!(value["spawned_by"], "ses_0199c4e2f1b7712a9d3e4f5061728390");
1322 let back: RunningRecord = serde_json::from_value(value).unwrap();
1323 assert_eq!(back, member);
1324 }
1325
1326 #[test]
1329 fn a_record_holding_no_entry_lifeline_launcher_or_spawner_omits_all_four_keys() {
1330 let mut member = launched_member();
1331 member.formation_entry = false;
1332 member.formation_lifeline = false;
1333 member.formation_launcher = None;
1334 member.spawned_by = None;
1335 let body = serde_json::to_string_pretty(&member).unwrap();
1336 for key in [
1337 "formation_entry",
1338 "formation_lifeline",
1339 "formation_launcher",
1340 "spawned_by",
1341 ] {
1342 assert!(!body.contains(key), "{key} in {body}");
1343 }
1344 assert_eq!(
1345 serde_json::from_str::<RunningRecord>(&body).unwrap(),
1346 member
1347 );
1348 }
1349
1350 #[test]
1353 fn liveness_is_running_exactly_when_its_process_state_is() {
1354 for state in [
1355 ProcessState::Alive,
1356 ProcessState::Unverified("unreadable start time".to_string()),
1357 ProcessState::Gone("no such process".to_string()),
1358 ] {
1359 let reachable = match liveness_without_probe(state.clone()) {
1360 Some(liveness) => vec![liveness],
1361 None => vec![
1363 Liveness::Live,
1364 Liveness::Unreachable("the door did not answer".to_string()),
1365 ],
1366 };
1367 for liveness in reachable {
1368 assert_eq!(
1369 liveness.is_running(),
1370 state.is_running(),
1371 "{state:?} → {liveness:?}"
1372 );
1373 }
1374 }
1375 }
1376
1377 #[test]
1378 fn a_record_with_a_malformed_formation_launcher_does_not_parse() {
1379 for launcher in [
1380 serde_json::json!(4242),
1381 serde_json::json!({"pid": 4242}),
1382 serde_json::json!({"process_start": "1"}),
1383 serde_json::json!({"pid": -1, "process_start": "1"}),
1384 serde_json::json!({"pid": "4242", "process_start": "1"}),
1385 ] {
1386 let mut value = serde_json::to_value(record(1, "42")).unwrap();
1387 value["formation_launcher"] = launcher.clone();
1388 assert!(
1389 serde_json::from_value::<RunningRecord>(value).is_err(),
1390 "{launcher}"
1391 );
1392 }
1393 }
1394
1395 #[test]
1398 fn an_identity_reads_and_signals_as_the_record_naming_the_same_process() {
1399 let pid = std::process::id();
1400 let token = process_start_token(pid).unwrap();
1401 for start in [token.as_str(), "not-this-process", ""] {
1402 let record = record(pid, start);
1403 let identity = ProcessIdentity {
1404 pid,
1405 process_start: start.to_string(),
1406 };
1407 assert_eq!(identity_state(&identity), process_state(&record), "{start}");
1408 }
1409 let stale = ProcessIdentity {
1411 pid,
1412 process_start: "not-this-process".to_string(),
1413 };
1414 assert_eq!(
1415 signal_term_process(&stale),
1416 signal_term(&record(pid, "not-this-process"))
1417 );
1418 assert_eq!(signal_kill_process(&stale), SignalOutcome::AlreadyGone);
1419 let nobody = ProcessIdentity {
1420 pid: 0,
1421 process_start: "42".to_string(),
1422 };
1423 assert_eq!(signal_term_process(&nobody), signal_term(&record(0, "42")));
1424 assert_eq!(
1425 ProcessIdentity::of_this_process(),
1426 Some(ProcessIdentity {
1427 pid,
1428 process_start: token
1429 })
1430 );
1431 }
1432
1433 #[test]
1435 fn a_term_sent_through_an_identity_reaches_the_process() {
1436 let mut child = std::process::Command::new("sleep")
1437 .arg("30")
1438 .spawn()
1439 .expect("sleep should start");
1440 let pid = child.id();
1441 let identity = ProcessIdentity {
1442 pid,
1443 process_start: process_start_token(pid).unwrap(),
1444 };
1445 assert_eq!(identity_state(&identity), ProcessState::Alive);
1446 assert_eq!(signal_term_process(&identity), SignalOutcome::Sent);
1447 let status = child.wait().unwrap();
1448 use std::os::unix::process::ExitStatusExt;
1449 assert_eq!(status.signal(), Some(libc::SIGTERM));
1450 assert!(matches!(identity_state(&identity), ProcessState::Gone(_)));
1451 }
1452
1453 #[test]
1456 fn a_record_missing_a_field_does_not_parse() {
1457 let mut value = serde_json::to_value(record(1, "42")).unwrap();
1458 value.as_object_mut().unwrap().remove("process_start");
1459 assert!(serde_json::from_value::<RunningRecord>(value).is_err());
1460 }
1461
1462 #[test]
1464 fn this_process_reads_as_alive() {
1465 let pid = std::process::id();
1466 let token = process_start_token(pid).expect("this process has a start time");
1467 assert_eq!(process_state(&record(pid, &token)), ProcessState::Alive);
1468 }
1469
1470 #[test]
1472 fn a_start_time_that_disagrees_reads_as_gone() {
1473 let pid = std::process::id();
1474 let state = process_state(&record(pid, "not-this-process"));
1475 match state {
1476 ProcessState::Gone(reason) => assert!(
1477 reason.contains("another time"),
1478 "the reason must say the pid belongs to another process now: {reason}"
1479 ),
1480 other => panic!("a mismatched start time must read as gone, not {other:?}"),
1481 }
1482 }
1483
1484 #[test]
1486 fn a_pid_nothing_holds_reads_as_gone() {
1487 let state = process_state(&record(0, "42"));
1489 assert!(matches!(state, ProcessState::Gone(_)));
1490 }
1491
1492 #[test]
1494 fn the_start_token_is_stable_for_one_process() {
1495 let pid = std::process::id();
1496 assert_eq!(process_start_token(pid), process_start_token(pid));
1497 assert!(process_start_token(pid).is_some_and(|token| !token.is_empty()));
1498 }
1499
1500 #[test]
1503 fn a_zombie_does_not_read_as_alive() {
1504 let mut child = std::process::Command::new("sh")
1505 .args(["-c", "exit 0"])
1506 .spawn()
1507 .expect("sh should start");
1508 let pid = child.id();
1509 let token = process_start_token(pid).expect("a live child has a start time");
1510
1511 let deadline = std::time::Instant::now() + Duration::from_secs(10);
1513 loop {
1514 if matches!(process_state(&record(pid, &token)), ProcessState::Gone(_)) {
1515 break;
1516 }
1517 assert!(
1518 std::time::Instant::now() < deadline,
1519 "a zombie kept reading as alive"
1520 );
1521 std::thread::sleep(Duration::from_millis(50));
1522 }
1523 let _ = child.wait();
1524 }
1525
1526 #[test]
1529 fn a_signal_at_a_mismatched_start_time_is_not_sent() {
1530 let record = record(std::process::id(), "not-this-process");
1532 assert_eq!(signal_kill(&record), SignalOutcome::AlreadyGone);
1533 assert_eq!(signal_term(&record), SignalOutcome::AlreadyGone);
1534 }
1535
1536 #[test]
1539 fn a_signal_at_a_pid_nothing_holds_is_already_gone() {
1540 assert_eq!(signal_term(&record(0, "42")), SignalOutcome::AlreadyGone);
1541 }
1542
1543 #[test]
1546 fn a_live_process_behind_a_silent_port_is_unreachable() {
1547 let pid = std::process::id();
1548 let token = process_start_token(pid).unwrap();
1549 let mut record = record(pid, &token);
1550 let port = crate::pinned_port::claim();
1552 record.url = format!("127.0.0.1:{port}");
1553 assert!(matches!(verify(&record), Liveness::Unreachable(_)));
1554 }
1555
1556 const PID: u32 = 4242;
1557
1558 #[test]
1559 fn classify_a_dead_pid_is_gone_whatever_the_token() {
1560 for token in [None, Some("123"), Some("")] {
1561 assert_eq!(
1562 classify(PID, "123", false, token),
1563 ProcessState::Gone("no process holds pid 4242".to_string()),
1564 "token {token:?}"
1565 );
1566 }
1567 }
1568
1569 #[test]
1570 fn classify_a_live_pid_with_the_recorded_token_is_alive() {
1571 assert_eq!(classify(PID, "123", true, Some("123")), ProcessState::Alive);
1572 }
1573
1574 #[test]
1575 fn classify_a_live_pid_with_another_token_is_gone() {
1576 assert_eq!(
1577 classify(PID, "123", true, Some("456")),
1578 ProcessState::Gone("pid 4242 is held by a process that started at another time".into())
1579 );
1580 }
1581
1582 #[test]
1584 fn classify_a_recorded_empty_token_against_a_fresh_one_is_gone() {
1585 assert!(matches!(
1586 classify(PID, "", true, Some("123")),
1587 ProcessState::Gone(reason) if reason.contains("another time")
1588 ));
1589 }
1590
1591 #[test]
1592 fn classify_a_live_pid_whose_token_cannot_be_read_is_unverified() {
1593 assert_eq!(
1594 classify(PID, "123", true, None),
1595 ProcessState::Unverified("pid 4242's start time could not be read".into())
1596 );
1597 }
1598
1599 #[test]
1600 fn nothing_is_refused_before_signalling_a_confirmed_process() {
1601 assert_eq!(refusal_before_signal(&ProcessState::Alive), None);
1602 }
1603
1604 #[test]
1605 fn a_gone_process_is_already_gone_before_any_signal() {
1606 assert_eq!(
1607 refusal_before_signal(&ProcessState::Gone("no process holds pid 1".into())),
1608 Some(SignalOutcome::AlreadyGone)
1609 );
1610 }
1611
1612 #[test]
1613 fn an_unverified_process_is_refused_before_any_signal() {
1614 let reason = "pid 1's start time could not be read".to_string();
1615 assert_eq!(
1616 refusal_before_signal(&ProcessState::Unverified(reason.clone())),
1617 Some(SignalOutcome::Refused(reason))
1618 );
1619 }
1620
1621 #[test]
1624 fn an_unverified_process_is_unreachable_without_a_probe() {
1625 let reason = "pid 1's start time could not be read".to_string();
1626 assert_eq!(
1627 liveness_without_probe(ProcessState::Unverified(reason.clone())),
1628 Some(Liveness::Unreachable(reason))
1629 );
1630 assert_eq!(
1631 liveness_without_probe(ProcessState::Gone("gone".into())),
1632 Some(Liveness::Gone("gone".into()))
1633 );
1634 assert_eq!(liveness_without_probe(ProcessState::Alive), None);
1635 }
1636
1637 #[cfg(target_os = "linux")]
1639 fn stat_line(comm: &str) -> String {
1640 let fields: Vec<String> = (3..=21).map(|field| field.to_string()).collect();
1641 format!("1234 ({comm}) {} 987654 23 24\n", fields.join(" "))
1642 }
1643
1644 #[cfg(target_os = "linux")]
1645 #[test]
1646 fn the_start_token_is_field_22_of_a_plain_stat_line() {
1647 assert_eq!(
1648 platform::start_token_from_stat(&stat_line("sleep")),
1649 Some("987654".to_string())
1650 );
1651 }
1652
1653 #[cfg(target_os = "linux")]
1654 #[test]
1655 fn the_start_token_is_counted_from_the_last_close_paren() {
1656 assert_eq!(
1657 platform::start_token_from_stat(&stat_line("a) (b c")),
1658 Some("987654".to_string())
1659 );
1660 }
1661
1662 #[cfg(target_os = "linux")]
1663 #[test]
1664 fn a_stat_line_with_no_close_paren_has_no_start_token() {
1665 assert_eq!(platform::start_token_from_stat("1234 sleep S 1 2 3"), None);
1666 }
1667
1668 #[cfg(target_os = "linux")]
1669 #[test]
1670 fn a_stat_line_truncated_before_field_22_has_no_start_token() {
1671 let line = stat_line("sleep");
1672 let truncated = &line[..line.find(" 987654").unwrap()];
1673 assert_eq!(platform::start_token_from_stat(truncated), None);
1674 }
1675}
1676
1677#[cfg(test)]
1678mod home_tests {
1679 use super::*;
1680
1681 #[test]
1682 fn running_dir_holds_a_wide_murmur_home_owner_only() {
1683 let home = tempfile::tempdir().unwrap();
1684 crate::murmur_home::wide_dir(&home.path().join(".murmur"), 0o755);
1685 crate::murmur_home::run_with_home(
1686 "running::home_tests::inner_running_dir_under_scratch_home",
1687 home.path(),
1688 );
1689 assert_eq!(
1690 crate::murmur_home::mode_of(&home.path().join(".murmur")),
1691 0o700
1692 );
1693 assert_eq!(
1694 crate::murmur_home::mode_of(&home.path().join(".murmur").join(RUNNING_DIR)),
1695 RUNNING_DIR_MODE
1696 );
1697 }
1698
1699 #[test]
1700 #[ignore = "run by running_dir_holds_a_wide_murmur_home_owner_only"]
1701 fn inner_running_dir_under_scratch_home() {
1702 if !crate::murmur_home::in_scratch_home() {
1703 return;
1704 }
1705 running_dir().unwrap();
1706 }
1707
1708 #[test]
1709 fn running_dir_location_creates_nothing() {
1710 let home = tempfile::tempdir().unwrap();
1711 crate::murmur_home::run_with_home(
1712 "running::home_tests::inner_running_dir_location_creates_nothing",
1713 home.path(),
1714 );
1715 assert!(!home.path().join(".murmur").exists());
1716 }
1717
1718 #[test]
1719 #[ignore = "run by running_dir_location_creates_nothing"]
1720 fn inner_running_dir_location_creates_nothing() {
1721 if !crate::murmur_home::in_scratch_home() {
1722 return;
1723 }
1724 let home = PathBuf::from(std::env::var_os("HOME").unwrap());
1725 assert_eq!(
1726 running_dir_location().unwrap(),
1727 home.join(".murmur").join(RUNNING_DIR)
1728 );
1729 assert!(!home.join(".murmur").exists());
1730 }
1731
1732 #[test]
1735 fn the_door_probe_names_a2a_1_0_on_both_cards() {
1736 use crate::http_client::capture::answer_one;
1737
1738 let card = serde_json::json!({"capabilities": {"extensions": [{
1739 "uri": crate::identity::CAPSULE_EXTENSION_URI,
1740 "params": {"sessionId": "ses_probe"}}]}});
1741 let (addr, sent) =
1742 answer_one(serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": card}).to_string());
1743 let token = crate::door_auth::DoorToken::new("tok".to_string());
1744 assert_eq!(
1745 probe_door_session_id(&addr, Some(&token)).unwrap(),
1746 "ses_probe"
1747 );
1748 let sent = sent.join().unwrap();
1749 assert_eq!(sent.request_line, "POST / HTTP/1.1");
1750 assert_eq!(sent.method(), "GetExtendedAgentCard");
1751 assert_eq!(sent.header("a2a-version"), ["1.0"]);
1752
1753 let (addr, sent) = answer_one(card.to_string());
1754 assert_eq!(probe_door_session_id(&addr, None).unwrap(), "ses_probe");
1755 let sent = sent.join().unwrap();
1756 assert_eq!(
1757 sent.request_line,
1758 "GET /.well-known/agent-card.json HTTP/1.1"
1759 );
1760 assert_eq!(sent.header("a2a-version"), ["1.0"]);
1761 }
1762
1763 fn record(session_id: &str) -> RunningRecord {
1764 RunningRecord {
1765 session_id: session_id.to_string(),
1766 url: "127.0.0.1:1".to_string(),
1767 pid: 1,
1768 process_start: "42".to_string(),
1769 capsule_name: "demo".to_string(),
1770 capsule_version: "0.1.0".to_string(),
1771 workdir: PathBuf::from("/tmp/demo"),
1772 records_dir: PathBuf::from("/tmp/demo").join("records"),
1773 outlives_launcher: true,
1774 started_at: "2026-01-01T00:00:00Z".to_string(),
1775 door_token: None,
1776 credentials: BTreeMap::new(),
1777 formation_id: None,
1778 formation_member: None,
1779 formation_entry: false,
1780 formation_lifeline: false,
1781 formation_launcher: None,
1782 spawned_by: None,
1783 }
1784 }
1785
1786 #[test]
1787 fn scan_reports_an_unparseable_file_and_leaves_it_in_place() {
1788 let dir = tempfile::tempdir().unwrap();
1789 let older = record("ses_0199c4e2f1b7712a9d3e4f5061728390");
1790 let newer = record("ses_0199c4e2f1b7712a9d3e4f5061728391");
1791 for record in [&older, &newer] {
1792 std::fs::write(
1793 record_path(dir.path(), &record.session_id),
1794 serde_json::to_vec(record).unwrap(),
1795 )
1796 .unwrap();
1797 }
1798 let broken = dir.path().join("ses_broken.json");
1799 std::fs::write(&broken, "{").unwrap();
1800 let staging = dir.path().join(".ses_x.json.1.2.tmp");
1801 std::fs::write(&staging, "{").unwrap();
1802 let control = token_path(dir.path(), &older.session_id);
1803 std::fs::write(&control, "ctl1.token.mac").unwrap();
1804
1805 let scan = scan(dir.path()).unwrap();
1806
1807 assert_eq!(scan.records, vec![newer, older]);
1808 assert_eq!(scan.unreadable, vec![broken.clone()]);
1809 assert_eq!(std::fs::read_to_string(&broken).unwrap(), "{");
1810 assert!(staging.exists());
1811 assert!(control.exists());
1812 }
1813
1814 fn mixed_running_dir() -> (tempfile::TempDir, RunningRecord, PathBuf, [PathBuf; 2]) {
1817 let dir = tempfile::tempdir().unwrap();
1818 let current = record("ses_0199c4e2f1b7712a9d3e4f5061728391");
1819 std::fs::write(
1820 record_path(dir.path(), ¤t.session_id),
1821 serde_json::to_vec(¤t).unwrap(),
1822 )
1823 .unwrap();
1824 let older_mur = record_path(dir.path(), "ses_0199c4e2f1b7712a9d3e4f5061728390");
1825 std::fs::write(
1826 &older_mur,
1827 serde_json::json!({
1828 "session_id": "ses_0199c4e2f1b7712a9d3e4f5061728390",
1829 "url": "127.0.0.1:1",
1830 "pid": 1,
1831 "process_start": "42",
1832 "capsule_name": "demo",
1833 "capsule_version": "0.1.0",
1834 "workdir": "/tmp/demo",
1835 "outlives_launcher": true,
1836 "started_at": "2026-01-01T00:00:00Z",
1837 })
1838 .to_string(),
1839 )
1840 .unwrap();
1841 let truncated = dir.path().join("ses_broken.json");
1842 std::fs::write(&truncated, "{").unwrap();
1843 let mut bad_pid = serde_json::to_value(record("ses_bad_pid")).unwrap();
1844 bad_pid["pid"] = serde_json::json!("one");
1845 let bad_pid_path = dir.path().join("ses_bad_pid.json");
1846 std::fs::write(&bad_pid_path, bad_pid.to_string()).unwrap();
1847 (dir, current, older_mur, [truncated, bad_pid_path])
1848 }
1849
1850 #[test]
1851 fn scan_names_a_record_an_older_mur_wrote_apart_from_unreadable_files() {
1852 let (dir, current, older_mur, unreadable) = mixed_running_dir();
1853 let before = std::fs::read(&older_mur).unwrap();
1854
1855 let mut scan = scan(dir.path()).unwrap();
1856
1857 scan.unreadable.sort();
1858 let mut expected_unreadable = unreadable.to_vec();
1859 expected_unreadable.sort();
1860 assert_eq!(scan.records, vec![current]);
1861 assert_eq!(scan.older_mur, vec![older_mur.clone()]);
1862 assert_eq!(scan.unreadable, expected_unreadable);
1863 assert_eq!(std::fs::read(&older_mur).unwrap(), before);
1864 assert!(unreadable.iter().all(|path| path.exists()));
1865 }
1866
1867 #[test]
1868 fn listing_unlinks_only_the_unreadable_files() {
1869 let (dir, current, older_mur, unreadable) = mixed_running_dir();
1870 let before = std::fs::read(&older_mur).unwrap();
1871
1872 let mut listing = list_pruning(dir.path()).unwrap();
1873
1874 listing.removed_unreadable.sort();
1875 let mut expected_removed = unreadable.to_vec();
1876 expected_removed.sort();
1877 assert_eq!(listing.records, vec![current]);
1878 assert_eq!(listing.older_mur, vec![older_mur.clone()]);
1879 assert_eq!(listing.removed_unreadable, expected_removed);
1880 assert_eq!(std::fs::read(&older_mur).unwrap(), before);
1881 assert!(unreadable.iter().all(|path| !path.exists()));
1882 }
1883
1884 #[test]
1885 fn an_object_without_a_string_session_id_is_unreadable() {
1886 let dir = tempfile::tempdir().unwrap();
1887 for (name, body) in [
1888 ("ses_array.json", "[]"),
1889 ("ses_no_id.json", r#"{"pid": 1}"#),
1890 ("ses_number_id.json", r#"{"session_id": 7}"#),
1891 ] {
1892 std::fs::write(dir.path().join(name), body).unwrap();
1893 }
1894
1895 let scan = scan(dir.path()).unwrap();
1896
1897 assert!(scan.older_mur.is_empty());
1898 assert_eq!(scan.unreadable.len(), 3);
1899 }
1900
1901 #[test]
1902 fn scan_of_a_missing_directory_is_empty_and_creates_nothing() {
1903 let parent = tempfile::tempdir().unwrap();
1904 let dir = parent.path().join(".murmur").join(RUNNING_DIR);
1905
1906 let scan = scan(&dir).unwrap();
1907
1908 assert!(scan.records.is_empty());
1909 assert!(scan.unreadable.is_empty());
1910 assert!(!parent.path().join(".murmur").exists());
1911 }
1912
1913 #[test]
1914 fn scan_of_a_regular_file_errs_naming_it() {
1915 let parent = tempfile::tempdir().unwrap();
1916 let file = parent.path().join(RUNNING_DIR);
1917 std::fs::write(&file, "").unwrap();
1918
1919 let err = scan(&file).unwrap_err();
1920
1921 assert!(err.starts_with(&format!("{}: ", file.display())), "{err}");
1922 }
1923}