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)]
72pub struct RunningRecord {
73 pub session_id: String,
75 pub url: String,
77 pub pid: u32,
79 pub process_start: String,
81 pub capsule_name: String,
82 pub capsule_version: String,
83 pub workdir: PathBuf,
85 pub outlives_launcher: bool,
88 pub started_at: String,
90 #[serde(
94 default,
95 skip_serializing_if = "Option::is_none",
96 with = "door_token_field"
97 )]
98 pub door_token: Option<crate::door_auth::DoorToken>,
99 #[serde(
105 default,
106 skip_serializing_if = "BTreeMap::is_empty",
107 with = "door_credentials_field"
108 )]
109 pub credentials: BTreeMap<String, crate::door_auth::DoorToken>,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub formation_id: Option<crate::formation::FormationId>,
116 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
119 pub formation_lifeline: bool,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub formation_launcher: Option<ProcessIdentity>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub spawned_by: Option<String>,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135pub struct ProcessIdentity {
136 pub pid: u32,
137 pub process_start: String,
138}
139
140impl ProcessIdentity {
141 pub fn of_this_process() -> Option<Self> {
144 let pid = std::process::id();
145 Some(Self {
146 pid,
147 process_start: process_start_token(pid)?,
148 })
149 }
150}
151
152mod door_token_field {
155 use crate::door_auth::DoorToken;
156 use serde::{Deserialize, Deserializer, Serializer};
157
158 pub(super) fn serialize<S: Serializer>(
159 token: &Option<DoorToken>,
160 serializer: S,
161 ) -> Result<S::Ok, S::Error> {
162 match token {
163 Some(token) => serializer.serialize_some(token.expose()),
164 None => serializer.serialize_none(),
165 }
166 }
167
168 pub(super) fn deserialize<'de, D: Deserializer<'de>>(
169 deserializer: D,
170 ) -> Result<Option<DoorToken>, D::Error> {
171 Ok(Option::<String>::deserialize(deserializer)?.map(DoorToken::new))
172 }
173}
174
175mod door_credentials_field {
179 use std::collections::BTreeMap;
180
181 use crate::door_auth::DoorToken;
182 use serde::{ser::SerializeMap, Deserialize, Deserializer, Serializer};
183
184 pub(super) fn serialize<S: Serializer>(
185 credentials: &BTreeMap<String, DoorToken>,
186 serializer: S,
187 ) -> Result<S::Ok, S::Error> {
188 let mut map = serializer.serialize_map(Some(credentials.len()))?;
189 for (name, token) in credentials {
190 map.serialize_entry(name, token.expose())?;
191 }
192 map.end()
193 }
194
195 pub(super) fn deserialize<'de, D: Deserializer<'de>>(
196 deserializer: D,
197 ) -> Result<BTreeMap<String, DoorToken>, D::Error> {
198 Ok(BTreeMap::<String, String>::deserialize(deserializer)?
199 .into_iter()
200 .map(|(name, token)| (name, DoorToken::new(token)))
201 .collect())
202 }
203}
204
205pub fn running_dir() -> Result<PathBuf, String> {
212 let dir = crate::murmur_home::ensure_murmur_home()?.join(RUNNING_DIR);
213 crate::state_store::ensure_private_dir(&dir, RUNNING_DIR_MODE)
214 .map_err(|reason| format!("{}: {reason}", dir.display()))?;
215 Ok(dir)
216}
217
218pub fn running_dir_location() -> Result<PathBuf, String> {
225 Ok(crate::state_store::murmur_home_dir()?.join(RUNNING_DIR))
226}
227
228#[derive(Debug, Default)]
230pub struct RecordScan {
231 pub records: Vec<RunningRecord>,
233 pub unreadable: Vec<PathBuf>,
235}
236
237pub fn scan(dir: &Path) -> Result<RecordScan, String> {
247 let entries = match std::fs::read_dir(dir) {
248 Ok(entries) => entries,
249 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(RecordScan::default()),
250 Err(err) => return Err(format!("{}: {err}", dir.display())),
251 };
252
253 let mut scan = RecordScan::default();
254 for entry in entries.flatten() {
255 let path = entry.path();
256 if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
257 continue;
258 }
259 match read_record(&path) {
260 Some(record) => scan.records.push(record),
261 None => scan.unreadable.push(path),
262 }
263 }
264 scan.records
265 .sort_by(|left, right| right.session_id.cmp(&left.session_id));
266 Ok(scan)
267}
268
269pub fn list() -> Result<Vec<RunningRecord>, String> {
279 list_reporting().map(|listing| listing.records)
280}
281
282#[derive(Debug, Default)]
284pub struct RecordListing {
285 pub records: Vec<RunningRecord>,
287 pub removed_unreadable: Vec<PathBuf>,
290}
291
292pub fn list_reporting() -> Result<RecordListing, String> {
296 let dir = running_dir()?;
297 let scan = scan(&dir)?;
298 let removed_unreadable = scan
299 .unreadable
300 .into_iter()
301 .filter(|path| std::fs::remove_file(path).is_ok())
302 .collect();
303 Ok(RecordListing {
304 records: scan.records,
305 removed_unreadable,
306 })
307}
308
309#[derive(Debug, Clone, PartialEq, Eq)]
314pub enum ProcessState {
315 Alive,
317 Unverified(String),
320 Gone(String),
323}
324
325pub fn process_state(record: &RunningRecord) -> ProcessState {
331 process_state_of(record.pid, &record.process_start)
332}
333
334pub fn identity_state(identity: &ProcessIdentity) -> ProcessState {
336 process_state_of(identity.pid, &identity.process_start)
337}
338
339impl RunningRecord {
340 pub fn identity(&self) -> ProcessIdentity {
342 ProcessIdentity {
343 pid: self.pid,
344 process_start: self.process_start.clone(),
345 }
346 }
347}
348
349pub(crate) fn process_state_of(pid: u32, recorded_start: &str) -> ProcessState {
352 let alive = pid_is_alive(pid);
353 let token = alive.then(|| process_start_token(pid)).flatten();
354 classify(pid, recorded_start, alive, token.as_deref())
355}
356
357fn classify(
363 pid: u32,
364 recorded_start: &str,
365 pid_alive: bool,
366 fresh_token: Option<&str>,
367) -> ProcessState {
368 if !pid_alive {
369 return ProcessState::Gone(format!("no process holds pid {pid}"));
370 }
371 match fresh_token {
372 Some(token) if token == recorded_start => ProcessState::Alive,
373 Some(_) => ProcessState::Gone(format!(
374 "pid {pid} is held by a process that started at another time"
375 )),
376 None => ProcessState::Unverified(format!("pid {pid}'s start time could not be read")),
377 }
378}
379
380#[derive(Debug, Clone, PartialEq, Eq)]
382pub enum Liveness {
383 Live,
385 Unreachable(String),
388 Gone(String),
390}
391
392pub fn verify(record: &RunningRecord) -> Liveness {
398 if let Some(liveness) = liveness_without_probe(process_state(record)) {
399 return liveness;
400 }
401 match probe_session_id(record) {
402 Ok(session_id) if session_id == record.session_id => Liveness::Live,
403 Ok(other) => Liveness::Unreachable(format!(
404 "the capsule at {} answers for session {other}",
405 record.url
406 )),
407 Err(reason) => Liveness::Unreachable(reason),
408 }
409}
410
411fn liveness_without_probe(state: ProcessState) -> Option<Liveness> {
414 match state {
415 ProcessState::Alive => None,
416 ProcessState::Unverified(reason) => Some(Liveness::Unreachable(reason)),
417 ProcessState::Gone(reason) => Some(Liveness::Gone(reason)),
418 }
419}
420
421#[derive(Debug, Clone, PartialEq, Eq)]
423pub enum SignalOutcome {
424 Sent,
426 AlreadyGone,
428 Refused(String),
432}
433
434pub fn signal_term(record: &RunningRecord) -> SignalOutcome {
436 signal(&record.identity(), libc::SIGTERM)
437}
438
439pub fn signal_kill(record: &RunningRecord) -> SignalOutcome {
441 signal(&record.identity(), libc::SIGKILL)
442}
443
444pub fn signal_term_process(identity: &ProcessIdentity) -> SignalOutcome {
446 signal(identity, libc::SIGTERM)
447}
448
449pub fn signal_kill_process(identity: &ProcessIdentity) -> SignalOutcome {
452 signal(identity, libc::SIGKILL)
453}
454
455#[allow(unsafe_code)]
463fn signal(identity: &ProcessIdentity, signal: libc::c_int) -> SignalOutcome {
464 if let Some(outcome) = refusal_before_signal(&identity_state(identity)) {
465 return outcome;
466 }
467 if unsafe { libc::kill(identity.pid as libc::pid_t, signal) } == 0 {
472 return SignalOutcome::Sent;
473 }
474 let err = std::io::Error::last_os_error();
475 if err.raw_os_error() == Some(libc::ESRCH) {
478 return SignalOutcome::AlreadyGone;
479 }
480 SignalOutcome::Refused(err.to_string())
481}
482
483fn refusal_before_signal(state: &ProcessState) -> Option<SignalOutcome> {
489 match state {
490 ProcessState::Alive => None,
491 ProcessState::Unverified(reason) => Some(SignalOutcome::Refused(reason.clone())),
492 ProcessState::Gone(_) => Some(SignalOutcome::AlreadyGone),
493 }
494}
495
496pub fn prune(record: &RunningRecord) {
499 if let Ok(dir) = running_dir() {
500 let _ = std::fs::remove_file(record_path(&dir, &record.session_id));
501 let _ = std::fs::remove_file(token_path(&dir, &record.session_id));
502 }
503}
504
505pub fn control_token_path(session_id: &str) -> Result<PathBuf, String> {
510 Ok(token_path(&running_dir()?, session_id))
511}
512
513pub struct ControlTokenGuard {
518 path: PathBuf,
519}
520
521impl ControlTokenGuard {
522 pub fn write(session_id: &str, token: &str) -> Result<Self, String> {
527 let path = control_token_path(session_id)?;
528 crate::retention::StagedRewrite::stage_with_mode(
529 &path,
530 token.as_bytes(),
531 Some(RECORD_FILE_MODE),
532 )
533 .and_then(crate::retention::StagedRewrite::commit)
534 .map_err(|reason| format!("failed to write {}: {reason}", path.display()))?;
535 Ok(Self { path })
536 }
537}
538
539impl Drop for ControlTokenGuard {
540 fn drop(&mut self) {
541 let _ = std::fs::remove_file(&self.path);
542 }
543}
544
545pub struct RunningGuard {
551 path: PathBuf,
552}
553
554impl RunningGuard {
555 pub fn write(record: &RunningRecord) -> Result<Self, String> {
561 let dir = running_dir()?;
562 let path = record_path(&dir, &record.session_id);
563 let body = serde_json::to_vec_pretty(record)
564 .map_err(|err| format!("the record could not be serialized: {err}"))?;
565
566 crate::retention::StagedRewrite::stage_with_mode(&path, &body, Some(RECORD_FILE_MODE))
570 .and_then(crate::retention::StagedRewrite::commit)
571 .map_err(|reason| format!("failed to write {}: {reason}", path.display()))?;
572 Ok(Self { path })
573 }
574}
575
576impl Drop for RunningGuard {
577 fn drop(&mut self) {
578 let _ = std::fs::remove_file(&self.path);
579 let _ = std::fs::remove_file(self.path.with_extension(CONTROL_TOKEN_EXTENSION));
581 }
582}
583
584#[must_use]
589pub fn has_controlling_terminal() -> bool {
590 std::fs::File::open("/dev/tty").is_ok()
591}
592
593fn record_path(dir: &Path, session_id: &str) -> PathBuf {
594 dir.join(format!("{session_id}.json"))
595}
596
597fn token_path(dir: &Path, session_id: &str) -> PathBuf {
598 dir.join(format!("{session_id}.{CONTROL_TOKEN_EXTENSION}"))
599}
600
601fn read_record(path: &Path) -> Option<RunningRecord> {
602 let body = std::fs::read_to_string(path).ok()?;
603 serde_json::from_str(&body).ok()
604}
605
606#[allow(unsafe_code)]
618pub(crate) fn pid_is_alive(pid: u32) -> bool {
619 let Ok(target) = libc::pid_t::try_from(pid) else {
620 return false;
621 };
622 if target == 0 {
623 return false;
624 }
625 let held = unsafe { libc::kill(target, 0) } == 0
628 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM);
629 held && !platform::is_zombie(pid)
630}
631
632#[must_use]
643pub fn process_start_token(pid: u32) -> Option<String> {
644 platform::process_start_token(pid)
645}
646
647#[cfg(target_os = "linux")]
648mod platform {
649 const STARTTIME_OFFSET_AFTER_COMM: usize = 22 - 3;
655
656 pub(super) fn process_start_token(pid: u32) -> Option<String> {
657 let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
658 start_token_from_stat(&stat)
659 }
660
661 pub(super) fn start_token_from_stat(stat: &str) -> Option<String> {
664 fields_after_comm(stat)?
665 .nth(STARTTIME_OFFSET_AFTER_COMM)
666 .map(str::to_string)
667 }
668
669 fn fields_after_comm(stat: &str) -> Option<std::str::SplitWhitespace<'_>> {
671 Some(stat[stat.rfind(')')? + 1..].split_whitespace())
672 }
673
674 pub(super) fn is_zombie(pid: u32) -> bool {
680 let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
681 return false;
682 };
683 fields_after_comm(&stat).and_then(|mut fields| fields.next()) == Some("Z")
684 }
685}
686
687#[cfg(target_os = "macos")]
688mod platform {
689 use std::mem::size_of;
690
691 pub(super) fn process_start_token(pid: u32) -> Option<String> {
693 let info = bsd_info(pid).ok()?;
694 Some(format!(
695 "{}.{:06}",
696 info.pbi_start_tvsec, info.pbi_start_tvusec
697 ))
698 }
699
700 pub(super) fn is_zombie(pid: u32) -> bool {
708 match bsd_info(pid) {
709 Ok(info) => info.pbi_status == libc::SZOMB,
710 Err(err) => err.raw_os_error() == Some(libc::ESRCH),
711 }
712 }
713
714 #[allow(unsafe_code)]
717 fn bsd_info(pid: u32) -> std::io::Result<libc::proc_bsdinfo> {
718 let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
721 let size = size_of::<libc::proc_bsdinfo>();
722
723 let written = unsafe {
727 libc::proc_pidinfo(
728 pid as libc::c_int,
729 libc::PROC_PIDTBSDINFO,
730 0,
731 std::ptr::addr_of_mut!(info).cast(),
732 size as libc::c_int,
733 )
734 };
735 if written <= 0 {
736 return Err(std::io::Error::last_os_error());
737 }
738 if usize::try_from(written).ok() != Some(size) {
739 return Err(std::io::Error::other(format!(
740 "proc_pidinfo wrote {written} bytes, not one proc_bsdinfo of {size}"
741 )));
742 }
743 Ok(info)
744 }
745}
746
747fn probe_session_id(record: &RunningRecord) -> Result<String, String> {
754 probe_door_session_id(&record.url, record.door_token.as_ref())
755}
756
757pub(crate) fn probe_door_session_id(
763 url: &str,
764 token: Option<&crate::door_auth::DoorToken>,
765) -> Result<String, String> {
766 let addr = url
767 .trim_start_matches("http://")
768 .trim_start_matches("https://");
769 let card = match token {
770 Some(token) => {
771 let body = serde_json::json!({
772 "jsonrpc": "2.0",
773 "id": 1,
774 "method": crate::identity::DoorMethod::GetAuthenticatedExtendedCard.wire_name(),
775 "params": {},
776 })
777 .to_string();
778 let authorization = crate::door_auth::bearer_header(token);
779 let response = crate::http_client::http_json_with_timeouts(
780 "POST",
781 &format!("http://{addr}/"),
782 Some(&body),
783 &[("Authorization", authorization.as_str())],
784 PROBE_CONNECT_TIMEOUT,
785 PROBE_READ_TIMEOUT,
786 )?;
787 response.get("result").cloned().ok_or_else(|| {
788 format!("the capsule at {addr} did not return its extended agent card")
789 })?
790 }
791 None => crate::http_client::http_json_with_timeouts(
792 "GET",
793 &format!("http://{addr}/.well-known/agent-card.json"),
794 None,
795 &[("Accept", "application/json")],
796 PROBE_CONNECT_TIMEOUT,
797 PROBE_READ_TIMEOUT,
798 )?,
799 };
800 crate::identity::session_id_from_card(&card)
801 .map(str::to_string)
802 .ok_or_else(|| format!("the agent card from {addr} names no session"))
803}
804
805#[cfg(test)]
806mod tests {
807 use super::*;
808
809 #[test]
810 fn prune_removes_the_record_and_its_control_token() {
811 let home = tempfile::tempdir().unwrap();
812 crate::murmur_home::run_with_home(
813 "running::tests::inner_prune_removes_the_record_and_its_control_token",
814 home.path(),
815 );
816 }
817
818 #[test]
819 #[ignore = "run by prune_removes_the_record_and_its_control_token"]
820 fn inner_prune_removes_the_record_and_its_control_token() {
821 if !crate::murmur_home::in_scratch_home() {
822 return;
823 }
824 let record = RunningRecord {
825 session_id: "ses_0199prunecontroltoken000000000000".to_string(),
826 url: "localhost:1".to_string(),
827 pid: std::process::id(),
828 process_start: String::new(),
829 capsule_name: "c".to_string(),
830 capsule_version: "0.1.0".to_string(),
831 workdir: PathBuf::from("/tmp"),
832 outlives_launcher: false,
833 started_at: "2026-01-01T00:00:00Z".to_string(),
834 door_token: None,
835 credentials: BTreeMap::new(),
836 formation_id: None,
837 formation_lifeline: false,
838 formation_launcher: None,
839 spawned_by: None,
840 };
841 let token = ControlTokenGuard::write(&record.session_id, "ctl1.token.mac").unwrap();
842 let record_guard = RunningGuard::write(&record).unwrap();
843 let dir = running_dir().unwrap();
844 let token_file = control_token_path(&record.session_id).unwrap();
845 assert_eq!(
846 token_file,
847 dir.join(format!("{}.control", record.session_id))
848 );
849 assert_eq!(crate::murmur_home::mode_of(&token_file), RECORD_FILE_MODE);
850 assert_eq!(
851 std::fs::read_to_string(&token_file).unwrap(),
852 "ctl1.token.mac"
853 );
854
855 prune(&record);
856 assert!(!record_path(&dir, &record.session_id).exists());
857 assert!(!token_file.exists());
858
859 std::mem::forget(token);
861 std::mem::forget(record_guard);
862 let _token = ControlTokenGuard::write(&record.session_id, "ctl1.token.mac").unwrap();
863 drop(RunningGuard::write(&record).unwrap());
864 assert!(!token_file.exists());
865 }
866
867 fn record(pid: u32, process_start: &str) -> RunningRecord {
868 RunningRecord {
869 session_id: "ses_0199c4e2f1b7712a9d3e4f5061728394".to_string(),
870 url: "127.0.0.1:1".to_string(),
871 pid,
872 process_start: process_start.to_string(),
873 capsule_name: "demo".to_string(),
874 capsule_version: "0.1.0".to_string(),
875 workdir: PathBuf::from("/tmp/demo"),
876 outlives_launcher: true,
877 started_at: "2026-01-01T00:00:00Z".to_string(),
878 door_token: None,
879 credentials: BTreeMap::new(),
880 formation_id: None,
881 formation_lifeline: false,
882 formation_launcher: None,
883 spawned_by: None,
884 }
885 }
886
887 #[test]
890 fn a_record_serializes_exactly_the_nine_fields() {
891 let value = serde_json::to_value(record(1, "42")).unwrap();
892 let mut keys: Vec<&str> = value
893 .as_object()
894 .unwrap()
895 .keys()
896 .map(String::as_str)
897 .collect();
898 keys.sort_unstable();
899 assert_eq!(
900 keys,
901 [
902 "capsule_name",
903 "capsule_version",
904 "outlives_launcher",
905 "pid",
906 "process_start",
907 "session_id",
908 "started_at",
909 "url",
910 "workdir",
911 ]
912 );
913 }
914
915 #[test]
916 fn a_record_with_a_door_token_serializes_it_beside_the_nine_fields() {
917 let mut with_token = record(1, "42");
918 with_token.door_token = Some(crate::door_auth::DoorToken::new(
919 "mdt1.payload.mac".to_string(),
920 ));
921 let value = serde_json::to_value(&with_token).unwrap();
922 let object = value.as_object().unwrap();
923 assert_eq!(object.len(), 10);
924 assert_eq!(object["door_token"], "mdt1.payload.mac");
925 let back: RunningRecord = serde_json::from_value(value).unwrap();
926 assert_eq!(back, with_token);
927 }
928
929 #[test]
930 fn a_record_debug_prints_no_door_token() {
931 let mut with_token = record(1, "42");
932 with_token.door_token = Some(crate::door_auth::DoorToken::new(
933 "mdt1.secretpayload.secretmac".to_string(),
934 ));
935 let debug = format!("{with_token:?}");
936 assert!(!debug.contains("secretpayload"), "{debug}");
937 assert!(debug.contains("DoorToken(<redacted>)"), "{debug}");
938 }
939
940 fn with_credentials() -> RunningRecord {
941 let mut authenticated = record(1, "42");
942 authenticated.door_token = Some(crate::door_auth::DoorToken::new(
943 "mdt1.operatorpayload.mac".to_string(),
944 ));
945 for name in ["watcher", "reader"] {
946 authenticated.credentials.insert(
947 name.to_string(),
948 crate::door_auth::DoorToken::new(format!("mdt1.{name}secret.mac")),
949 );
950 }
951 authenticated
952 }
953
954 #[test]
955 fn a_record_with_credentials_serializes_them_as_an_object_by_name() {
956 let authenticated = with_credentials();
957 let value = serde_json::to_value(&authenticated).unwrap();
958 let object = value.as_object().unwrap();
959 assert_eq!(object.len(), 11);
960 assert_eq!(
961 object["credentials"],
962 serde_json::json!({
963 "reader": "mdt1.readersecret.mac",
964 "watcher": "mdt1.watchersecret.mac",
965 })
966 );
967 let back: RunningRecord = serde_json::from_value(value).unwrap();
968 assert_eq!(back, authenticated);
969 }
970
971 #[test]
972 fn a_record_debug_prints_no_credential_token() {
973 let debug = format!("{:?}", with_credentials());
974 assert!(!debug.contains("secret"), "{debug}");
975 assert!(!debug.contains("operatorpayload"), "{debug}");
976 assert!(
977 debug.contains("\"watcher\": DoorToken(<redacted>)"),
978 "{debug}"
979 );
980 }
981
982 #[test]
984 fn a_record_with_no_declared_credentials_omits_the_key() {
985 let mut operator_only = record(1, "42");
986 operator_only.door_token = Some(crate::door_auth::DoorToken::new(
987 "mdt1.payload.mac".to_string(),
988 ));
989 let value = serde_json::to_value(&operator_only).unwrap();
990 assert!(value.get("credentials").is_none(), "{value}");
991 }
992
993 #[test]
995 fn a_record_without_a_credentials_key_parses_as_empty() {
996 let mut value = serde_json::to_value(with_credentials()).unwrap();
997 value.as_object_mut().unwrap().remove("credentials");
998 let parsed: RunningRecord = serde_json::from_value(value).unwrap();
999 assert!(parsed.credentials.is_empty());
1000 assert!(parsed.door_token.is_some());
1001 }
1002
1003 #[test]
1005 fn a_record_without_a_door_token_key_parses() {
1006 let value = serde_json::to_value(record(1, "42")).unwrap();
1007 assert!(value.get("door_token").is_none());
1008 let parsed: RunningRecord = serde_json::from_value(value).unwrap();
1009 assert_eq!(parsed.door_token, None);
1010 }
1011
1012 #[test]
1013 fn a_member_record_serializes_its_formation_beside_the_nine_fields() {
1014 let formation = crate::formation::FormationId::mint();
1015 let mut member = record(1, "42");
1016 member.formation_id = Some(formation.clone());
1017 let value = serde_json::to_value(&member).unwrap();
1018 let object = value.as_object().unwrap();
1019 assert_eq!(object.len(), 10);
1020 assert_eq!(object["formation_id"], formation.as_str());
1021 let back: RunningRecord = serde_json::from_value(value).unwrap();
1022 assert_eq!(back, member);
1023 }
1024
1025 #[test]
1027 fn a_standalone_record_has_no_formation_key() {
1028 let body = serde_json::to_string_pretty(&record(1, "42")).unwrap();
1029 assert!(!body.contains("formation"), "{body}");
1030 let parsed: RunningRecord = serde_json::from_str(&body).unwrap();
1031 assert_eq!(parsed.formation_id, None);
1032 }
1033
1034 #[test]
1035 fn a_record_with_a_malformed_formation_id_does_not_parse() {
1036 let mut value = serde_json::to_value(record(1, "42")).unwrap();
1037 value
1038 .as_object_mut()
1039 .unwrap()
1040 .insert("formation_id".to_string(), "frm_ABC".into());
1041 assert!(serde_json::from_value::<RunningRecord>(value).is_err());
1042 }
1043
1044 #[test]
1045 fn list_prunes_a_record_with_a_malformed_formation_id() {
1046 let home = tempfile::tempdir().unwrap();
1047 crate::murmur_home::run_with_home(
1048 "running::tests::inner_list_prunes_a_record_with_a_malformed_formation_id",
1049 home.path(),
1050 );
1051 }
1052
1053 #[test]
1054 #[ignore = "run by list_prunes_a_record_with_a_malformed_formation_id"]
1055 fn inner_list_prunes_a_record_with_a_malformed_formation_id() {
1056 if !crate::murmur_home::in_scratch_home() {
1057 return;
1058 }
1059 let dir = running_dir().unwrap();
1060 let mut member = record(1, "42");
1061 member.formation_id = Some(crate::formation::FormationId::mint());
1062 let mut malformed = serde_json::to_value(record(2, "42")).unwrap();
1063 malformed["session_id"] = "ses_0199c4e2f1b7712a9d3e4f5061728395".into();
1064 malformed["formation_id"] = "frm_ABC".into();
1065 let member_path = record_path(&dir, &member.session_id);
1066 let malformed_path = dir.join("ses_0199c4e2f1b7712a9d3e4f5061728395.json");
1067 std::fs::write(&member_path, serde_json::to_vec_pretty(&member).unwrap()).unwrap();
1068 std::fs::write(
1069 &malformed_path,
1070 serde_json::to_vec_pretty(&malformed).unwrap(),
1071 )
1072 .unwrap();
1073
1074 assert_eq!(list().unwrap(), vec![member]);
1075 assert!(member_path.exists());
1076 assert!(!malformed_path.exists());
1077 }
1078
1079 fn launched_member() -> RunningRecord {
1080 let mut member = record(1, "42");
1081 member.formation_id = Some(crate::formation::FormationId::mint());
1082 member.formation_lifeline = true;
1083 member.formation_launcher = Some(ProcessIdentity {
1084 pid: 4242,
1085 process_start: "987654".to_string(),
1086 });
1087 member.spawned_by = Some("ses_0199c4e2f1b7712a9d3e4f5061728390".to_string());
1088 member
1089 }
1090
1091 #[test]
1092 fn a_launched_members_record_round_trips_its_launcher_and_lineage() {
1093 let member = launched_member();
1094 let value = serde_json::to_value(&member).unwrap();
1095 assert_eq!(value["formation_lifeline"], true);
1096 assert_eq!(
1097 value["formation_launcher"],
1098 serde_json::json!({"pid": 4242, "process_start": "987654"})
1099 );
1100 assert_eq!(value["spawned_by"], "ses_0199c4e2f1b7712a9d3e4f5061728390");
1101 let back: RunningRecord = serde_json::from_value(value).unwrap();
1102 assert_eq!(back, member);
1103 }
1104
1105 #[test]
1108 fn a_record_holding_no_lifeline_launcher_or_spawner_omits_all_three_keys() {
1109 let mut member = launched_member();
1110 member.formation_lifeline = false;
1111 member.formation_launcher = None;
1112 member.spawned_by = None;
1113 let body = serde_json::to_string_pretty(&member).unwrap();
1114 for key in ["formation_lifeline", "formation_launcher", "spawned_by"] {
1115 assert!(!body.contains(key), "{key} in {body}");
1116 }
1117 assert_eq!(
1118 serde_json::from_str::<RunningRecord>(&body).unwrap(),
1119 member
1120 );
1121 }
1122
1123 #[test]
1124 fn a_record_with_a_malformed_formation_launcher_does_not_parse() {
1125 for launcher in [
1126 serde_json::json!(4242),
1127 serde_json::json!({"pid": 4242}),
1128 serde_json::json!({"process_start": "1"}),
1129 serde_json::json!({"pid": -1, "process_start": "1"}),
1130 serde_json::json!({"pid": "4242", "process_start": "1"}),
1131 ] {
1132 let mut value = serde_json::to_value(record(1, "42")).unwrap();
1133 value["formation_launcher"] = launcher.clone();
1134 assert!(
1135 serde_json::from_value::<RunningRecord>(value).is_err(),
1136 "{launcher}"
1137 );
1138 }
1139 }
1140
1141 #[test]
1144 fn an_identity_reads_and_signals_as_the_record_naming_the_same_process() {
1145 let pid = std::process::id();
1146 let token = process_start_token(pid).unwrap();
1147 for start in [token.as_str(), "not-this-process", ""] {
1148 let record = record(pid, start);
1149 let identity = ProcessIdentity {
1150 pid,
1151 process_start: start.to_string(),
1152 };
1153 assert_eq!(identity_state(&identity), process_state(&record), "{start}");
1154 }
1155 let stale = ProcessIdentity {
1157 pid,
1158 process_start: "not-this-process".to_string(),
1159 };
1160 assert_eq!(
1161 signal_term_process(&stale),
1162 signal_term(&record(pid, "not-this-process"))
1163 );
1164 assert_eq!(signal_kill_process(&stale), SignalOutcome::AlreadyGone);
1165 let nobody = ProcessIdentity {
1166 pid: 0,
1167 process_start: "42".to_string(),
1168 };
1169 assert_eq!(signal_term_process(&nobody), signal_term(&record(0, "42")));
1170 assert_eq!(
1171 ProcessIdentity::of_this_process(),
1172 Some(ProcessIdentity {
1173 pid,
1174 process_start: token
1175 })
1176 );
1177 }
1178
1179 #[test]
1181 fn a_term_sent_through_an_identity_reaches_the_process() {
1182 let mut child = std::process::Command::new("sleep")
1183 .arg("30")
1184 .spawn()
1185 .expect("sleep should start");
1186 let pid = child.id();
1187 let identity = ProcessIdentity {
1188 pid,
1189 process_start: process_start_token(pid).unwrap(),
1190 };
1191 assert_eq!(identity_state(&identity), ProcessState::Alive);
1192 assert_eq!(signal_term_process(&identity), SignalOutcome::Sent);
1193 let status = child.wait().unwrap();
1194 use std::os::unix::process::ExitStatusExt;
1195 assert_eq!(status.signal(), Some(libc::SIGTERM));
1196 assert!(matches!(identity_state(&identity), ProcessState::Gone(_)));
1197 }
1198
1199 #[test]
1202 fn a_record_missing_a_field_does_not_parse() {
1203 let mut value = serde_json::to_value(record(1, "42")).unwrap();
1204 value.as_object_mut().unwrap().remove("process_start");
1205 assert!(serde_json::from_value::<RunningRecord>(value).is_err());
1206 }
1207
1208 #[test]
1210 fn this_process_reads_as_alive() {
1211 let pid = std::process::id();
1212 let token = process_start_token(pid).expect("this process has a start time");
1213 assert_eq!(process_state(&record(pid, &token)), ProcessState::Alive);
1214 }
1215
1216 #[test]
1218 fn a_start_time_that_disagrees_reads_as_gone() {
1219 let pid = std::process::id();
1220 let state = process_state(&record(pid, "not-this-process"));
1221 match state {
1222 ProcessState::Gone(reason) => assert!(
1223 reason.contains("another time"),
1224 "the reason must say the pid belongs to another process now: {reason}"
1225 ),
1226 other => panic!("a mismatched start time must read as gone, not {other:?}"),
1227 }
1228 }
1229
1230 #[test]
1232 fn a_pid_nothing_holds_reads_as_gone() {
1233 let state = process_state(&record(0, "42"));
1235 assert!(matches!(state, ProcessState::Gone(_)));
1236 }
1237
1238 #[test]
1240 fn the_start_token_is_stable_for_one_process() {
1241 let pid = std::process::id();
1242 assert_eq!(process_start_token(pid), process_start_token(pid));
1243 assert!(process_start_token(pid).is_some_and(|token| !token.is_empty()));
1244 }
1245
1246 #[test]
1249 fn a_zombie_does_not_read_as_alive() {
1250 let mut child = std::process::Command::new("sh")
1251 .args(["-c", "exit 0"])
1252 .spawn()
1253 .expect("sh should start");
1254 let pid = child.id();
1255 let token = process_start_token(pid).expect("a live child has a start time");
1256
1257 let deadline = std::time::Instant::now() + Duration::from_secs(10);
1259 loop {
1260 if matches!(process_state(&record(pid, &token)), ProcessState::Gone(_)) {
1261 break;
1262 }
1263 assert!(
1264 std::time::Instant::now() < deadline,
1265 "a zombie kept reading as alive"
1266 );
1267 std::thread::sleep(Duration::from_millis(50));
1268 }
1269 let _ = child.wait();
1270 }
1271
1272 #[test]
1275 fn a_signal_at_a_mismatched_start_time_is_not_sent() {
1276 let record = record(std::process::id(), "not-this-process");
1278 assert_eq!(signal_kill(&record), SignalOutcome::AlreadyGone);
1279 assert_eq!(signal_term(&record), SignalOutcome::AlreadyGone);
1280 }
1281
1282 #[test]
1285 fn a_signal_at_a_pid_nothing_holds_is_already_gone() {
1286 assert_eq!(signal_term(&record(0, "42")), SignalOutcome::AlreadyGone);
1287 }
1288
1289 #[test]
1292 fn a_live_process_behind_a_silent_port_is_unreachable() {
1293 let pid = std::process::id();
1294 let token = process_start_token(pid).unwrap();
1295 let mut record = record(pid, &token);
1296 let port = crate::pinned_port::claim();
1298 record.url = format!("127.0.0.1:{port}");
1299 assert!(matches!(verify(&record), Liveness::Unreachable(_)));
1300 }
1301
1302 const PID: u32 = 4242;
1303
1304 #[test]
1305 fn classify_a_dead_pid_is_gone_whatever_the_token() {
1306 for token in [None, Some("123"), Some("")] {
1307 assert_eq!(
1308 classify(PID, "123", false, token),
1309 ProcessState::Gone("no process holds pid 4242".to_string()),
1310 "token {token:?}"
1311 );
1312 }
1313 }
1314
1315 #[test]
1316 fn classify_a_live_pid_with_the_recorded_token_is_alive() {
1317 assert_eq!(classify(PID, "123", true, Some("123")), ProcessState::Alive);
1318 }
1319
1320 #[test]
1321 fn classify_a_live_pid_with_another_token_is_gone() {
1322 assert_eq!(
1323 classify(PID, "123", true, Some("456")),
1324 ProcessState::Gone("pid 4242 is held by a process that started at another time".into())
1325 );
1326 }
1327
1328 #[test]
1330 fn classify_a_recorded_empty_token_against_a_fresh_one_is_gone() {
1331 assert!(matches!(
1332 classify(PID, "", true, Some("123")),
1333 ProcessState::Gone(reason) if reason.contains("another time")
1334 ));
1335 }
1336
1337 #[test]
1338 fn classify_a_live_pid_whose_token_cannot_be_read_is_unverified() {
1339 assert_eq!(
1340 classify(PID, "123", true, None),
1341 ProcessState::Unverified("pid 4242's start time could not be read".into())
1342 );
1343 }
1344
1345 #[test]
1346 fn nothing_is_refused_before_signalling_a_confirmed_process() {
1347 assert_eq!(refusal_before_signal(&ProcessState::Alive), None);
1348 }
1349
1350 #[test]
1351 fn a_gone_process_is_already_gone_before_any_signal() {
1352 assert_eq!(
1353 refusal_before_signal(&ProcessState::Gone("no process holds pid 1".into())),
1354 Some(SignalOutcome::AlreadyGone)
1355 );
1356 }
1357
1358 #[test]
1359 fn an_unverified_process_is_refused_before_any_signal() {
1360 let reason = "pid 1's start time could not be read".to_string();
1361 assert_eq!(
1362 refusal_before_signal(&ProcessState::Unverified(reason.clone())),
1363 Some(SignalOutcome::Refused(reason))
1364 );
1365 }
1366
1367 #[test]
1370 fn an_unverified_process_is_unreachable_without_a_probe() {
1371 let reason = "pid 1's start time could not be read".to_string();
1372 assert_eq!(
1373 liveness_without_probe(ProcessState::Unverified(reason.clone())),
1374 Some(Liveness::Unreachable(reason))
1375 );
1376 assert_eq!(
1377 liveness_without_probe(ProcessState::Gone("gone".into())),
1378 Some(Liveness::Gone("gone".into()))
1379 );
1380 assert_eq!(liveness_without_probe(ProcessState::Alive), None);
1381 }
1382
1383 #[cfg(target_os = "linux")]
1385 fn stat_line(comm: &str) -> String {
1386 let fields: Vec<String> = (3..=21).map(|field| field.to_string()).collect();
1387 format!("1234 ({comm}) {} 987654 23 24\n", fields.join(" "))
1388 }
1389
1390 #[cfg(target_os = "linux")]
1391 #[test]
1392 fn the_start_token_is_field_22_of_a_plain_stat_line() {
1393 assert_eq!(
1394 platform::start_token_from_stat(&stat_line("sleep")),
1395 Some("987654".to_string())
1396 );
1397 }
1398
1399 #[cfg(target_os = "linux")]
1400 #[test]
1401 fn the_start_token_is_counted_from_the_last_close_paren() {
1402 assert_eq!(
1403 platform::start_token_from_stat(&stat_line("a) (b c")),
1404 Some("987654".to_string())
1405 );
1406 }
1407
1408 #[cfg(target_os = "linux")]
1409 #[test]
1410 fn a_stat_line_with_no_close_paren_has_no_start_token() {
1411 assert_eq!(platform::start_token_from_stat("1234 sleep S 1 2 3"), None);
1412 }
1413
1414 #[cfg(target_os = "linux")]
1415 #[test]
1416 fn a_stat_line_truncated_before_field_22_has_no_start_token() {
1417 let line = stat_line("sleep");
1418 let truncated = &line[..line.find(" 987654").unwrap()];
1419 assert_eq!(platform::start_token_from_stat(truncated), None);
1420 }
1421}
1422
1423#[cfg(test)]
1424mod home_tests {
1425 use super::*;
1426
1427 #[test]
1428 fn running_dir_holds_a_wide_murmur_home_owner_only() {
1429 let home = tempfile::tempdir().unwrap();
1430 crate::murmur_home::wide_dir(&home.path().join(".murmur"), 0o755);
1431 crate::murmur_home::run_with_home(
1432 "running::home_tests::inner_running_dir_under_scratch_home",
1433 home.path(),
1434 );
1435 assert_eq!(
1436 crate::murmur_home::mode_of(&home.path().join(".murmur")),
1437 0o700
1438 );
1439 assert_eq!(
1440 crate::murmur_home::mode_of(&home.path().join(".murmur").join(RUNNING_DIR)),
1441 RUNNING_DIR_MODE
1442 );
1443 }
1444
1445 #[test]
1446 #[ignore = "run by running_dir_holds_a_wide_murmur_home_owner_only"]
1447 fn inner_running_dir_under_scratch_home() {
1448 if !crate::murmur_home::in_scratch_home() {
1449 return;
1450 }
1451 running_dir().unwrap();
1452 }
1453
1454 #[test]
1455 fn running_dir_location_creates_nothing() {
1456 let home = tempfile::tempdir().unwrap();
1457 crate::murmur_home::run_with_home(
1458 "running::home_tests::inner_running_dir_location_creates_nothing",
1459 home.path(),
1460 );
1461 assert!(!home.path().join(".murmur").exists());
1462 }
1463
1464 #[test]
1465 #[ignore = "run by running_dir_location_creates_nothing"]
1466 fn inner_running_dir_location_creates_nothing() {
1467 if !crate::murmur_home::in_scratch_home() {
1468 return;
1469 }
1470 let home = PathBuf::from(std::env::var_os("HOME").unwrap());
1471 assert_eq!(
1472 running_dir_location().unwrap(),
1473 home.join(".murmur").join(RUNNING_DIR)
1474 );
1475 assert!(!home.join(".murmur").exists());
1476 }
1477
1478 fn record(session_id: &str) -> RunningRecord {
1479 RunningRecord {
1480 session_id: session_id.to_string(),
1481 url: "127.0.0.1:1".to_string(),
1482 pid: 1,
1483 process_start: "42".to_string(),
1484 capsule_name: "demo".to_string(),
1485 capsule_version: "0.1.0".to_string(),
1486 workdir: PathBuf::from("/tmp/demo"),
1487 outlives_launcher: true,
1488 started_at: "2026-01-01T00:00:00Z".to_string(),
1489 door_token: None,
1490 credentials: BTreeMap::new(),
1491 formation_id: None,
1492 formation_lifeline: false,
1493 formation_launcher: None,
1494 spawned_by: None,
1495 }
1496 }
1497
1498 #[test]
1499 fn scan_reports_an_unparseable_file_and_leaves_it_in_place() {
1500 let dir = tempfile::tempdir().unwrap();
1501 let older = record("ses_0199c4e2f1b7712a9d3e4f5061728390");
1502 let newer = record("ses_0199c4e2f1b7712a9d3e4f5061728391");
1503 for record in [&older, &newer] {
1504 std::fs::write(
1505 record_path(dir.path(), &record.session_id),
1506 serde_json::to_vec(record).unwrap(),
1507 )
1508 .unwrap();
1509 }
1510 let broken = dir.path().join("ses_broken.json");
1511 std::fs::write(&broken, "{").unwrap();
1512 let staging = dir.path().join(".ses_x.json.1.2.tmp");
1513 std::fs::write(&staging, "{").unwrap();
1514 let control = token_path(dir.path(), &older.session_id);
1515 std::fs::write(&control, "ctl1.token.mac").unwrap();
1516
1517 let scan = scan(dir.path()).unwrap();
1518
1519 assert_eq!(scan.records, vec![newer, older]);
1520 assert_eq!(scan.unreadable, vec![broken.clone()]);
1521 assert_eq!(std::fs::read_to_string(&broken).unwrap(), "{");
1522 assert!(staging.exists());
1523 assert!(control.exists());
1524 }
1525
1526 #[test]
1527 fn scan_of_a_missing_directory_is_empty_and_creates_nothing() {
1528 let parent = tempfile::tempdir().unwrap();
1529 let dir = parent.path().join(".murmur").join(RUNNING_DIR);
1530
1531 let scan = scan(&dir).unwrap();
1532
1533 assert!(scan.records.is_empty());
1534 assert!(scan.unreadable.is_empty());
1535 assert!(!parent.path().join(".murmur").exists());
1536 }
1537
1538 #[test]
1539 fn scan_of_a_regular_file_errs_naming_it() {
1540 let parent = tempfile::tempdir().unwrap();
1541 let file = parent.path().join(RUNNING_DIR);
1542 std::fs::write(&file, "").unwrap();
1543
1544 let err = scan(&file).unwrap_err();
1545
1546 assert!(err.starts_with(&format!("{}: ", file.display())), "{err}");
1547 }
1548}