pacsea/logic/
password.rs

1//! Sudo password validation utilities.
2
3use std::process::Command;
4
5/// What: Returns true only when running in integration test context.
6///
7/// Inputs:
8/// - None (reads env var `PACSEA_INTEGRATION_TEST`).
9///
10/// Output:
11/// - `true` if `PACSEA_INTEGRATION_TEST=1` is set, `false` otherwise.
12///
13/// Details:
14/// - Used to gate `PACSEA_TEST_SUDO_PASSWORDLESS` so production never honors it.
15/// - Integration tests set this env var so the test override is applied.
16fn is_integration_test_context() -> bool {
17    std::env::var("PACSEA_INTEGRATION_TEST").is_ok_and(|v| v == "1")
18}
19
20/// What: Check if passwordless sudo is available for the current user.
21///
22/// Inputs:
23/// - None (checks system configuration).
24///
25/// Output:
26/// - `Ok(true)` if passwordless sudo is available, `Ok(false)` if not, or `Err(String)` on error.
27///
28/// # Errors
29///
30/// - Returns `Err` if the check cannot be executed (e.g., sudo not installed).
31///
32/// Details:
33/// - Uses `sudo -n true` to test if sudo can run without password.
34/// - `-n`: Non-interactive mode (fails if password required).
35/// - `true`: Simple command that always succeeds if sudo works.
36/// - Returns `Ok(false)` if sudo is not available or requires a password.
37/// - **Testing**: Only when `PACSEA_INTEGRATION_TEST=1` is set (test harness), the env var
38///   `PACSEA_TEST_SUDO_PASSWORDLESS` is honored: "1" = available, "0" = unavailable.
39///   In production (when `PACSEA_INTEGRATION_TEST` is not set), `PACSEA_TEST_SUDO_PASSWORDLESS`
40///   is ignored so the only way to enable passwordless sudo is via `use_passwordless_sudo` in settings.
41pub fn check_passwordless_sudo_available() -> Result<bool, String> {
42    // Honor test override only in integration test context so production never honors it
43    if is_integration_test_context()
44        && let Ok(val) = std::env::var("PACSEA_TEST_SUDO_PASSWORDLESS")
45    {
46        tracing::debug!(
47            "Using test override for passwordless sudo check: PACSEA_TEST_SUDO_PASSWORDLESS={}",
48            val
49        );
50        return Ok(val == "1");
51    }
52
53    let status = Command::new("sudo")
54        .args(["-n", "true"])
55        .stdin(std::process::Stdio::null())
56        .stdout(std::process::Stdio::null())
57        .stderr(std::process::Stdio::null())
58        .status()
59        .map_err(|e| format!("Failed to check passwordless sudo: {e}"))?;
60
61    Ok(status.success())
62}
63
64/// What: Check if passwordless sudo should be used based on settings and system availability.
65///
66/// Inputs:
67/// - `settings`: Reference to the application settings.
68///
69/// Output:
70/// - `true` if passwordless sudo should be used, `false` otherwise.
71///
72/// Details:
73/// - First checks if `use_passwordless_sudo` is enabled in settings (safety barrier).
74/// - If not enabled, returns `false` immediately without checking system availability.
75/// - If enabled, checks if passwordless sudo is actually available on the system.
76/// - Returns `true` only if both conditions are met.
77/// - Logs the decision for debugging purposes.
78/// - **Testing**: Only when `PACSEA_INTEGRATION_TEST=1` is set (test harness), the env var
79///   `PACSEA_TEST_SUDO_PASSWORDLESS` is honored. In production this override is disabled.
80#[must_use]
81pub fn should_use_passwordless_sudo(settings: &crate::theme::Settings) -> bool {
82    // Honor test override only in integration test context so production never honors it
83    if is_integration_test_context()
84        && let Ok(val) = std::env::var("PACSEA_TEST_SUDO_PASSWORDLESS")
85    {
86        tracing::debug!(
87            "Using test override for should_use_passwordless_sudo: PACSEA_TEST_SUDO_PASSWORDLESS={}",
88            val
89        );
90        return val == "1";
91    }
92
93    // Check if passwordless sudo is enabled in settings (safety barrier)
94    if !settings.use_passwordless_sudo {
95        tracing::debug!("Passwordless sudo disabled in settings, requiring password prompt");
96        return false;
97    }
98
99    // Check if passwordless sudo is available on the system
100    match check_passwordless_sudo_available() {
101        Ok(true) => {
102            tracing::info!("Passwordless sudo enabled in settings and available on system");
103            true
104        }
105        Ok(false) => {
106            tracing::debug!(
107                "Passwordless sudo enabled in settings but not available on system, requiring password prompt"
108            );
109            false
110        }
111        Err(e) => {
112            tracing::debug!(
113                "Passwordless sudo check failed ({}), requiring password prompt",
114                e
115            );
116            false
117        }
118    }
119}
120
121/// What: Validate a sudo password without executing any command.
122///
123/// Inputs:
124/// - `password`: Password to validate.
125///
126/// Output:
127/// - `Ok(true)` if password is valid, `Ok(false)` if invalid, or `Err(String)` on error.
128///
129/// # Errors
130///
131/// - Returns `Err` if the validation command cannot be executed (e.g., sudo not available).
132///
133/// Details:
134/// - First invalidates cached sudo credentials with `sudo -k` to ensure fresh validation.
135/// - Then executes `printf '%s\n' '<password>' | sudo -S -v` to test password validity.
136/// - Uses `printf` instead of `echo` for more reliable password handling.
137/// - Uses `sudo -v` which validates credentials without executing a command.
138/// - Returns `Ok(true)` if password is valid, `Ok(false)` if invalid.
139/// - Handles errors appropriately (e.g., if sudo is not available).
140pub fn validate_sudo_password(password: &str) -> Result<bool, String> {
141    use crate::install::shell_single_quote;
142
143    // Escape password for shell safety
144    let escaped_password = shell_single_quote(password);
145
146    // Build command: sudo -k ; printf '%s\n' '<password>' | sudo -S -v
147    // First, sudo -k invalidates any cached credentials to ensure fresh validation.
148    // Without this, cached credentials could cause validation to succeed even with wrong password.
149    // Use printf instead of echo for more reliable password handling.
150    // sudo -v validates credentials without executing a command.
151    let cmd = format!("sudo -k ; printf '%s\\n' {escaped_password} | sudo -S -v 2>&1");
152
153    // Execute command
154    let output = Command::new("sh")
155        .arg("-c")
156        .arg(&cmd)
157        .output()
158        .map_err(|e| format!("Failed to execute sudo validation: {e}"))?;
159
160    // Check exit code
161    // Exit code 0 means password is valid
162    // Non-zero exit code means password is invalid or other error
163    // This approach is language-independent as it relies on exit codes, not error messages
164    if output.status.success() {
165        Ok(true)
166    } else {
167        Ok(false)
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    /// What: Check if passwordless sudo is configured (test helper).
176    ///
177    /// Inputs:
178    /// - None.
179    ///
180    /// Output:
181    /// - `true` if passwordless sudo is available, `false` otherwise.
182    ///
183    /// Details:
184    /// - Uses the public `check_passwordless_sudo_available()` function.
185    /// - Returns `false` if sudo is not available or requires a password.
186    fn is_passwordless_sudo() -> bool {
187        check_passwordless_sudo_available().unwrap_or(false)
188    }
189
190    #[test]
191    /// What: Test passwordless sudo check returns a valid result.
192    ///
193    /// Inputs:
194    /// - None.
195    ///
196    /// Output:
197    /// - Returns `Ok(bool)` without panicking.
198    ///
199    /// Details:
200    /// - Verifies the function returns a valid result (either true or false).
201    /// - Does not assert on the actual value since it depends on system configuration.
202    fn test_check_passwordless_sudo_available() {
203        let result = check_passwordless_sudo_available();
204        // Should return Ok with either true or false, depending on system config
205        assert!(result.is_ok());
206    }
207
208    #[test]
209    #[ignore = "Uses sudo with wrong password - may lock user out. Run with --ignored"]
210    /// What: Test password validation handles invalid passwords.
211    ///
212    /// Inputs:
213    /// - Invalid password string.
214    ///
215    /// Output:
216    /// - Returns `Ok(false)` for invalid password.
217    ///
218    /// Details:
219    /// - Verifies the function correctly identifies invalid passwords.
220    /// - Skips assertion if passwordless sudo is configured (common in CI).
221    /// - Marked as ignored to prevent user lockout from failed sudo attempts.
222    fn test_validate_sudo_password_invalid() {
223        // Skip test if passwordless sudo is configured (common in CI environments)
224        if is_passwordless_sudo() {
225            return;
226        }
227
228        // This test uses an obviously wrong password
229        // It should return Ok(false) without panicking
230        let result = validate_sudo_password("definitely_wrong_password_12345");
231        // Result may be Ok(false) or Err depending on system configuration
232        if let Ok(valid) = result {
233            // Should be false for invalid password
234            assert!(!valid);
235        } else {
236            // Error is acceptable (e.g., sudo not available)
237        }
238    }
239
240    #[test]
241    #[ignore = "Uses sudo with wrong password - may lock user out. Run with --ignored"]
242    /// What: Test password validation handles empty passwords.
243    ///
244    /// Inputs:
245    /// - Empty password string.
246    ///
247    /// Output:
248    /// - Returns `Ok(false)` for empty password.
249    ///
250    /// Details:
251    /// - Verifies the function correctly handles empty passwords.
252    /// - Skips assertion if passwordless sudo is configured (common in CI).
253    /// - Marked as ignored to prevent user lockout from failed sudo attempts.
254    fn test_validate_sudo_password_empty() {
255        // Skip test if passwordless sudo is configured (common in CI environments)
256        if is_passwordless_sudo() {
257            return;
258        }
259
260        let result = validate_sudo_password("");
261        // Empty password should be invalid
262        if let Ok(valid) = result {
263            assert!(!valid);
264        } else {
265            // Error is acceptable
266        }
267    }
268
269    #[test]
270    #[ignore = "Uses sudo with wrong password - may lock user out. Run with --ignored"]
271    /// What: Test password validation handles special characters.
272    ///
273    /// Inputs:
274    /// - Password with special characters that need escaping.
275    ///
276    /// Output:
277    /// - Handles special characters without panicking.
278    ///
279    /// Details:
280    /// - Verifies the function correctly escapes special characters in passwords.
281    /// - Marked as ignored to prevent user lockout from failed sudo attempts.
282    fn test_validate_sudo_password_special_chars() {
283        // Test with password containing special shell characters
284        let passwords = vec![
285            "pass'word",
286            "pass\"word",
287            "pass$word",
288            "pass`word",
289            "pass\\word",
290        ];
291        for pass in passwords {
292            let result = validate_sudo_password(pass);
293            // Just verify it doesn't panic
294            let _ = result;
295        }
296    }
297
298    #[test]
299    #[ignore = "Uses sudo with wrong password - may lock user out. Run with --ignored"]
300    /// What: Test password validation function signature.
301    ///
302    /// Inputs:
303    /// - Various password strings.
304    ///
305    /// Output:
306    /// - Returns Result<bool, String> as expected.
307    ///
308    /// Details:
309    /// - Verifies the function returns the correct type.
310    /// - Marked as ignored to prevent user lockout from failed sudo attempts.
311    fn test_validate_sudo_password_signature() {
312        let result: Result<bool, String> = validate_sudo_password("test");
313        // Verify it returns the correct type
314        let _ = result;
315    }
316}