1use std::{
40 collections::BTreeMap,
41 path::{Path, PathBuf},
42 time::Duration,
43};
44
45use serde::{Deserialize, Serialize};
46
47const RUNNING_DIR: &str = "running";
49
50const RUNNING_DIR_MODE: u32 = 0o700;
52
53const RECORD_FILE_MODE: u32 = 0o600;
55
56const CONTROL_TOKEN_EXTENSION: &str = "control";
58
59const PROBE_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
62
63const PROBE_READ_TIMEOUT: Duration = Duration::from_secs(5);
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct RunningRecord {
78 pub session_id: String,
80 pub url: Option<String>,
83 pub pid: u32,
85 pub process_start: String,
87 pub capsule_name: String,
88 pub capsule_version: String,
89 pub workdir: PathBuf,
91 pub records_dir: PathBuf,
97 pub outlives_launcher: bool,
100 pub started_at: String,
102 #[serde(
106 default,
107 skip_serializing_if = "Option::is_none",
108 with = "door_token_field"
109 )]
110 pub door_token: Option<crate::door_auth::DoorToken>,
111 #[serde(
117 default,
118 skip_serializing_if = "BTreeMap::is_empty",
119 with = "door_credentials_field"
120 )]
121 pub credentials: BTreeMap<String, crate::door_auth::DoorToken>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub formation_id: Option<crate::formation::FormationId>,
128 #[serde(
135 default,
136 skip_serializing_if = "Option::is_none",
137 deserialize_with = "member_name_field::deserialize"
138 )]
139 pub formation_member: Option<String>,
140 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
145 pub formation_entry: bool,
146 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
149 pub formation_lifeline: bool,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub formation_launcher: Option<ProcessIdentity>,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub spawned_by: Option<String>,
160 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
162 pub unconfined: bool,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168pub struct ProcessIdentity {
169 pub pid: u32,
170 pub process_start: String,
171}
172
173impl ProcessIdentity {
174 pub fn of_this_process() -> Option<Self> {
177 let pid = std::process::id();
178 Some(Self {
179 pid,
180 process_start: process_start_token(pid)?,
181 })
182 }
183}
184
185mod door_token_field {
188 use crate::door_auth::DoorToken;
189 use serde::{Deserialize, Deserializer, Serializer};
190
191 pub(super) fn serialize<S: Serializer>(
192 token: &Option<DoorToken>,
193 serializer: S,
194 ) -> Result<S::Ok, S::Error> {
195 match token {
196 Some(token) => serializer.serialize_some(token.expose()),
197 None => serializer.serialize_none(),
198 }
199 }
200
201 pub(super) fn deserialize<'de, D: Deserializer<'de>>(
202 deserializer: D,
203 ) -> Result<Option<DoorToken>, D::Error> {
204 Ok(Option::<String>::deserialize(deserializer)?.map(DoorToken::new))
205 }
206}
207
208mod door_credentials_field {
212 use std::collections::BTreeMap;
213
214 use crate::door_auth::DoorToken;
215 use serde::{ser::SerializeMap, Deserialize, Deserializer, Serializer};
216
217 pub(super) fn serialize<S: Serializer>(
218 credentials: &BTreeMap<String, DoorToken>,
219 serializer: S,
220 ) -> Result<S::Ok, S::Error> {
221 let mut map = serializer.serialize_map(Some(credentials.len()))?;
222 for (name, token) in credentials {
223 map.serialize_entry(name, token.expose())?;
224 }
225 map.end()
226 }
227
228 pub(super) fn deserialize<'de, D: Deserializer<'de>>(
229 deserializer: D,
230 ) -> Result<BTreeMap<String, DoorToken>, D::Error> {
231 Ok(BTreeMap::<String, String>::deserialize(deserializer)?
232 .into_iter()
233 .map(|(name, token)| (name, DoorToken::new(token)))
234 .collect())
235 }
236}
237
238mod member_name_field {
241 use serde::{de::Error, Deserialize, Deserializer};
242
243 pub(super) fn deserialize<'de, D: Deserializer<'de>>(
244 deserializer: D,
245 ) -> Result<Option<String>, D::Error> {
246 let Some(name) = Option::<String>::deserialize(deserializer)? else {
247 return Ok(None);
248 };
249 match murmur_artifact::member_name_format_error(&name) {
250 Some(reason) => Err(D::Error::custom(format!(
251 "formation_member {name:?} is not a member name: it {reason}"
252 ))),
253 None => Ok(Some(name)),
254 }
255 }
256}
257
258pub fn running_dir() -> Result<PathBuf, String> {
265 let dir = crate::murmur_home::ensure_murmur_home()?.join(RUNNING_DIR);
266 crate::state_store::ensure_private_dir(&dir, RUNNING_DIR_MODE)
267 .map_err(|reason| format!("{}: {reason}", dir.display()))?;
268 Ok(dir)
269}
270
271pub fn running_dir_location() -> Result<PathBuf, String> {
278 Ok(crate::state_store::murmur_home_dir()?.join(RUNNING_DIR))
279}
280
281pub fn record_location(session_id: &str) -> Result<PathBuf, String> {
286 Ok(record_path(&running_dir_location()?, session_id))
287}
288
289#[derive(Debug, Default)]
291pub struct RecordScan {
292 pub records: Vec<RunningRecord>,
294 pub older_mur: Vec<PathBuf>,
298 pub unreadable: Vec<PathBuf>,
300}
301
302#[derive(Debug)]
304enum RecordRead {
305 Record(Box<RunningRecord>),
306 OlderMur,
308 Unreadable,
309}
310
311pub fn scan(dir: &Path) -> Result<RecordScan, String> {
322 let entries = match std::fs::read_dir(dir) {
323 Ok(entries) => entries,
324 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(RecordScan::default()),
325 Err(err) => return Err(format!("{}: {err}", dir.display())),
326 };
327
328 let mut scan = RecordScan::default();
329 for entry in entries.flatten() {
330 let path = entry.path();
331 if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
332 continue;
333 }
334 match read_record(&path) {
335 RecordRead::Record(record) => scan.records.push(*record),
336 RecordRead::OlderMur => scan.older_mur.push(path),
337 RecordRead::Unreadable => scan.unreadable.push(path),
338 }
339 }
340 scan.records
341 .sort_by(|left, right| right.session_id.cmp(&left.session_id));
342 scan.older_mur.sort_by(|left, right| right.cmp(left));
343 Ok(scan)
344}
345
346pub fn list() -> Result<Vec<RunningRecord>, String> {
357 list_reporting().map(|listing| listing.records)
358}
359
360#[derive(Debug, Default)]
362pub struct RecordListing {
363 pub records: Vec<RunningRecord>,
365 pub older_mur: Vec<PathBuf>,
368 pub removed_unreadable: Vec<PathBuf>,
371}
372
373pub fn list_reporting() -> Result<RecordListing, String> {
378 list_pruning(&running_dir()?)
379}
380
381fn list_pruning(dir: &Path) -> Result<RecordListing, String> {
383 let scan = scan(dir)?;
384 let removed_unreadable = scan
385 .unreadable
386 .into_iter()
387 .filter(|path| std::fs::remove_file(path).is_ok())
388 .collect();
389 Ok(RecordListing {
390 records: scan.records,
391 older_mur: scan.older_mur,
392 removed_unreadable,
393 })
394}
395
396#[derive(Debug, Clone, PartialEq, Eq)]
401pub enum ProcessState {
402 Alive,
404 Unverified(String),
407 Gone(String),
410}
411
412impl ProcessState {
413 #[must_use]
419 pub fn is_running(&self) -> bool {
420 matches!(self, ProcessState::Alive | ProcessState::Unverified(_))
421 }
422}
423
424pub fn process_state(record: &RunningRecord) -> ProcessState {
430 process_state_of(record.pid, &record.process_start)
431}
432
433pub fn identity_state(identity: &ProcessIdentity) -> ProcessState {
435 process_state_of(identity.pid, &identity.process_start)
436}
437
438impl RunningRecord {
439 pub fn identity(&self) -> ProcessIdentity {
441 ProcessIdentity {
442 pid: self.pid,
443 process_start: self.process_start.clone(),
444 }
445 }
446}
447
448pub(crate) fn process_state_of(pid: u32, recorded_start: &str) -> ProcessState {
451 let alive = pid_is_alive(pid);
452 let token = alive.then(|| process_start_token(pid)).flatten();
453 classify(pid, recorded_start, alive, token.as_deref())
454}
455
456fn classify(
462 pid: u32,
463 recorded_start: &str,
464 pid_alive: bool,
465 fresh_token: Option<&str>,
466) -> ProcessState {
467 if !pid_alive {
468 return ProcessState::Gone(format!("no process holds pid {pid}"));
469 }
470 match fresh_token {
471 Some(token) if token == recorded_start => ProcessState::Alive,
472 Some(_) => ProcessState::Gone(format!(
473 "pid {pid} is held by a process that started at another time"
474 )),
475 None => ProcessState::Unverified(format!("pid {pid}'s start time could not be read")),
476 }
477}
478
479#[derive(Debug, Clone, PartialEq, Eq)]
481pub enum Liveness {
482 Live,
484 Unreachable(String),
487 Gone(String),
489}
490
491impl Liveness {
492 #[must_use]
498 pub fn is_running(&self) -> bool {
499 !matches!(self, Liveness::Gone(_))
500 }
501
502 pub fn reason(&self) -> &str {
504 match self {
505 Liveness::Live => "",
506 Liveness::Unreachable(reason) | Liveness::Gone(reason) => reason,
507 }
508 }
509}
510
511pub fn verify(record: &RunningRecord) -> Liveness {
518 if let Some(liveness) = liveness_without_probe(process_state(record)) {
519 return liveness;
520 }
521 let Some(url) = record.url.as_deref() else {
522 return Liveness::Live;
523 };
524 match probe_door_session_id(url, record.door_token.as_ref()) {
525 Ok(session_id) if session_id == record.session_id => Liveness::Live,
526 Ok(other) => {
527 Liveness::Unreachable(format!("the capsule at {url} answers for session {other}"))
528 }
529 Err(reason) => Liveness::Unreachable(reason),
530 }
531}
532
533fn liveness_without_probe(state: ProcessState) -> Option<Liveness> {
536 match state {
537 ProcessState::Alive => None,
538 ProcessState::Unverified(reason) => Some(Liveness::Unreachable(reason)),
539 ProcessState::Gone(reason) => Some(Liveness::Gone(reason)),
540 }
541}
542
543#[derive(Debug, Clone, PartialEq, Eq)]
545pub enum SignalOutcome {
546 Sent,
548 AlreadyGone,
550 Refused(String),
554}
555
556pub fn signal_term(record: &RunningRecord) -> SignalOutcome {
558 signal(&record.identity(), libc::SIGTERM)
559}
560
561pub fn signal_kill(record: &RunningRecord) -> SignalOutcome {
563 signal(&record.identity(), libc::SIGKILL)
564}
565
566pub fn signal_term_process(identity: &ProcessIdentity) -> SignalOutcome {
568 signal(identity, libc::SIGTERM)
569}
570
571pub fn signal_kill_process(identity: &ProcessIdentity) -> SignalOutcome {
574 signal(identity, libc::SIGKILL)
575}
576
577#[allow(unsafe_code)]
585fn signal(identity: &ProcessIdentity, signal: libc::c_int) -> SignalOutcome {
586 if let Some(outcome) = refusal_before_signal(&identity_state(identity)) {
587 return outcome;
588 }
589 if unsafe { libc::kill(identity.pid as libc::pid_t, signal) } == 0 {
594 return SignalOutcome::Sent;
595 }
596 let err = std::io::Error::last_os_error();
597 if err.raw_os_error() == Some(libc::ESRCH) {
600 return SignalOutcome::AlreadyGone;
601 }
602 SignalOutcome::Refused(err.to_string())
603}
604
605fn refusal_before_signal(state: &ProcessState) -> Option<SignalOutcome> {
611 match state {
612 ProcessState::Alive => None,
613 ProcessState::Unverified(reason) => Some(SignalOutcome::Refused(reason.clone())),
614 ProcessState::Gone(_) => Some(SignalOutcome::AlreadyGone),
615 }
616}
617
618pub fn prune(record: &RunningRecord) {
621 if let Ok(dir) = running_dir() {
622 let _ = std::fs::remove_file(record_path(&dir, &record.session_id));
623 let _ = std::fs::remove_file(token_path(&dir, &record.session_id));
624 }
625}
626
627pub fn control_token_path(session_id: &str) -> Result<PathBuf, String> {
632 Ok(token_path(&running_dir()?, session_id))
633}
634
635pub struct ControlTokenGuard {
640 path: PathBuf,
641}
642
643impl ControlTokenGuard {
644 pub fn write(session_id: &str, token: &str) -> Result<Self, String> {
649 let path = control_token_path(session_id)?;
650 crate::retention::StagedRewrite::stage_with_mode(
651 &path,
652 token.as_bytes(),
653 Some(RECORD_FILE_MODE),
654 )
655 .and_then(crate::retention::StagedRewrite::commit)
656 .map_err(|reason| format!("failed to write {}: {reason}", path.display()))?;
657 Ok(Self { path })
658 }
659}
660
661impl Drop for ControlTokenGuard {
662 fn drop(&mut self) {
663 let _ = std::fs::remove_file(&self.path);
664 }
665}
666
667pub struct RunningGuard {
673 path: PathBuf,
674}
675
676impl RunningGuard {
677 pub fn write(record: &RunningRecord) -> Result<Self, String> {
683 let dir = running_dir()?;
684 let path = record_path(&dir, &record.session_id);
685 let body = serde_json::to_vec_pretty(record)
686 .map_err(|err| format!("the record could not be serialized: {err}"))?;
687
688 crate::retention::StagedRewrite::stage_with_mode(&path, &body, Some(RECORD_FILE_MODE))
692 .and_then(crate::retention::StagedRewrite::commit)
693 .map_err(|reason| format!("failed to write {}: {reason}", path.display()))?;
694 Ok(Self { path })
695 }
696}
697
698impl Drop for RunningGuard {
699 fn drop(&mut self) {
700 let _ = std::fs::remove_file(&self.path);
701 let _ = std::fs::remove_file(self.path.with_extension(CONTROL_TOKEN_EXTENSION));
703 }
704}
705
706#[must_use]
711pub fn has_controlling_terminal() -> bool {
712 std::fs::File::open("/dev/tty").is_ok()
713}
714
715fn record_path(dir: &Path, session_id: &str) -> PathBuf {
716 dir.join(format!("{session_id}.json"))
717}
718
719fn token_path(dir: &Path, session_id: &str) -> PathBuf {
720 dir.join(format!("{session_id}.{CONTROL_TOKEN_EXTENSION}"))
721}
722
723fn read_record(path: &Path) -> RecordRead {
724 let Ok(body) = std::fs::read_to_string(path) else {
725 return RecordRead::Unreadable;
726 };
727 if let Ok(record) = serde_json::from_str(&body) {
728 return RecordRead::Record(record);
729 }
730 match serde_json::from_str::<serde_json::Value>(&body) {
731 Ok(serde_json::Value::Object(object))
732 if object
733 .get("session_id")
734 .is_some_and(serde_json::Value::is_string)
735 && !object.contains_key("records_dir") =>
736 {
737 RecordRead::OlderMur
738 }
739 _ => RecordRead::Unreadable,
740 }
741}
742
743#[allow(unsafe_code)]
755pub(crate) fn pid_is_alive(pid: u32) -> bool {
756 let Ok(target) = libc::pid_t::try_from(pid) else {
757 return false;
758 };
759 if target == 0 {
760 return false;
761 }
762 let held = unsafe { libc::kill(target, 0) } == 0
765 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM);
766 held && !platform::is_zombie(pid)
767}
768
769#[must_use]
780pub fn process_start_token(pid: u32) -> Option<String> {
781 platform::process_start_token(pid)
782}
783
784#[cfg(target_os = "linux")]
785mod platform {
786 const STARTTIME_OFFSET_AFTER_COMM: usize = 22 - 3;
792
793 pub(super) fn process_start_token(pid: u32) -> Option<String> {
794 let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
795 start_token_from_stat(&stat)
796 }
797
798 pub(super) fn start_token_from_stat(stat: &str) -> Option<String> {
801 fields_after_comm(stat)?
802 .nth(STARTTIME_OFFSET_AFTER_COMM)
803 .map(str::to_string)
804 }
805
806 fn fields_after_comm(stat: &str) -> Option<std::str::SplitWhitespace<'_>> {
808 Some(stat[stat.rfind(')')? + 1..].split_whitespace())
809 }
810
811 pub(super) fn is_zombie(pid: u32) -> bool {
817 let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
818 return false;
819 };
820 fields_after_comm(&stat).and_then(|mut fields| fields.next()) == Some("Z")
821 }
822}
823
824#[cfg(target_os = "macos")]
825mod platform {
826 use std::mem::size_of;
827
828 pub(super) fn process_start_token(pid: u32) -> Option<String> {
830 let info = bsd_info(pid).ok()?;
831 Some(format!(
832 "{}.{:06}",
833 info.pbi_start_tvsec, info.pbi_start_tvusec
834 ))
835 }
836
837 pub(super) fn is_zombie(pid: u32) -> bool {
845 match bsd_info(pid) {
846 Ok(info) => info.pbi_status == libc::SZOMB,
847 Err(err) => err.raw_os_error() == Some(libc::ESRCH),
848 }
849 }
850
851 #[allow(unsafe_code)]
854 fn bsd_info(pid: u32) -> std::io::Result<libc::proc_bsdinfo> {
855 let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
858 let size = size_of::<libc::proc_bsdinfo>();
859
860 let written = unsafe {
864 libc::proc_pidinfo(
865 pid as libc::c_int,
866 libc::PROC_PIDTBSDINFO,
867 0,
868 std::ptr::addr_of_mut!(info).cast(),
869 size as libc::c_int,
870 )
871 };
872 if written <= 0 {
873 return Err(std::io::Error::last_os_error());
874 }
875 if usize::try_from(written).ok() != Some(size) {
876 return Err(std::io::Error::other(format!(
877 "proc_pidinfo wrote {written} bytes, not one proc_bsdinfo of {size}"
878 )));
879 }
880 Ok(info)
881 }
882}
883
884pub(crate) fn probe_door_session_id(
894 url: &str,
895 token: Option<&crate::door_auth::DoorToken>,
896) -> Result<String, String> {
897 let addr = url
898 .trim_start_matches("http://")
899 .trim_start_matches("https://");
900 let version = (
901 crate::a2a::A2A_VERSION_HEADER,
902 crate::a2a::A2A_PROTOCOL_VERSION,
903 );
904 let card = match token {
905 Some(token) => {
906 let body = serde_json::json!({
907 "jsonrpc": "2.0",
908 "id": 1,
909 "method": crate::identity::DoorMethod::GetExtendedAgentCard.wire_name(),
910 "params": {},
911 })
912 .to_string();
913 let authorization = crate::door_auth::bearer_header(token);
914 let response = crate::http_client::http_json_with_timeouts(
915 "POST",
916 &format!("http://{addr}/"),
917 Some(&body),
918 &[("Authorization", authorization.as_str()), version],
919 PROBE_CONNECT_TIMEOUT,
920 PROBE_READ_TIMEOUT,
921 )?;
922 response.get("result").cloned().ok_or_else(|| {
923 format!("the capsule at {addr} did not return its extended agent card")
924 })?
925 }
926 None => crate::http_client::http_json_with_timeouts(
927 "GET",
928 &format!("http://{addr}/.well-known/agent-card.json"),
929 None,
930 &[("Accept", "application/json"), version],
931 PROBE_CONNECT_TIMEOUT,
932 PROBE_READ_TIMEOUT,
933 )?,
934 };
935 crate::identity::session_id_from_card(&card)
936 .map(str::to_string)
937 .ok_or_else(|| format!("the agent card from {addr} names no session"))
938}
939
940#[cfg(test)]
941mod tests {
942 use super::*;
943
944 #[test]
945 fn alive_and_unverified_are_running_and_gone_is_not() {
946 assert!(ProcessState::Alive.is_running());
947 assert!(ProcessState::Unverified("start time unreadable".to_string()).is_running());
948 assert!(!ProcessState::Gone("no process holds the pid".to_string()).is_running());
949 }
950
951 #[test]
952 fn prune_removes_the_record_and_its_control_token() {
953 let home = tempfile::tempdir().unwrap();
954 crate::murmur_home::run_with_home(
955 "running::tests::inner_prune_removes_the_record_and_its_control_token",
956 home.path(),
957 );
958 }
959
960 #[test]
961 #[ignore = "run by prune_removes_the_record_and_its_control_token"]
962 fn inner_prune_removes_the_record_and_its_control_token() {
963 if !crate::murmur_home::in_scratch_home() {
964 return;
965 }
966 let record = RunningRecord {
967 session_id: "ses_0199prunecontroltoken000000000000".to_string(),
968 url: Some("localhost:1".to_string()),
969 pid: std::process::id(),
970 process_start: String::new(),
971 capsule_name: "c".to_string(),
972 capsule_version: "0.1.0".to_string(),
973 workdir: PathBuf::from("/tmp"),
974 records_dir: PathBuf::from("/tmp").join("records"),
975 outlives_launcher: false,
976 started_at: "2026-01-01T00:00:00Z".to_string(),
977 door_token: None,
978 credentials: BTreeMap::new(),
979 formation_id: None,
980 formation_member: None,
981 formation_entry: false,
982 formation_lifeline: false,
983 formation_launcher: None,
984 spawned_by: None,
985 unconfined: false,
986 };
987 let token = ControlTokenGuard::write(&record.session_id, "ctl1.token.mac").unwrap();
988 let record_guard = RunningGuard::write(&record).unwrap();
989 let dir = running_dir().unwrap();
990 let token_file = control_token_path(&record.session_id).unwrap();
991 assert_eq!(
992 token_file,
993 dir.join(format!("{}.control", record.session_id))
994 );
995 assert_eq!(crate::murmur_home::mode_of(&token_file), RECORD_FILE_MODE);
996 assert_eq!(
997 std::fs::read_to_string(&token_file).unwrap(),
998 "ctl1.token.mac"
999 );
1000
1001 prune(&record);
1002 assert!(!record_path(&dir, &record.session_id).exists());
1003 assert!(!token_file.exists());
1004
1005 std::mem::forget(token);
1007 std::mem::forget(record_guard);
1008 let _token = ControlTokenGuard::write(&record.session_id, "ctl1.token.mac").unwrap();
1009 drop(RunningGuard::write(&record).unwrap());
1010 assert!(!token_file.exists());
1011 }
1012
1013 fn record(pid: u32, process_start: &str) -> RunningRecord {
1014 RunningRecord {
1015 session_id: "ses_0199c4e2f1b7712a9d3e4f5061728394".to_string(),
1016 url: Some("127.0.0.1:1".to_string()),
1017 pid,
1018 process_start: process_start.to_string(),
1019 capsule_name: "demo".to_string(),
1020 capsule_version: "0.1.0".to_string(),
1021 workdir: PathBuf::from("/tmp/demo"),
1022 records_dir: PathBuf::from("/tmp/demo").join("records"),
1023 outlives_launcher: true,
1024 started_at: "2026-01-01T00:00:00Z".to_string(),
1025 door_token: None,
1026 credentials: BTreeMap::new(),
1027 formation_id: None,
1028 formation_member: None,
1029 formation_entry: false,
1030 formation_lifeline: false,
1031 formation_launcher: None,
1032 spawned_by: None,
1033 unconfined: false,
1034 }
1035 }
1036
1037 #[test]
1040 fn a_record_serializes_exactly_the_ten_fields() {
1041 let value = serde_json::to_value(record(1, "42")).unwrap();
1042 let mut keys: Vec<&str> = value
1043 .as_object()
1044 .unwrap()
1045 .keys()
1046 .map(String::as_str)
1047 .collect();
1048 keys.sort_unstable();
1049 assert_eq!(
1050 keys,
1051 [
1052 "capsule_name",
1053 "capsule_version",
1054 "outlives_launcher",
1055 "pid",
1056 "process_start",
1057 "records_dir",
1058 "session_id",
1059 "started_at",
1060 "url",
1061 "workdir",
1062 ]
1063 );
1064 }
1065
1066 #[test]
1067 fn a_record_with_a_door_token_serializes_it_beside_the_ten_fields() {
1068 let mut with_token = record(1, "42");
1069 with_token.door_token = Some(crate::door_auth::DoorToken::new(
1070 "mdt1.payload.mac".to_string(),
1071 ));
1072 let value = serde_json::to_value(&with_token).unwrap();
1073 let object = value.as_object().unwrap();
1074 assert_eq!(object.len(), 11);
1075 assert_eq!(object["door_token"], "mdt1.payload.mac");
1076 let back: RunningRecord = serde_json::from_value(value).unwrap();
1077 assert_eq!(back, with_token);
1078 }
1079
1080 #[test]
1081 fn a_record_debug_prints_no_door_token() {
1082 let mut with_token = record(1, "42");
1083 with_token.door_token = Some(crate::door_auth::DoorToken::new(
1084 "mdt1.secretpayload.secretmac".to_string(),
1085 ));
1086 let debug = format!("{with_token:?}");
1087 assert!(!debug.contains("secretpayload"), "{debug}");
1088 assert!(debug.contains("DoorToken(<redacted>)"), "{debug}");
1089 }
1090
1091 fn with_credentials() -> RunningRecord {
1092 let mut authenticated = record(1, "42");
1093 authenticated.door_token = Some(crate::door_auth::DoorToken::new(
1094 "mdt1.operatorpayload.mac".to_string(),
1095 ));
1096 for name in ["watcher", "reader"] {
1097 authenticated.credentials.insert(
1098 name.to_string(),
1099 crate::door_auth::DoorToken::new(format!("mdt1.{name}secret.mac")),
1100 );
1101 }
1102 authenticated
1103 }
1104
1105 #[test]
1106 fn a_record_with_credentials_serializes_them_as_an_object_by_name() {
1107 let authenticated = with_credentials();
1108 let value = serde_json::to_value(&authenticated).unwrap();
1109 let object = value.as_object().unwrap();
1110 assert_eq!(object.len(), 12);
1111 assert_eq!(
1112 object["credentials"],
1113 serde_json::json!({
1114 "reader": "mdt1.readersecret.mac",
1115 "watcher": "mdt1.watchersecret.mac",
1116 })
1117 );
1118 let back: RunningRecord = serde_json::from_value(value).unwrap();
1119 assert_eq!(back, authenticated);
1120 }
1121
1122 #[test]
1123 fn a_record_debug_prints_no_credential_token() {
1124 let debug = format!("{:?}", with_credentials());
1125 assert!(!debug.contains("secret"), "{debug}");
1126 assert!(!debug.contains("operatorpayload"), "{debug}");
1127 assert!(
1128 debug.contains("\"watcher\": DoorToken(<redacted>)"),
1129 "{debug}"
1130 );
1131 }
1132
1133 #[test]
1135 fn a_record_with_no_declared_credentials_omits_the_key() {
1136 let mut operator_only = record(1, "42");
1137 operator_only.door_token = Some(crate::door_auth::DoorToken::new(
1138 "mdt1.payload.mac".to_string(),
1139 ));
1140 let value = serde_json::to_value(&operator_only).unwrap();
1141 assert!(value.get("credentials").is_none(), "{value}");
1142 }
1143
1144 #[test]
1146 fn a_record_without_a_credentials_key_parses_as_empty() {
1147 let mut value = serde_json::to_value(with_credentials()).unwrap();
1148 value.as_object_mut().unwrap().remove("credentials");
1149 let parsed: RunningRecord = serde_json::from_value(value).unwrap();
1150 assert!(parsed.credentials.is_empty());
1151 assert!(parsed.door_token.is_some());
1152 }
1153
1154 #[test]
1156 fn a_record_without_a_door_token_key_parses() {
1157 let value = serde_json::to_value(record(1, "42")).unwrap();
1158 assert!(value.get("door_token").is_none());
1159 let parsed: RunningRecord = serde_json::from_value(value).unwrap();
1160 assert_eq!(parsed.door_token, None);
1161 }
1162
1163 #[test]
1164 fn a_member_record_serializes_its_formation_beside_the_ten_fields() {
1165 let formation = crate::formation::FormationId::mint();
1166 let mut member = record(1, "42");
1167 member.formation_id = Some(formation.clone());
1168 let value = serde_json::to_value(&member).unwrap();
1169 let object = value.as_object().unwrap();
1170 assert_eq!(object.len(), 11);
1171 assert_eq!(object["formation_id"], formation.as_str());
1172 let back: RunningRecord = serde_json::from_value(value).unwrap();
1173 assert_eq!(back, member);
1174 }
1175
1176 #[test]
1178 fn a_standalone_record_has_no_formation_key() {
1179 let body = serde_json::to_string_pretty(&record(1, "42")).unwrap();
1180 assert!(!body.contains("formation"), "{body}");
1181 let parsed: RunningRecord = serde_json::from_str(&body).unwrap();
1182 assert_eq!(parsed.formation_id, None);
1183 }
1184
1185 #[test]
1186 fn a_record_with_a_malformed_formation_id_does_not_parse() {
1187 let mut value = serde_json::to_value(record(1, "42")).unwrap();
1188 value
1189 .as_object_mut()
1190 .unwrap()
1191 .insert("formation_id".to_string(), "frm_ABC".into());
1192 assert!(serde_json::from_value::<RunningRecord>(value).is_err());
1193 }
1194
1195 #[test]
1196 fn list_prunes_a_record_with_a_malformed_formation_id() {
1197 let home = tempfile::tempdir().unwrap();
1198 crate::murmur_home::run_with_home(
1199 "running::tests::inner_list_prunes_a_record_with_a_malformed_formation_id",
1200 home.path(),
1201 );
1202 }
1203
1204 #[test]
1205 #[ignore = "run by list_prunes_a_record_with_a_malformed_formation_id"]
1206 fn inner_list_prunes_a_record_with_a_malformed_formation_id() {
1207 if !crate::murmur_home::in_scratch_home() {
1208 return;
1209 }
1210 let dir = running_dir().unwrap();
1211 let mut member = record(1, "42");
1212 member.formation_id = Some(crate::formation::FormationId::mint());
1213 let mut malformed = serde_json::to_value(record(2, "42")).unwrap();
1214 malformed["session_id"] = "ses_0199c4e2f1b7712a9d3e4f5061728395".into();
1215 malformed["formation_id"] = "frm_ABC".into();
1216 let member_path = record_path(&dir, &member.session_id);
1217 let malformed_path = dir.join("ses_0199c4e2f1b7712a9d3e4f5061728395.json");
1218 std::fs::write(&member_path, serde_json::to_vec_pretty(&member).unwrap()).unwrap();
1219 std::fs::write(
1220 &malformed_path,
1221 serde_json::to_vec_pretty(&malformed).unwrap(),
1222 )
1223 .unwrap();
1224
1225 assert_eq!(list().unwrap(), vec![member]);
1226 assert!(member_path.exists());
1227 assert!(!malformed_path.exists());
1228 }
1229
1230 #[test]
1231 fn a_member_record_serializes_its_roster_name_beside_its_formation() {
1232 let mut member = record(1, "42");
1233 member.formation_id = Some(crate::formation::FormationId::mint());
1234 member.formation_member = Some("w1".to_string());
1235 let value = serde_json::to_value(&member).unwrap();
1236 let object = value.as_object().unwrap();
1237 assert_eq!(object.len(), 12);
1238 assert_eq!(object["formation_member"], "w1");
1239 let back: RunningRecord = serde_json::from_value(value).unwrap();
1240 assert_eq!(back, member);
1241 }
1242
1243 #[test]
1244 fn a_record_without_a_formation_member_or_with_a_null_one_parses_to_none() {
1245 let mut value = serde_json::to_value(record(1, "42")).unwrap();
1246 assert!(value.get("formation_member").is_none());
1247 let parsed: RunningRecord = serde_json::from_value(value.clone()).unwrap();
1248 assert_eq!(parsed.formation_member, None);
1249 value["formation_member"] = serde_json::Value::Null;
1250 let parsed: RunningRecord = serde_json::from_value(value).unwrap();
1251 assert_eq!(parsed.formation_member, None);
1252 }
1253
1254 #[test]
1255 fn a_record_whose_formation_member_is_not_a_member_name_does_not_parse() {
1256 let too_long = format!("w{}", "1".repeat(32));
1257 let malformed: [serde_json::Value; 4] = [7.into(), "".into(), "W1".into(), too_long.into()];
1258 for bad in malformed {
1259 let mut value = serde_json::to_value(record(1, "42")).unwrap();
1260 value["formation_member"] = bad.clone();
1261 assert!(
1262 serde_json::from_value::<RunningRecord>(value).is_err(),
1263 "formation_member {bad} parsed"
1264 );
1265 }
1266 }
1267
1268 #[test]
1269 fn list_prunes_a_record_with_a_malformed_formation_member() {
1270 let home = tempfile::tempdir().unwrap();
1271 crate::murmur_home::run_with_home(
1272 "running::tests::inner_list_prunes_a_record_with_a_malformed_formation_member",
1273 home.path(),
1274 );
1275 }
1276
1277 #[test]
1278 #[ignore = "run by list_prunes_a_record_with_a_malformed_formation_member"]
1279 fn inner_list_prunes_a_record_with_a_malformed_formation_member() {
1280 if !crate::murmur_home::in_scratch_home() {
1281 return;
1282 }
1283 let dir = running_dir().unwrap();
1284 let mut member = record(1, "42");
1285 member.formation_id = Some(crate::formation::FormationId::mint());
1286 member.formation_member = Some("w1".to_string());
1287 let mut malformed = serde_json::to_value(&member).unwrap();
1288 malformed["session_id"] = "ses_0199c4e2f1b7712a9d3e4f5061728395".into();
1289 malformed["formation_member"] = 7.into();
1290 let member_path = record_path(&dir, &member.session_id);
1291 let malformed_path = dir.join("ses_0199c4e2f1b7712a9d3e4f5061728395.json");
1292 std::fs::write(&member_path, serde_json::to_vec_pretty(&member).unwrap()).unwrap();
1293 std::fs::write(
1294 &malformed_path,
1295 serde_json::to_vec_pretty(&malformed).unwrap(),
1296 )
1297 .unwrap();
1298
1299 assert_eq!(list().unwrap(), vec![member]);
1300 assert!(member_path.exists());
1301 assert!(!malformed_path.exists());
1302 }
1303
1304 fn launched_member() -> RunningRecord {
1305 let mut member = record(1, "42");
1306 member.formation_id = Some(crate::formation::FormationId::mint());
1307 member.formation_lifeline = true;
1308 member.formation_launcher = Some(ProcessIdentity {
1309 pid: 4242,
1310 process_start: "987654".to_string(),
1311 });
1312 member.spawned_by = Some("ses_0199c4e2f1b7712a9d3e4f5061728390".to_string());
1313 member
1314 }
1315
1316 #[test]
1317 fn a_launched_members_record_round_trips_its_launcher_and_lineage() {
1318 let mut member = launched_member();
1319 member.formation_entry = true;
1320 let value = serde_json::to_value(&member).unwrap();
1321 assert_eq!(value["formation_entry"], true);
1322 assert_eq!(value["formation_lifeline"], true);
1323 assert_eq!(
1324 value["formation_launcher"],
1325 serde_json::json!({"pid": 4242, "process_start": "987654"})
1326 );
1327 assert_eq!(value["spawned_by"], "ses_0199c4e2f1b7712a9d3e4f5061728390");
1328 let back: RunningRecord = serde_json::from_value(value).unwrap();
1329 assert_eq!(back, member);
1330 }
1331
1332 #[test]
1335 fn a_record_holding_no_entry_lifeline_launcher_or_spawner_omits_all_four_keys() {
1336 let mut member = launched_member();
1337 member.formation_entry = false;
1338 member.formation_lifeline = false;
1339 member.formation_launcher = None;
1340 member.spawned_by = None;
1341 let body = serde_json::to_string_pretty(&member).unwrap();
1342 for key in [
1343 "formation_entry",
1344 "formation_lifeline",
1345 "formation_launcher",
1346 "spawned_by",
1347 "unconfined",
1348 ] {
1349 assert!(!body.contains(key), "{key} in {body}");
1350 }
1351 assert_eq!(
1352 serde_json::from_str::<RunningRecord>(&body).unwrap(),
1353 member
1354 );
1355 }
1356
1357 #[test]
1360 fn liveness_is_running_exactly_when_its_process_state_is() {
1361 for state in [
1362 ProcessState::Alive,
1363 ProcessState::Unverified("unreadable start time".to_string()),
1364 ProcessState::Gone("no such process".to_string()),
1365 ] {
1366 let reachable = match liveness_without_probe(state.clone()) {
1367 Some(liveness) => vec![liveness],
1368 None => vec![
1370 Liveness::Live,
1371 Liveness::Unreachable("the door did not answer".to_string()),
1372 ],
1373 };
1374 for liveness in reachable {
1375 assert_eq!(
1376 liveness.is_running(),
1377 state.is_running(),
1378 "{state:?} → {liveness:?}"
1379 );
1380 }
1381 }
1382 }
1383
1384 #[test]
1385 fn a_record_with_a_malformed_formation_launcher_does_not_parse() {
1386 for launcher in [
1387 serde_json::json!(4242),
1388 serde_json::json!({"pid": 4242}),
1389 serde_json::json!({"process_start": "1"}),
1390 serde_json::json!({"pid": -1, "process_start": "1"}),
1391 serde_json::json!({"pid": "4242", "process_start": "1"}),
1392 ] {
1393 let mut value = serde_json::to_value(record(1, "42")).unwrap();
1394 value["formation_launcher"] = launcher.clone();
1395 assert!(
1396 serde_json::from_value::<RunningRecord>(value).is_err(),
1397 "{launcher}"
1398 );
1399 }
1400 }
1401
1402 #[test]
1405 fn an_identity_reads_and_signals_as_the_record_naming_the_same_process() {
1406 let pid = std::process::id();
1407 let token = process_start_token(pid).unwrap();
1408 for start in [token.as_str(), "not-this-process", ""] {
1409 let record = record(pid, start);
1410 let identity = ProcessIdentity {
1411 pid,
1412 process_start: start.to_string(),
1413 };
1414 assert_eq!(identity_state(&identity), process_state(&record), "{start}");
1415 }
1416 let stale = ProcessIdentity {
1418 pid,
1419 process_start: "not-this-process".to_string(),
1420 };
1421 assert_eq!(
1422 signal_term_process(&stale),
1423 signal_term(&record(pid, "not-this-process"))
1424 );
1425 assert_eq!(signal_kill_process(&stale), SignalOutcome::AlreadyGone);
1426 let nobody = ProcessIdentity {
1427 pid: 0,
1428 process_start: "42".to_string(),
1429 };
1430 assert_eq!(signal_term_process(&nobody), signal_term(&record(0, "42")));
1431 assert_eq!(
1432 ProcessIdentity::of_this_process(),
1433 Some(ProcessIdentity {
1434 pid,
1435 process_start: token
1436 })
1437 );
1438 }
1439
1440 #[test]
1442 fn a_term_sent_through_an_identity_reaches_the_process() {
1443 let mut child = std::process::Command::new("sleep")
1444 .arg("30")
1445 .spawn()
1446 .expect("sleep should start");
1447 let pid = child.id();
1448 let identity = ProcessIdentity {
1449 pid,
1450 process_start: process_start_token(pid).unwrap(),
1451 };
1452 assert_eq!(identity_state(&identity), ProcessState::Alive);
1453 assert_eq!(signal_term_process(&identity), SignalOutcome::Sent);
1454 let status = child.wait().unwrap();
1455 use std::os::unix::process::ExitStatusExt;
1456 assert_eq!(status.signal(), Some(libc::SIGTERM));
1457 assert!(matches!(identity_state(&identity), ProcessState::Gone(_)));
1458 }
1459
1460 #[test]
1463 fn a_record_missing_a_field_does_not_parse() {
1464 let mut value = serde_json::to_value(record(1, "42")).unwrap();
1465 value.as_object_mut().unwrap().remove("process_start");
1466 assert!(serde_json::from_value::<RunningRecord>(value).is_err());
1467 }
1468
1469 #[test]
1471 fn this_process_reads_as_alive() {
1472 let pid = std::process::id();
1473 let token = process_start_token(pid).expect("this process has a start time");
1474 assert_eq!(process_state(&record(pid, &token)), ProcessState::Alive);
1475 }
1476
1477 #[test]
1479 fn a_start_time_that_disagrees_reads_as_gone() {
1480 let pid = std::process::id();
1481 let state = process_state(&record(pid, "not-this-process"));
1482 match state {
1483 ProcessState::Gone(reason) => assert!(
1484 reason.contains("another time"),
1485 "the reason must say the pid belongs to another process now: {reason}"
1486 ),
1487 other => panic!("a mismatched start time must read as gone, not {other:?}"),
1488 }
1489 }
1490
1491 #[test]
1493 fn a_pid_nothing_holds_reads_as_gone() {
1494 let state = process_state(&record(0, "42"));
1496 assert!(matches!(state, ProcessState::Gone(_)));
1497 }
1498
1499 #[test]
1501 fn the_start_token_is_stable_for_one_process() {
1502 let pid = std::process::id();
1503 assert_eq!(process_start_token(pid), process_start_token(pid));
1504 assert!(process_start_token(pid).is_some_and(|token| !token.is_empty()));
1505 }
1506
1507 #[test]
1510 fn a_zombie_does_not_read_as_alive() {
1511 let mut child = std::process::Command::new("sh")
1512 .args(["-c", "exit 0"])
1513 .spawn()
1514 .expect("sh should start");
1515 let pid = child.id();
1516 let token = process_start_token(pid).expect("a live child has a start time");
1517
1518 let deadline = std::time::Instant::now() + Duration::from_secs(10);
1520 loop {
1521 if matches!(process_state(&record(pid, &token)), ProcessState::Gone(_)) {
1522 break;
1523 }
1524 assert!(
1525 std::time::Instant::now() < deadline,
1526 "a zombie kept reading as alive"
1527 );
1528 std::thread::sleep(Duration::from_millis(50));
1529 }
1530 let _ = child.wait();
1531 }
1532
1533 #[test]
1536 fn a_signal_at_a_mismatched_start_time_is_not_sent() {
1537 let record = record(std::process::id(), "not-this-process");
1539 assert_eq!(signal_kill(&record), SignalOutcome::AlreadyGone);
1540 assert_eq!(signal_term(&record), SignalOutcome::AlreadyGone);
1541 }
1542
1543 #[test]
1546 fn a_doorless_record_is_live_on_its_process_alone() {
1547 let pid = std::process::id();
1548 let token = process_start_token(pid).unwrap();
1549 let mut doorless = record(pid, &token);
1550 doorless.url = None;
1551 assert_eq!(verify(&doorless), Liveness::Live);
1552
1553 let mut dead = record(0, "42");
1554 dead.url = None;
1555 assert!(matches!(verify(&dead), Liveness::Gone(_)));
1556 }
1557
1558 #[test]
1560 fn a_doorless_record_round_trips_with_a_null_url() {
1561 let mut doorless = record(1, "42");
1562 doorless.url = None;
1563 let text = serde_json::to_string(&doorless).unwrap();
1564 assert!(text.contains("\"url\":null"), "{text}");
1565 let back: RunningRecord = serde_json::from_str(&text).unwrap();
1566 assert_eq!(back, doorless);
1567
1568 let with_door = record(1, "42");
1569 let text = serde_json::to_string(&with_door).unwrap();
1570 assert!(text.contains("\"url\":\"127.0.0.1:1\""), "{text}");
1571 let back: RunningRecord = serde_json::from_str(&text).unwrap();
1572 assert_eq!(back.url.as_deref(), Some("127.0.0.1:1"));
1573 }
1574
1575 #[test]
1578 fn a_signal_at_a_pid_nothing_holds_is_already_gone() {
1579 assert_eq!(signal_term(&record(0, "42")), SignalOutcome::AlreadyGone);
1580 }
1581
1582 #[test]
1585 fn a_live_process_behind_a_silent_port_is_unreachable() {
1586 let pid = std::process::id();
1587 let token = process_start_token(pid).unwrap();
1588 let mut record = record(pid, &token);
1589 let port = crate::pinned_port::claim();
1591 record.url = Some(format!("127.0.0.1:{port}"));
1592 assert!(matches!(verify(&record), Liveness::Unreachable(_)));
1593 }
1594
1595 const PID: u32 = 4242;
1596
1597 #[test]
1598 fn classify_a_dead_pid_is_gone_whatever_the_token() {
1599 for token in [None, Some("123"), Some("")] {
1600 assert_eq!(
1601 classify(PID, "123", false, token),
1602 ProcessState::Gone("no process holds pid 4242".to_string()),
1603 "token {token:?}"
1604 );
1605 }
1606 }
1607
1608 #[test]
1609 fn classify_a_live_pid_with_the_recorded_token_is_alive() {
1610 assert_eq!(classify(PID, "123", true, Some("123")), ProcessState::Alive);
1611 }
1612
1613 #[test]
1614 fn classify_a_live_pid_with_another_token_is_gone() {
1615 assert_eq!(
1616 classify(PID, "123", true, Some("456")),
1617 ProcessState::Gone("pid 4242 is held by a process that started at another time".into())
1618 );
1619 }
1620
1621 #[test]
1623 fn classify_a_recorded_empty_token_against_a_fresh_one_is_gone() {
1624 assert!(matches!(
1625 classify(PID, "", true, Some("123")),
1626 ProcessState::Gone(reason) if reason.contains("another time")
1627 ));
1628 }
1629
1630 #[test]
1631 fn classify_a_live_pid_whose_token_cannot_be_read_is_unverified() {
1632 assert_eq!(
1633 classify(PID, "123", true, None),
1634 ProcessState::Unverified("pid 4242's start time could not be read".into())
1635 );
1636 }
1637
1638 #[test]
1639 fn nothing_is_refused_before_signalling_a_confirmed_process() {
1640 assert_eq!(refusal_before_signal(&ProcessState::Alive), None);
1641 }
1642
1643 #[test]
1644 fn a_gone_process_is_already_gone_before_any_signal() {
1645 assert_eq!(
1646 refusal_before_signal(&ProcessState::Gone("no process holds pid 1".into())),
1647 Some(SignalOutcome::AlreadyGone)
1648 );
1649 }
1650
1651 #[test]
1652 fn an_unverified_process_is_refused_before_any_signal() {
1653 let reason = "pid 1's start time could not be read".to_string();
1654 assert_eq!(
1655 refusal_before_signal(&ProcessState::Unverified(reason.clone())),
1656 Some(SignalOutcome::Refused(reason))
1657 );
1658 }
1659
1660 #[test]
1663 fn an_unverified_process_is_unreachable_without_a_probe() {
1664 let reason = "pid 1's start time could not be read".to_string();
1665 assert_eq!(
1666 liveness_without_probe(ProcessState::Unverified(reason.clone())),
1667 Some(Liveness::Unreachable(reason))
1668 );
1669 assert_eq!(
1670 liveness_without_probe(ProcessState::Gone("gone".into())),
1671 Some(Liveness::Gone("gone".into()))
1672 );
1673 assert_eq!(liveness_without_probe(ProcessState::Alive), None);
1674 }
1675
1676 #[cfg(target_os = "linux")]
1678 fn stat_line(comm: &str) -> String {
1679 let fields: Vec<String> = (3..=21).map(|field| field.to_string()).collect();
1680 format!("1234 ({comm}) {} 987654 23 24\n", fields.join(" "))
1681 }
1682
1683 #[cfg(target_os = "linux")]
1684 #[test]
1685 fn the_start_token_is_field_22_of_a_plain_stat_line() {
1686 assert_eq!(
1687 platform::start_token_from_stat(&stat_line("sleep")),
1688 Some("987654".to_string())
1689 );
1690 }
1691
1692 #[cfg(target_os = "linux")]
1693 #[test]
1694 fn the_start_token_is_counted_from_the_last_close_paren() {
1695 assert_eq!(
1696 platform::start_token_from_stat(&stat_line("a) (b c")),
1697 Some("987654".to_string())
1698 );
1699 }
1700
1701 #[cfg(target_os = "linux")]
1702 #[test]
1703 fn a_stat_line_with_no_close_paren_has_no_start_token() {
1704 assert_eq!(platform::start_token_from_stat("1234 sleep S 1 2 3"), None);
1705 }
1706
1707 #[cfg(target_os = "linux")]
1708 #[test]
1709 fn a_stat_line_truncated_before_field_22_has_no_start_token() {
1710 let line = stat_line("sleep");
1711 let truncated = &line[..line.find(" 987654").unwrap()];
1712 assert_eq!(platform::start_token_from_stat(truncated), None);
1713 }
1714}
1715
1716#[cfg(test)]
1717mod home_tests {
1718 use super::*;
1719
1720 #[test]
1721 fn running_dir_holds_a_wide_murmur_home_owner_only() {
1722 let home = tempfile::tempdir().unwrap();
1723 crate::murmur_home::wide_dir(&home.path().join(".murmur"), 0o755);
1724 crate::murmur_home::run_with_home(
1725 "running::home_tests::inner_running_dir_under_scratch_home",
1726 home.path(),
1727 );
1728 assert_eq!(
1729 crate::murmur_home::mode_of(&home.path().join(".murmur")),
1730 0o700
1731 );
1732 assert_eq!(
1733 crate::murmur_home::mode_of(&home.path().join(".murmur").join(RUNNING_DIR)),
1734 RUNNING_DIR_MODE
1735 );
1736 }
1737
1738 #[test]
1739 #[ignore = "run by running_dir_holds_a_wide_murmur_home_owner_only"]
1740 fn inner_running_dir_under_scratch_home() {
1741 if !crate::murmur_home::in_scratch_home() {
1742 return;
1743 }
1744 running_dir().unwrap();
1745 }
1746
1747 #[test]
1748 fn running_dir_location_creates_nothing() {
1749 let home = tempfile::tempdir().unwrap();
1750 crate::murmur_home::run_with_home(
1751 "running::home_tests::inner_running_dir_location_creates_nothing",
1752 home.path(),
1753 );
1754 assert!(!home.path().join(".murmur").exists());
1755 }
1756
1757 #[test]
1758 #[ignore = "run by running_dir_location_creates_nothing"]
1759 fn inner_running_dir_location_creates_nothing() {
1760 if !crate::murmur_home::in_scratch_home() {
1761 return;
1762 }
1763 let home = PathBuf::from(std::env::var_os("HOME").unwrap());
1764 assert_eq!(
1765 running_dir_location().unwrap(),
1766 home.join(".murmur").join(RUNNING_DIR)
1767 );
1768 assert!(!home.join(".murmur").exists());
1769 }
1770
1771 #[test]
1774 fn the_door_probe_names_a2a_1_0_on_both_cards() {
1775 use crate::http_client::capture::answer_one;
1776
1777 let card = serde_json::json!({"capabilities": {"extensions": [{
1778 "uri": crate::identity::CAPSULE_EXTENSION_URI,
1779 "params": {"sessionId": "ses_probe"}}]}});
1780 let (addr, sent) =
1781 answer_one(serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": card}).to_string());
1782 let token = crate::door_auth::DoorToken::new("tok".to_string());
1783 assert_eq!(
1784 probe_door_session_id(&addr, Some(&token)).unwrap(),
1785 "ses_probe"
1786 );
1787 let sent = sent.join().unwrap();
1788 assert_eq!(sent.request_line, "POST / HTTP/1.1");
1789 assert_eq!(sent.method(), "GetExtendedAgentCard");
1790 assert_eq!(sent.header("a2a-version"), ["1.0"]);
1791
1792 let (addr, sent) = answer_one(card.to_string());
1793 assert_eq!(probe_door_session_id(&addr, None).unwrap(), "ses_probe");
1794 let sent = sent.join().unwrap();
1795 assert_eq!(
1796 sent.request_line,
1797 "GET /.well-known/agent-card.json HTTP/1.1"
1798 );
1799 assert_eq!(sent.header("a2a-version"), ["1.0"]);
1800 }
1801
1802 fn record(session_id: &str) -> RunningRecord {
1803 RunningRecord {
1804 session_id: session_id.to_string(),
1805 url: Some("127.0.0.1:1".to_string()),
1806 pid: 1,
1807 process_start: "42".to_string(),
1808 capsule_name: "demo".to_string(),
1809 capsule_version: "0.1.0".to_string(),
1810 workdir: PathBuf::from("/tmp/demo"),
1811 records_dir: PathBuf::from("/tmp/demo").join("records"),
1812 outlives_launcher: true,
1813 started_at: "2026-01-01T00:00:00Z".to_string(),
1814 door_token: None,
1815 credentials: BTreeMap::new(),
1816 formation_id: None,
1817 formation_member: None,
1818 formation_entry: false,
1819 formation_lifeline: false,
1820 formation_launcher: None,
1821 spawned_by: None,
1822 unconfined: false,
1823 }
1824 }
1825
1826 #[test]
1827 fn scan_reports_an_unparseable_file_and_leaves_it_in_place() {
1828 let dir = tempfile::tempdir().unwrap();
1829 let older = record("ses_0199c4e2f1b7712a9d3e4f5061728390");
1830 let newer = record("ses_0199c4e2f1b7712a9d3e4f5061728391");
1831 for record in [&older, &newer] {
1832 std::fs::write(
1833 record_path(dir.path(), &record.session_id),
1834 serde_json::to_vec(record).unwrap(),
1835 )
1836 .unwrap();
1837 }
1838 let broken = dir.path().join("ses_broken.json");
1839 std::fs::write(&broken, "{").unwrap();
1840 let staging = dir.path().join(".ses_x.json.1.2.tmp");
1841 std::fs::write(&staging, "{").unwrap();
1842 let control = token_path(dir.path(), &older.session_id);
1843 std::fs::write(&control, "ctl1.token.mac").unwrap();
1844
1845 let scan = scan(dir.path()).unwrap();
1846
1847 assert_eq!(scan.records, vec![newer, older]);
1848 assert_eq!(scan.unreadable, vec![broken.clone()]);
1849 assert_eq!(std::fs::read_to_string(&broken).unwrap(), "{");
1850 assert!(staging.exists());
1851 assert!(control.exists());
1852 }
1853
1854 fn mixed_running_dir() -> (tempfile::TempDir, RunningRecord, PathBuf, [PathBuf; 2]) {
1857 let dir = tempfile::tempdir().unwrap();
1858 let current = record("ses_0199c4e2f1b7712a9d3e4f5061728391");
1859 std::fs::write(
1860 record_path(dir.path(), ¤t.session_id),
1861 serde_json::to_vec(¤t).unwrap(),
1862 )
1863 .unwrap();
1864 let older_mur = record_path(dir.path(), "ses_0199c4e2f1b7712a9d3e4f5061728390");
1865 std::fs::write(
1866 &older_mur,
1867 serde_json::json!({
1868 "session_id": "ses_0199c4e2f1b7712a9d3e4f5061728390",
1869 "url": "127.0.0.1:1",
1870 "pid": 1,
1871 "process_start": "42",
1872 "capsule_name": "demo",
1873 "capsule_version": "0.1.0",
1874 "workdir": "/tmp/demo",
1875 "outlives_launcher": true,
1876 "started_at": "2026-01-01T00:00:00Z",
1877 })
1878 .to_string(),
1879 )
1880 .unwrap();
1881 let truncated = dir.path().join("ses_broken.json");
1882 std::fs::write(&truncated, "{").unwrap();
1883 let mut bad_pid = serde_json::to_value(record("ses_bad_pid")).unwrap();
1884 bad_pid["pid"] = serde_json::json!("one");
1885 let bad_pid_path = dir.path().join("ses_bad_pid.json");
1886 std::fs::write(&bad_pid_path, bad_pid.to_string()).unwrap();
1887 (dir, current, older_mur, [truncated, bad_pid_path])
1888 }
1889
1890 #[test]
1891 fn scan_names_a_record_an_older_mur_wrote_apart_from_unreadable_files() {
1892 let (dir, current, older_mur, unreadable) = mixed_running_dir();
1893 let before = std::fs::read(&older_mur).unwrap();
1894
1895 let mut scan = scan(dir.path()).unwrap();
1896
1897 scan.unreadable.sort();
1898 let mut expected_unreadable = unreadable.to_vec();
1899 expected_unreadable.sort();
1900 assert_eq!(scan.records, vec![current]);
1901 assert_eq!(scan.older_mur, vec![older_mur.clone()]);
1902 assert_eq!(scan.unreadable, expected_unreadable);
1903 assert_eq!(std::fs::read(&older_mur).unwrap(), before);
1904 assert!(unreadable.iter().all(|path| path.exists()));
1905 }
1906
1907 #[test]
1908 fn listing_unlinks_only_the_unreadable_files() {
1909 let (dir, current, older_mur, unreadable) = mixed_running_dir();
1910 let before = std::fs::read(&older_mur).unwrap();
1911
1912 let mut listing = list_pruning(dir.path()).unwrap();
1913
1914 listing.removed_unreadable.sort();
1915 let mut expected_removed = unreadable.to_vec();
1916 expected_removed.sort();
1917 assert_eq!(listing.records, vec![current]);
1918 assert_eq!(listing.older_mur, vec![older_mur.clone()]);
1919 assert_eq!(listing.removed_unreadable, expected_removed);
1920 assert_eq!(std::fs::read(&older_mur).unwrap(), before);
1921 assert!(unreadable.iter().all(|path| !path.exists()));
1922 }
1923
1924 #[test]
1925 fn an_object_without_a_string_session_id_is_unreadable() {
1926 let dir = tempfile::tempdir().unwrap();
1927 for (name, body) in [
1928 ("ses_array.json", "[]"),
1929 ("ses_no_id.json", r#"{"pid": 1}"#),
1930 ("ses_number_id.json", r#"{"session_id": 7}"#),
1931 ] {
1932 std::fs::write(dir.path().join(name), body).unwrap();
1933 }
1934
1935 let scan = scan(dir.path()).unwrap();
1936
1937 assert!(scan.older_mur.is_empty());
1938 assert_eq!(scan.unreadable.len(), 3);
1939 }
1940
1941 #[test]
1942 fn scan_of_a_missing_directory_is_empty_and_creates_nothing() {
1943 let parent = tempfile::tempdir().unwrap();
1944 let dir = parent.path().join(".murmur").join(RUNNING_DIR);
1945
1946 let scan = scan(&dir).unwrap();
1947
1948 assert!(scan.records.is_empty());
1949 assert!(scan.unreadable.is_empty());
1950 assert!(!parent.path().join(".murmur").exists());
1951 }
1952
1953 #[test]
1954 fn scan_of_a_regular_file_errs_naming_it() {
1955 let parent = tempfile::tempdir().unwrap();
1956 let file = parent.path().join(RUNNING_DIR);
1957 std::fs::write(&file, "").unwrap();
1958
1959 let err = scan(&file).unwrap_err();
1960
1961 assert!(err.starts_with(&format!("{}: ", file.display())), "{err}");
1962 }
1963}