cleansys_core/auth.rs
1//! Sudo/root authentication helpers shared by the TUI password prompt and the
2//! GUI authentication dialog.
3//!
4//! This module contains no UI framework code — front-ends own their own
5//! widgets/state and call into these functions to perform the actual
6//! authentication.
7
8use anyhow::Result;
9use std::io::Write;
10use std::process::{Command, Stdio};
11use std::sync::{Mutex, OnceLock};
12
13/// Process-wide cache of the last successfully-validated sudo password.
14///
15/// GUI front-ends have no controlling TTY, so `sudo`'s own ticket cache
16/// (normally keyed per-TTY/session) is not a reliable way to reuse
17/// credentials across the separate `sudo` child processes spawned for each
18/// cleaner — `sudo -n <cmd>` can fail even immediately after a successful
19/// [`authenticate_sudo`] call, depending on the platform's sudoers
20/// configuration. Caching the raw password here lets
21/// [`crate::utils::execute_with_sudo`] re-authenticate every single
22/// privileged command the same reliable way `authenticate_sudo` itself
23/// does (piping it to `sudo -S`), instead of depending on ticket reuse.
24fn password_cell() -> &'static Mutex<Option<String>> {
25 static CELL: OnceLock<Mutex<Option<String>>> = OnceLock::new();
26 CELL.get_or_init(|| Mutex::new(None))
27}
28
29/// Cache a validated sudo password for later privileged commands to reuse.
30pub fn cache_sudo_password(password: String) {
31 if let Ok(mut guard) = password_cell().lock() {
32 *guard = Some(password);
33 }
34}
35
36/// Clear any cached sudo password (call once a run finishes, the dialog is
37/// cancelled, or the app is closing).
38pub fn clear_cached_sudo_password() {
39 if let Ok(mut guard) = password_cell().lock() {
40 guard.take();
41 }
42}
43
44/// Return a clone of the cached sudo password, if one is set.
45pub fn cached_sudo_password() -> Option<String> {
46 password_cell().lock().ok().and_then(|guard| guard.clone())
47}
48
49/// Attempt to authenticate as root using `sudo -S -v` with the given password
50/// piped over stdin. Returns `Ok(true)` if authentication succeeded, `Ok(false)`
51/// if the password was rejected, and `Err` if `sudo` could not be invoked at all.
52///
53/// On success, the password is also cached (see [`cache_sudo_password`]) so
54/// that subsequent privileged commands run via
55/// [`crate::utils::execute_with_sudo`] can re-authenticate reliably without
56/// depending on `sudo`'s own (TTY/session-keyed) credential cache.
57pub fn authenticate_sudo(password: &str) -> Result<bool> {
58 let mut child = Command::new("sudo")
59 .arg("-S")
60 .arg("-v")
61 .stdin(Stdio::piped())
62 .stdout(Stdio::null())
63 .stderr(Stdio::null())
64 .spawn()?;
65
66 if let Some(mut stdin) = child.stdin.take() {
67 writeln!(stdin, "{}", password)?;
68 }
69
70 let status = child.wait()?;
71 let success = status.success();
72 if success {
73 cache_sudo_password(password.to_string());
74 }
75 Ok(success)
76}
77
78#[cfg(test)]
79mod tests {
80 use super::*;
81
82 #[test]
83 #[cfg(unix)]
84 fn authenticate_sudo_with_wrong_password_does_not_panic() {
85 // We can't guarantee the outcome (depends on the test machine's sudo
86 // config and cached credentials), just that the call completes.
87 let _ = authenticate_sudo("definitely-not-the-real-password-12345");
88 }
89
90 #[test]
91 fn cached_sudo_password_round_trips_and_clears() {
92 // Single test exercising cache/get/clear in sequence against the
93 // process-global cell, since running separate tests concurrently
94 // against shared global state would race.
95 clear_cached_sudo_password();
96 assert_eq!(cached_sudo_password(), None);
97
98 cache_sudo_password("hunter2".to_string());
99 assert_eq!(cached_sudo_password().as_deref(), Some("hunter2"));
100
101 // Caching again overwrites, it doesn't accumulate.
102 cache_sudo_password("new-password".to_string());
103 assert_eq!(cached_sudo_password().as_deref(), Some("new-password"));
104
105 clear_cached_sudo_password();
106 assert_eq!(cached_sudo_password(), None);
107
108 // Clearing an already-empty cache is a harmless no-op.
109 clear_cached_sudo_password();
110 assert_eq!(cached_sudo_password(), None);
111 }
112}