1use std::{
35 path::{Path, PathBuf},
36 time::Duration,
37};
38
39use serde::{Deserialize, Serialize};
40
41const RUNNING_DIR: &str = "running";
43
44const RUNNING_DIR_MODE: u32 = 0o700;
46
47const RECORD_FILE_MODE: u32 = 0o600;
49
50const CONTROL_TOKEN_EXTENSION: &str = "control";
52
53const PROBE_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
56
57const PROBE_READ_TIMEOUT: Duration = Duration::from_secs(5);
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct RunningRecord {
68 pub session_id: String,
70 pub url: String,
72 pub pid: u32,
74 pub process_start: String,
76 pub capsule_name: String,
77 pub capsule_version: String,
78 pub workdir: PathBuf,
80 pub outlives_launcher: bool,
83 pub started_at: String,
85 #[serde(
89 default,
90 skip_serializing_if = "Option::is_none",
91 with = "door_token_field"
92 )]
93 pub door_token: Option<crate::door_auth::DoorToken>,
94}
95
96mod door_token_field {
98 use crate::door_auth::DoorToken;
99 use serde::{Deserialize, Deserializer, Serializer};
100
101 pub(super) fn serialize<S: Serializer>(
102 token: &Option<DoorToken>,
103 serializer: S,
104 ) -> Result<S::Ok, S::Error> {
105 match token {
106 Some(token) => serializer.serialize_some(token.expose()),
107 None => serializer.serialize_none(),
108 }
109 }
110
111 pub(super) fn deserialize<'de, D: Deserializer<'de>>(
112 deserializer: D,
113 ) -> Result<Option<DoorToken>, D::Error> {
114 Ok(Option::<String>::deserialize(deserializer)?.map(DoorToken::new))
115 }
116}
117
118pub fn running_dir() -> Result<PathBuf, String> {
125 let dir = crate::murmur_home::ensure_murmur_home()?.join(RUNNING_DIR);
126 crate::state_store::ensure_private_dir(&dir, RUNNING_DIR_MODE)
127 .map_err(|reason| format!("{}: {reason}", dir.display()))?;
128 Ok(dir)
129}
130
131pub fn list() -> Result<Vec<RunningRecord>, String> {
141 let dir = running_dir()?;
142 let entries = std::fs::read_dir(&dir).map_err(|err| format!("{}: {err}", dir.display()))?;
143
144 let mut records = Vec::new();
145 for entry in entries.flatten() {
146 let path = entry.path();
147 if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
148 continue;
149 }
150 match read_record(&path) {
151 Some(record) => records.push(record),
152 None => {
153 let _ = std::fs::remove_file(&path);
154 }
155 }
156 }
157 records.sort_by(|left, right| right.session_id.cmp(&left.session_id));
158 Ok(records)
159}
160
161#[derive(Debug, Clone, PartialEq, Eq)]
166pub enum ProcessState {
167 Alive,
169 Unverified(String),
172 Gone(String),
175}
176
177pub fn process_state(record: &RunningRecord) -> ProcessState {
183 let alive = pid_is_alive(record.pid);
184 let token = alive.then(|| process_start_token(record.pid)).flatten();
185 classify(record, alive, token.as_deref())
186}
187
188fn classify(record: &RunningRecord, pid_alive: bool, fresh_token: Option<&str>) -> ProcessState {
194 if !pid_alive {
195 return ProcessState::Gone(format!("no process holds pid {}", record.pid));
196 }
197 match fresh_token {
198 Some(token) if token == record.process_start => ProcessState::Alive,
199 Some(_) => ProcessState::Gone(format!(
200 "pid {} is held by a process that started at another time",
201 record.pid
202 )),
203 None => {
204 ProcessState::Unverified(format!("pid {}'s start time could not be read", record.pid))
205 }
206 }
207}
208
209#[derive(Debug, Clone, PartialEq, Eq)]
211pub enum Liveness {
212 Live,
214 Unreachable(String),
217 Gone(String),
219}
220
221pub fn verify(record: &RunningRecord) -> Liveness {
227 if let Some(liveness) = liveness_without_probe(process_state(record)) {
228 return liveness;
229 }
230 match probe_session_id(record) {
231 Ok(session_id) if session_id == record.session_id => Liveness::Live,
232 Ok(other) => Liveness::Unreachable(format!(
233 "the capsule at {} answers for session {other}",
234 record.url
235 )),
236 Err(reason) => Liveness::Unreachable(reason),
237 }
238}
239
240fn liveness_without_probe(state: ProcessState) -> Option<Liveness> {
243 match state {
244 ProcessState::Alive => None,
245 ProcessState::Unverified(reason) => Some(Liveness::Unreachable(reason)),
246 ProcessState::Gone(reason) => Some(Liveness::Gone(reason)),
247 }
248}
249
250#[derive(Debug, Clone, PartialEq, Eq)]
252pub enum SignalOutcome {
253 Sent,
255 AlreadyGone,
257 Refused(String),
261}
262
263pub fn signal_term(record: &RunningRecord) -> SignalOutcome {
265 signal(record, libc::SIGTERM)
266}
267
268pub fn signal_kill(record: &RunningRecord) -> SignalOutcome {
270 signal(record, libc::SIGKILL)
271}
272
273#[allow(unsafe_code)]
281fn signal(record: &RunningRecord, signal: libc::c_int) -> SignalOutcome {
282 if let Some(outcome) = refusal_before_signal(&process_state(record)) {
283 return outcome;
284 }
285 if unsafe { libc::kill(record.pid as libc::pid_t, signal) } == 0 {
288 return SignalOutcome::Sent;
289 }
290 let err = std::io::Error::last_os_error();
291 if err.raw_os_error() == Some(libc::ESRCH) {
294 return SignalOutcome::AlreadyGone;
295 }
296 SignalOutcome::Refused(err.to_string())
297}
298
299fn refusal_before_signal(state: &ProcessState) -> Option<SignalOutcome> {
305 match state {
306 ProcessState::Alive => None,
307 ProcessState::Unverified(reason) => Some(SignalOutcome::Refused(reason.clone())),
308 ProcessState::Gone(_) => Some(SignalOutcome::AlreadyGone),
309 }
310}
311
312pub fn prune(record: &RunningRecord) {
315 if let Ok(dir) = running_dir() {
316 let _ = std::fs::remove_file(record_path(&dir, &record.session_id));
317 let _ = std::fs::remove_file(token_path(&dir, &record.session_id));
318 }
319}
320
321pub fn control_token_path(session_id: &str) -> Result<PathBuf, String> {
326 Ok(token_path(&running_dir()?, session_id))
327}
328
329pub struct ControlTokenGuard {
334 path: PathBuf,
335}
336
337impl ControlTokenGuard {
338 pub fn write(session_id: &str, token: &str) -> Result<Self, String> {
343 let path = control_token_path(session_id)?;
344 crate::retention::StagedRewrite::stage_with_mode(
345 &path,
346 token.as_bytes(),
347 Some(RECORD_FILE_MODE),
348 )
349 .and_then(crate::retention::StagedRewrite::commit)
350 .map_err(|reason| format!("failed to write {}: {reason}", path.display()))?;
351 Ok(Self { path })
352 }
353}
354
355impl Drop for ControlTokenGuard {
356 fn drop(&mut self) {
357 let _ = std::fs::remove_file(&self.path);
358 }
359}
360
361pub struct RunningGuard {
367 path: PathBuf,
368}
369
370impl RunningGuard {
371 pub fn write(record: &RunningRecord) -> Result<Self, String> {
377 let dir = running_dir()?;
378 let path = record_path(&dir, &record.session_id);
379 let body = serde_json::to_vec_pretty(record)
380 .map_err(|err| format!("the record could not be serialized: {err}"))?;
381
382 crate::retention::StagedRewrite::stage_with_mode(&path, &body, Some(RECORD_FILE_MODE))
386 .and_then(crate::retention::StagedRewrite::commit)
387 .map_err(|reason| format!("failed to write {}: {reason}", path.display()))?;
388 Ok(Self { path })
389 }
390}
391
392impl Drop for RunningGuard {
393 fn drop(&mut self) {
394 let _ = std::fs::remove_file(&self.path);
395 let _ = std::fs::remove_file(self.path.with_extension(CONTROL_TOKEN_EXTENSION));
397 }
398}
399
400#[must_use]
405pub fn has_controlling_terminal() -> bool {
406 std::fs::File::open("/dev/tty").is_ok()
407}
408
409fn record_path(dir: &Path, session_id: &str) -> PathBuf {
410 dir.join(format!("{session_id}.json"))
411}
412
413fn token_path(dir: &Path, session_id: &str) -> PathBuf {
414 dir.join(format!("{session_id}.{CONTROL_TOKEN_EXTENSION}"))
415}
416
417fn read_record(path: &Path) -> Option<RunningRecord> {
418 let body = std::fs::read_to_string(path).ok()?;
419 serde_json::from_str(&body).ok()
420}
421
422#[allow(unsafe_code)]
434fn pid_is_alive(pid: u32) -> bool {
435 let Ok(target) = libc::pid_t::try_from(pid) else {
436 return false;
437 };
438 if target == 0 {
439 return false;
440 }
441 let held = unsafe { libc::kill(target, 0) } == 0
444 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM);
445 held && !platform::is_zombie(pid)
446}
447
448#[must_use]
459pub fn process_start_token(pid: u32) -> Option<String> {
460 platform::process_start_token(pid)
461}
462
463#[cfg(target_os = "linux")]
464mod platform {
465 const STARTTIME_OFFSET_AFTER_COMM: usize = 22 - 3;
471
472 pub(super) fn process_start_token(pid: u32) -> Option<String> {
473 let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
474 start_token_from_stat(&stat)
475 }
476
477 pub(super) fn start_token_from_stat(stat: &str) -> Option<String> {
480 fields_after_comm(stat)?
481 .nth(STARTTIME_OFFSET_AFTER_COMM)
482 .map(str::to_string)
483 }
484
485 fn fields_after_comm(stat: &str) -> Option<std::str::SplitWhitespace<'_>> {
487 Some(stat[stat.rfind(')')? + 1..].split_whitespace())
488 }
489
490 pub(super) fn is_zombie(pid: u32) -> bool {
496 let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
497 return false;
498 };
499 fields_after_comm(&stat).and_then(|mut fields| fields.next()) == Some("Z")
500 }
501}
502
503#[cfg(target_os = "macos")]
504mod platform {
505 use std::mem::size_of;
506
507 pub(super) fn process_start_token(pid: u32) -> Option<String> {
509 let info = bsd_info(pid).ok()?;
510 Some(format!(
511 "{}.{:06}",
512 info.pbi_start_tvsec, info.pbi_start_tvusec
513 ))
514 }
515
516 pub(super) fn is_zombie(pid: u32) -> bool {
524 match bsd_info(pid) {
525 Ok(info) => info.pbi_status == libc::SZOMB,
526 Err(err) => err.raw_os_error() == Some(libc::ESRCH),
527 }
528 }
529
530 #[allow(unsafe_code)]
533 fn bsd_info(pid: u32) -> std::io::Result<libc::proc_bsdinfo> {
534 let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
537 let size = size_of::<libc::proc_bsdinfo>();
538
539 let written = unsafe {
543 libc::proc_pidinfo(
544 pid as libc::c_int,
545 libc::PROC_PIDTBSDINFO,
546 0,
547 std::ptr::addr_of_mut!(info).cast(),
548 size as libc::c_int,
549 )
550 };
551 if written <= 0 {
552 return Err(std::io::Error::last_os_error());
553 }
554 if usize::try_from(written).ok() != Some(size) {
555 return Err(std::io::Error::other(format!(
556 "proc_pidinfo wrote {written} bytes, not one proc_bsdinfo of {size}"
557 )));
558 }
559 Ok(info)
560 }
561}
562
563fn probe_session_id(record: &RunningRecord) -> Result<String, String> {
570 let addr = record
571 .url
572 .trim_start_matches("http://")
573 .trim_start_matches("https://");
574 let card = match &record.door_token {
575 Some(token) => {
576 let body = serde_json::json!({
577 "jsonrpc": "2.0",
578 "id": 1,
579 "method": crate::identity::DoorMethod::GetAuthenticatedExtendedCard.wire_name(),
580 "params": {},
581 })
582 .to_string();
583 let authorization = crate::door_auth::bearer_header(token);
584 let response = crate::http_client::http_json_with_timeouts(
585 "POST",
586 &format!("http://{addr}/"),
587 Some(&body),
588 &[("Authorization", authorization.as_str())],
589 PROBE_CONNECT_TIMEOUT,
590 PROBE_READ_TIMEOUT,
591 )?;
592 response.get("result").cloned().ok_or_else(|| {
593 format!("the capsule at {addr} did not return its extended agent card")
594 })?
595 }
596 None => crate::http_client::http_json_with_timeouts(
597 "GET",
598 &format!("http://{addr}/.well-known/agent-card.json"),
599 None,
600 &[("Accept", "application/json")],
601 PROBE_CONNECT_TIMEOUT,
602 PROBE_READ_TIMEOUT,
603 )?,
604 };
605 crate::identity::session_id_from_card(&card)
606 .map(str::to_string)
607 .ok_or_else(|| format!("the agent card from {addr} names no session"))
608}
609
610#[cfg(test)]
611mod tests {
612 use super::*;
613
614 #[test]
615 fn prune_removes_the_record_and_its_control_token() {
616 let home = tempfile::tempdir().unwrap();
617 crate::murmur_home::run_with_home(
618 "running::tests::inner_prune_removes_the_record_and_its_control_token",
619 home.path(),
620 );
621 }
622
623 #[test]
624 #[ignore = "run by prune_removes_the_record_and_its_control_token"]
625 fn inner_prune_removes_the_record_and_its_control_token() {
626 if !crate::murmur_home::in_scratch_home() {
627 return;
628 }
629 let record = RunningRecord {
630 session_id: "ses_0199prunecontroltoken000000000000".to_string(),
631 url: "localhost:1".to_string(),
632 pid: std::process::id(),
633 process_start: String::new(),
634 capsule_name: "c".to_string(),
635 capsule_version: "0.1.0".to_string(),
636 workdir: PathBuf::from("/tmp"),
637 outlives_launcher: false,
638 started_at: "2026-01-01T00:00:00Z".to_string(),
639 door_token: None,
640 };
641 let token = ControlTokenGuard::write(&record.session_id, "ctl1.token.mac").unwrap();
642 let record_guard = RunningGuard::write(&record).unwrap();
643 let dir = running_dir().unwrap();
644 let token_file = control_token_path(&record.session_id).unwrap();
645 assert_eq!(
646 token_file,
647 dir.join(format!("{}.control", record.session_id))
648 );
649 assert_eq!(crate::murmur_home::mode_of(&token_file), RECORD_FILE_MODE);
650 assert_eq!(
651 std::fs::read_to_string(&token_file).unwrap(),
652 "ctl1.token.mac"
653 );
654
655 prune(&record);
656 assert!(!record_path(&dir, &record.session_id).exists());
657 assert!(!token_file.exists());
658
659 std::mem::forget(token);
661 std::mem::forget(record_guard);
662 let _token = ControlTokenGuard::write(&record.session_id, "ctl1.token.mac").unwrap();
663 drop(RunningGuard::write(&record).unwrap());
664 assert!(!token_file.exists());
665 }
666
667 fn record(pid: u32, process_start: &str) -> RunningRecord {
668 RunningRecord {
669 session_id: "ses_0199c4e2f1b7712a9d3e4f5061728394".to_string(),
670 url: "127.0.0.1:1".to_string(),
671 pid,
672 process_start: process_start.to_string(),
673 capsule_name: "demo".to_string(),
674 capsule_version: "0.1.0".to_string(),
675 workdir: PathBuf::from("/tmp/demo"),
676 outlives_launcher: true,
677 started_at: "2026-01-01T00:00:00Z".to_string(),
678 door_token: None,
679 }
680 }
681
682 #[test]
685 fn a_record_serializes_exactly_the_nine_fields() {
686 let value = serde_json::to_value(record(1, "42")).unwrap();
687 let mut keys: Vec<&str> = value
688 .as_object()
689 .unwrap()
690 .keys()
691 .map(String::as_str)
692 .collect();
693 keys.sort_unstable();
694 assert_eq!(
695 keys,
696 [
697 "capsule_name",
698 "capsule_version",
699 "outlives_launcher",
700 "pid",
701 "process_start",
702 "session_id",
703 "started_at",
704 "url",
705 "workdir",
706 ]
707 );
708 }
709
710 #[test]
711 fn a_record_with_a_door_token_serializes_it_beside_the_nine_fields() {
712 let mut with_token = record(1, "42");
713 with_token.door_token = Some(crate::door_auth::DoorToken::new(
714 "mdt1.payload.mac".to_string(),
715 ));
716 let value = serde_json::to_value(&with_token).unwrap();
717 let object = value.as_object().unwrap();
718 assert_eq!(object.len(), 10);
719 assert_eq!(object["door_token"], "mdt1.payload.mac");
720 let back: RunningRecord = serde_json::from_value(value).unwrap();
721 assert_eq!(back, with_token);
722 }
723
724 #[test]
725 fn a_record_debug_prints_no_door_token() {
726 let mut with_token = record(1, "42");
727 with_token.door_token = Some(crate::door_auth::DoorToken::new(
728 "mdt1.secretpayload.secretmac".to_string(),
729 ));
730 let debug = format!("{with_token:?}");
731 assert!(!debug.contains("secretpayload"), "{debug}");
732 assert!(debug.contains("DoorToken(<redacted>)"), "{debug}");
733 }
734
735 #[test]
737 fn a_record_without_a_door_token_key_parses() {
738 let value = serde_json::to_value(record(1, "42")).unwrap();
739 assert!(value.get("door_token").is_none());
740 let parsed: RunningRecord = serde_json::from_value(value).unwrap();
741 assert_eq!(parsed.door_token, None);
742 }
743
744 #[test]
747 fn a_record_missing_a_field_does_not_parse() {
748 let mut value = serde_json::to_value(record(1, "42")).unwrap();
749 value.as_object_mut().unwrap().remove("process_start");
750 assert!(serde_json::from_value::<RunningRecord>(value).is_err());
751 }
752
753 #[test]
755 fn this_process_reads_as_alive() {
756 let pid = std::process::id();
757 let token = process_start_token(pid).expect("this process has a start time");
758 assert_eq!(process_state(&record(pid, &token)), ProcessState::Alive);
759 }
760
761 #[test]
763 fn a_start_time_that_disagrees_reads_as_gone() {
764 let pid = std::process::id();
765 let state = process_state(&record(pid, "not-this-process"));
766 match state {
767 ProcessState::Gone(reason) => assert!(
768 reason.contains("another time"),
769 "the reason must say the pid belongs to another process now: {reason}"
770 ),
771 other => panic!("a mismatched start time must read as gone, not {other:?}"),
772 }
773 }
774
775 #[test]
777 fn a_pid_nothing_holds_reads_as_gone() {
778 let state = process_state(&record(0, "42"));
780 assert!(matches!(state, ProcessState::Gone(_)));
781 }
782
783 #[test]
785 fn the_start_token_is_stable_for_one_process() {
786 let pid = std::process::id();
787 assert_eq!(process_start_token(pid), process_start_token(pid));
788 assert!(process_start_token(pid).is_some_and(|token| !token.is_empty()));
789 }
790
791 #[test]
794 fn a_zombie_does_not_read_as_alive() {
795 let mut child = std::process::Command::new("sh")
796 .args(["-c", "exit 0"])
797 .spawn()
798 .expect("sh should start");
799 let pid = child.id();
800 let token = process_start_token(pid).expect("a live child has a start time");
801
802 let deadline = std::time::Instant::now() + Duration::from_secs(10);
804 loop {
805 if matches!(process_state(&record(pid, &token)), ProcessState::Gone(_)) {
806 break;
807 }
808 assert!(
809 std::time::Instant::now() < deadline,
810 "a zombie kept reading as alive"
811 );
812 std::thread::sleep(Duration::from_millis(50));
813 }
814 let _ = child.wait();
815 }
816
817 #[test]
820 fn a_signal_at_a_mismatched_start_time_is_not_sent() {
821 let record = record(std::process::id(), "not-this-process");
823 assert_eq!(signal_kill(&record), SignalOutcome::AlreadyGone);
824 assert_eq!(signal_term(&record), SignalOutcome::AlreadyGone);
825 }
826
827 #[test]
830 fn a_signal_at_a_pid_nothing_holds_is_already_gone() {
831 assert_eq!(signal_term(&record(0, "42")), SignalOutcome::AlreadyGone);
832 }
833
834 #[test]
837 fn a_live_process_behind_a_silent_port_is_unreachable() {
838 let pid = std::process::id();
839 let token = process_start_token(pid).unwrap();
840 let mut record = record(pid, &token);
841 let port = {
843 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
844 listener.local_addr().unwrap().port()
845 };
846 record.url = format!("127.0.0.1:{port}");
847 assert!(matches!(verify(&record), Liveness::Unreachable(_)));
848 }
849
850 const PID: u32 = 4242;
851
852 #[test]
853 fn classify_a_dead_pid_is_gone_whatever_the_token() {
854 for token in [None, Some("123"), Some("")] {
855 assert_eq!(
856 classify(&record(PID, "123"), false, token),
857 ProcessState::Gone("no process holds pid 4242".to_string()),
858 "token {token:?}"
859 );
860 }
861 }
862
863 #[test]
864 fn classify_a_live_pid_with_the_recorded_token_is_alive() {
865 assert_eq!(
866 classify(&record(PID, "123"), true, Some("123")),
867 ProcessState::Alive
868 );
869 }
870
871 #[test]
872 fn classify_a_live_pid_with_another_token_is_gone() {
873 assert_eq!(
874 classify(&record(PID, "123"), true, Some("456")),
875 ProcessState::Gone("pid 4242 is held by a process that started at another time".into())
876 );
877 }
878
879 #[test]
881 fn classify_a_recorded_empty_token_against_a_fresh_one_is_gone() {
882 assert!(matches!(
883 classify(&record(PID, ""), true, Some("123")),
884 ProcessState::Gone(reason) if reason.contains("another time")
885 ));
886 }
887
888 #[test]
889 fn classify_a_live_pid_whose_token_cannot_be_read_is_unverified() {
890 assert_eq!(
891 classify(&record(PID, "123"), true, None),
892 ProcessState::Unverified("pid 4242's start time could not be read".into())
893 );
894 }
895
896 #[test]
897 fn nothing_is_refused_before_signalling_a_confirmed_process() {
898 assert_eq!(refusal_before_signal(&ProcessState::Alive), None);
899 }
900
901 #[test]
902 fn a_gone_process_is_already_gone_before_any_signal() {
903 assert_eq!(
904 refusal_before_signal(&ProcessState::Gone("no process holds pid 1".into())),
905 Some(SignalOutcome::AlreadyGone)
906 );
907 }
908
909 #[test]
910 fn an_unverified_process_is_refused_before_any_signal() {
911 let reason = "pid 1's start time could not be read".to_string();
912 assert_eq!(
913 refusal_before_signal(&ProcessState::Unverified(reason.clone())),
914 Some(SignalOutcome::Refused(reason))
915 );
916 }
917
918 #[test]
921 fn an_unverified_process_is_unreachable_without_a_probe() {
922 let reason = "pid 1's start time could not be read".to_string();
923 assert_eq!(
924 liveness_without_probe(ProcessState::Unverified(reason.clone())),
925 Some(Liveness::Unreachable(reason))
926 );
927 assert_eq!(
928 liveness_without_probe(ProcessState::Gone("gone".into())),
929 Some(Liveness::Gone("gone".into()))
930 );
931 assert_eq!(liveness_without_probe(ProcessState::Alive), None);
932 }
933
934 #[cfg(target_os = "linux")]
936 fn stat_line(comm: &str) -> String {
937 let fields: Vec<String> = (3..=21).map(|field| field.to_string()).collect();
938 format!("1234 ({comm}) {} 987654 23 24\n", fields.join(" "))
939 }
940
941 #[cfg(target_os = "linux")]
942 #[test]
943 fn the_start_token_is_field_22_of_a_plain_stat_line() {
944 assert_eq!(
945 platform::start_token_from_stat(&stat_line("sleep")),
946 Some("987654".to_string())
947 );
948 }
949
950 #[cfg(target_os = "linux")]
951 #[test]
952 fn the_start_token_is_counted_from_the_last_close_paren() {
953 assert_eq!(
954 platform::start_token_from_stat(&stat_line("a) (b c")),
955 Some("987654".to_string())
956 );
957 }
958
959 #[cfg(target_os = "linux")]
960 #[test]
961 fn a_stat_line_with_no_close_paren_has_no_start_token() {
962 assert_eq!(platform::start_token_from_stat("1234 sleep S 1 2 3"), None);
963 }
964
965 #[cfg(target_os = "linux")]
966 #[test]
967 fn a_stat_line_truncated_before_field_22_has_no_start_token() {
968 let line = stat_line("sleep");
969 let truncated = &line[..line.find(" 987654").unwrap()];
970 assert_eq!(platform::start_token_from_stat(truncated), None);
971 }
972}
973
974#[cfg(test)]
975mod home_tests {
976 use super::*;
977
978 #[test]
979 fn running_dir_holds_a_wide_murmur_home_owner_only() {
980 let home = tempfile::tempdir().unwrap();
981 crate::murmur_home::wide_dir(&home.path().join(".murmur"), 0o755);
982 crate::murmur_home::run_with_home(
983 "running::home_tests::inner_running_dir_under_scratch_home",
984 home.path(),
985 );
986 assert_eq!(
987 crate::murmur_home::mode_of(&home.path().join(".murmur")),
988 0o700
989 );
990 assert_eq!(
991 crate::murmur_home::mode_of(&home.path().join(".murmur").join(RUNNING_DIR)),
992 RUNNING_DIR_MODE
993 );
994 }
995
996 #[test]
997 #[ignore = "run by running_dir_holds_a_wide_murmur_home_owner_only"]
998 fn inner_running_dir_under_scratch_home() {
999 if !crate::murmur_home::in_scratch_home() {
1000 return;
1001 }
1002 running_dir().unwrap();
1003 }
1004}