rlmctl_core/guard/
service.rs1use std::io::Read;
6use std::process::{Child, Command, Stdio};
7use std::thread;
8use std::time::{Duration, Instant};
9
10const SYSTEMCTL_TIMEOUT: Duration = Duration::from_secs(1);
16
17const POLL_INTERVAL: Duration = Duration::from_millis(20);
19
20const TIMED_OUT: &str = "timeout";
25
26#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct ServiceState {
31 pub active: String,
32 pub enabled: String,
33}
34
35pub fn query() -> ServiceState {
45 match (
46 run_with_timeout(systemctl_command(&["is-active"]), SYSTEMCTL_TIMEOUT),
47 run_with_timeout(systemctl_command(&["is-enabled"]), SYSTEMCTL_TIMEOUT),
48 ) {
49 (RunOutcome::Output(a), RunOutcome::Output(e)) => ServiceState {
50 active: state_word(&a, "unknown"),
51 enabled: state_word(&e, "not-found"),
52 },
53 (RunOutcome::TimedOut, _) | (_, RunOutcome::TimedOut) => ServiceState {
54 active: TIMED_OUT.to_string(),
55 enabled: TIMED_OUT.to_string(),
56 },
57 _ => ServiceState {
58 active: "unknown".to_string(),
59 enabled: "unknown".to_string(),
60 },
61 }
62}
63
64fn systemctl_command(verb_args: &[&str]) -> Command {
66 let mut cmd = Command::new("systemctl");
67 cmd.arg("--user");
68 cmd.args(verb_args);
69 cmd.arg("rlm-guard");
70 cmd
71}
72
73enum RunOutcome {
75 Output(String),
77 TimedOut,
80 Failed,
82}
83
84fn run_with_timeout(mut cmd: Command, timeout: Duration) -> RunOutcome {
91 let Ok(mut child) = cmd.stdout(Stdio::piped()).stderr(Stdio::null()).spawn() else {
92 return RunOutcome::Failed;
93 };
94 let deadline = Instant::now() + timeout;
95 loop {
96 match child.try_wait() {
97 Ok(Some(_status)) => return RunOutcome::Output(read_child_stdout(&mut child)),
98 Ok(None) => {
99 if Instant::now() >= deadline {
100 let _ = child.kill();
101 let _ = child.wait();
102 return RunOutcome::TimedOut;
103 }
104 thread::sleep(POLL_INTERVAL);
105 }
106 Err(_) => return RunOutcome::Failed,
107 }
108 }
109}
110
111fn read_child_stdout(child: &mut Child) -> String {
115 let mut out = String::new();
116 if let Some(mut stdout) = child.stdout.take() {
117 let _ = stdout.read_to_string(&mut out);
118 }
119 out
120}
121
122pub fn state_word(stdout: &str, fallback: &str) -> String {
126 match stdout.lines().next().map(str::trim) {
127 Some(s) if !s.is_empty() => s.to_string(),
128 _ => fallback.to_string(),
129 }
130}
131
132pub fn describe(s: &ServiceState) -> String {
134 if s.active == TIMED_OUT {
135 return "unknown (systemctl did not answer)".to_string();
136 }
137 if s.active == "unknown" {
138 return "unknown (systemctl --user is not available)".to_string();
139 }
140 if s.active == "failed" {
141 return "failed (see: journalctl --user -u rlm-guard -n 20)".to_string();
142 }
143 if s.enabled == "not-found" {
144 return "not installed (run: rlm guard enable)".to_string();
145 }
146 let running = if s.active == "active" {
147 "running"
148 } else {
149 "stopped"
150 };
151 let login = if s.enabled == "enabled" {
152 "starts at login"
153 } else {
154 "not started at login"
155 };
156 format!("{running} ({login})")
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[test]
164 fn state_words_and_descriptions() {
165 assert_eq!(state_word("active\n", "unknown"), "active");
166 assert_eq!(state_word("", "not-found"), "not-found");
167 let d = |a: &str, e: &str| {
168 describe(&ServiceState {
169 active: a.into(),
170 enabled: e.into(),
171 })
172 };
173 assert_eq!(d("active", "enabled"), "running (starts at login)");
174 assert_eq!(d("inactive", "disabled"), "stopped (not started at login)");
175 assert_eq!(
176 d("inactive", "not-found"),
177 "not installed (run: rlm guard enable)"
178 );
179 assert!(d("failed", "enabled").starts_with("failed (see: journalctl --user -u rlm-guard"));
180 assert_eq!(
181 d("unknown", "unknown"),
182 "unknown (systemctl --user is not available)"
183 );
184 assert_eq!(
185 d(TIMED_OUT, TIMED_OUT),
186 "unknown (systemctl did not answer)"
187 );
188 }
189
190 #[test]
197 fn run_with_timeout_kills_and_reaps_a_hung_child() {
198 let mut cmd = Command::new("sleep");
199 cmd.arg("5");
200 let start = Instant::now();
201 let outcome = run_with_timeout(cmd, Duration::from_millis(100));
202 assert!(
203 matches!(outcome, RunOutcome::TimedOut),
204 "expected a timeout, not a completed run"
205 );
206 assert!(
207 start.elapsed() < Duration::from_secs(2),
208 "must not wait anywhere near the full 5s sleep"
209 );
210 }
211
212 #[test]
215 fn run_with_timeout_returns_output_when_fast() {
216 let mut cmd = Command::new("echo");
217 cmd.arg("hello");
218 let outcome = run_with_timeout(cmd, Duration::from_secs(1));
219 match outcome {
220 RunOutcome::Output(s) => assert_eq!(s.trim(), "hello"),
221 _ => panic!("expected Output, got a failure or timeout"),
222 }
223 }
224}