Skip to main content

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}