use std::io::Read;
use std::process::{Child, Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};
const SYSTEMCTL_TIMEOUT: Duration = Duration::from_secs(1);
const POLL_INTERVAL: Duration = Duration::from_millis(20);
const TIMED_OUT: &str = "timeout";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServiceState {
pub active: String,
pub enabled: String,
}
pub fn query() -> ServiceState {
match (
run_with_timeout(systemctl_command(&["is-active"]), SYSTEMCTL_TIMEOUT),
run_with_timeout(systemctl_command(&["is-enabled"]), SYSTEMCTL_TIMEOUT),
) {
(RunOutcome::Output(a), RunOutcome::Output(e)) => ServiceState {
active: state_word(&a, "unknown"),
enabled: state_word(&e, "not-found"),
},
(RunOutcome::TimedOut, _) | (_, RunOutcome::TimedOut) => ServiceState {
active: TIMED_OUT.to_string(),
enabled: TIMED_OUT.to_string(),
},
_ => ServiceState {
active: "unknown".to_string(),
enabled: "unknown".to_string(),
},
}
}
fn systemctl_command(verb_args: &[&str]) -> Command {
let mut cmd = Command::new("systemctl");
cmd.arg("--user");
cmd.args(verb_args);
cmd.arg("rlm-guard");
cmd
}
enum RunOutcome {
Output(String),
TimedOut,
Failed,
}
fn run_with_timeout(mut cmd: Command, timeout: Duration) -> RunOutcome {
let Ok(mut child) = cmd.stdout(Stdio::piped()).stderr(Stdio::null()).spawn() else {
return RunOutcome::Failed;
};
let deadline = Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(_status)) => return RunOutcome::Output(read_child_stdout(&mut child)),
Ok(None) => {
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return RunOutcome::TimedOut;
}
thread::sleep(POLL_INTERVAL);
}
Err(_) => return RunOutcome::Failed,
}
}
}
fn read_child_stdout(child: &mut Child) -> String {
let mut out = String::new();
if let Some(mut stdout) = child.stdout.take() {
let _ = stdout.read_to_string(&mut out);
}
out
}
pub fn state_word(stdout: &str, fallback: &str) -> String {
match stdout.lines().next().map(str::trim) {
Some(s) if !s.is_empty() => s.to_string(),
_ => fallback.to_string(),
}
}
pub fn describe(s: &ServiceState) -> String {
if s.active == TIMED_OUT {
return "unknown (systemctl did not answer)".to_string();
}
if s.active == "unknown" {
return "unknown (systemctl --user is not available)".to_string();
}
if s.active == "failed" {
return "failed (see: journalctl --user -u rlm-guard -n 20)".to_string();
}
if s.enabled == "not-found" {
return "not installed (run: rlm guard enable)".to_string();
}
let running = if s.active == "active" {
"running"
} else {
"stopped"
};
let login = if s.enabled == "enabled" {
"starts at login"
} else {
"not started at login"
};
format!("{running} ({login})")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn state_words_and_descriptions() {
assert_eq!(state_word("active\n", "unknown"), "active");
assert_eq!(state_word("", "not-found"), "not-found");
let d = |a: &str, e: &str| {
describe(&ServiceState {
active: a.into(),
enabled: e.into(),
})
};
assert_eq!(d("active", "enabled"), "running (starts at login)");
assert_eq!(d("inactive", "disabled"), "stopped (not started at login)");
assert_eq!(
d("inactive", "not-found"),
"not installed (run: rlm guard enable)"
);
assert!(d("failed", "enabled").starts_with("failed (see: journalctl --user -u rlm-guard"));
assert_eq!(
d("unknown", "unknown"),
"unknown (systemctl --user is not available)"
);
assert_eq!(
d(TIMED_OUT, TIMED_OUT),
"unknown (systemctl did not answer)"
);
}
#[test]
fn run_with_timeout_kills_and_reaps_a_hung_child() {
let mut cmd = Command::new("sleep");
cmd.arg("5");
let start = Instant::now();
let outcome = run_with_timeout(cmd, Duration::from_millis(100));
assert!(
matches!(outcome, RunOutcome::TimedOut),
"expected a timeout, not a completed run"
);
assert!(
start.elapsed() < Duration::from_secs(2),
"must not wait anywhere near the full 5s sleep"
);
}
#[test]
fn run_with_timeout_returns_output_when_fast() {
let mut cmd = Command::new("echo");
cmd.arg("hello");
let outcome = run_with_timeout(cmd, Duration::from_secs(1));
match outcome {
RunOutcome::Output(s) => assert_eq!(s.trim(), "hello"),
_ => panic!("expected Output, got a failure or timeout"),
}
}
}