Skip to main content

pacsea/logic/
privilege.rs

1//! Privilege escalation abstraction for sudo/doas support.
2//!
3//! # doas capability spike (Phase 0)
4//!
5//! **Target package:** `opendoas` (Arch: `extra/opendoas`)
6//! **Minimum supported behavior:** `OpenDoas` as packaged in Arch Linux repos.
7//!
8//! ## Supported patterns
9//!
10//! | Pattern | sudo | doas |
11//! |---|---|---|
12//! | Non-interactive check | `sudo -n true` | `doas -n true` |
13//! | Direct command execution | `sudo <cmd>` | `doas <cmd>` |
14//! | Passwordless execution | sudoers `NOPASSWD` | `permit nopass` in `/etc/doas.conf` |
15//! | Password via stdin | `sudo -S` reads stdin | **NOT supported** |
16//! | Credential refresh | `sudo -v` | **NOT supported** |
17//! | Credential invalidation | `sudo -k` | **NOT supported** |
18//! | Askpass env var | `SUDO_ASKPASS` | **NOT supported** |
19//!
20//! ## Implications for Pacsea
21//!
22//! - When doas requires a password, it prompts via its own terminal interaction.
23//! - The in-app password modal **cannot** be used with doas (no stdin pipe support).
24//! - Pacsea skips the password modal for doas and lets the spawned terminal handle prompting.
25//! - Credential warm-up (`sudo -S -v`) is unavailable for doas.
26//! - `doas -n true` works identically to `sudo -n true` for passwordless detection.
27
28use std::fmt;
29use std::process::Command;
30
31/// What: Privilege escalation tool supported by Pacsea.
32///
33/// Inputs: None (enum variant selection).
34///
35/// Output: Identifies which privilege tool to invoke.
36///
37/// Details:
38/// - `Sudo` uses the standard sudo binary with full feature support
39///   (stdin password, credential caching, askpass).
40/// - `Doas` uses the `OpenDoas` binary with limited feature support
41///   (no stdin password, no credential caching).
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub enum PrivilegeTool {
44    /// Standard sudo — full feature support.
45    Sudo,
46    /// `OpenDoas` — limited feature support (no stdin password pipe, no credential caching).
47    Doas,
48}
49
50/// What: User-configured privilege tool selection mode parsed from `settings.conf`.
51///
52/// Inputs: None (enum variant selection).
53///
54/// Output: Controls how Pacsea selects the privilege escalation tool.
55///
56/// Details:
57/// - `Auto` (default): prefer doas if available on `$PATH`, fall back to sudo.
58/// - `Sudo`: always use sudo; fail with actionable error if unavailable.
59/// - `Doas`: always use doas; fail with actionable error if unavailable.
60#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
61pub enum PrivilegeMode {
62    /// Auto-detect: prefer doas if available, fall back to sudo.
63    #[default]
64    Auto,
65    /// Always use sudo.
66    Sudo,
67    /// Always use doas.
68    Doas,
69}
70
71/// What: Capability flags describing which features a privilege tool supports.
72///
73/// Inputs: None (populated per tool via [`PrivilegeTool::capabilities`]).
74///
75/// Output: Boolean flags for each optional capability.
76///
77/// Details:
78/// - sudo supports all capabilities.
79/// - doas supports none of these optional capabilities.
80/// - Used to route behavior: e.g. skip password modal when stdin pipe is unsupported.
81#[derive(Clone, Copy, Debug, PartialEq, Eq)]
82#[allow(clippy::struct_excessive_bools)]
83pub struct PrivilegeCapabilities {
84    /// Tool supports reading password from stdin (`sudo -S`).
85    pub supports_stdin_password: bool,
86    /// Tool supports credential validation/refresh without running a command (`sudo -v`).
87    pub supports_credential_refresh: bool,
88    /// Tool supports credential invalidation (`sudo -k`).
89    pub supports_credential_invalidation: bool,
90    /// Tool supports the `ASKPASS` environment variable (`SUDO_ASKPASS`).
91    pub supports_askpass: bool,
92}
93
94// ---------------------------------------------------------------------------
95// PrivilegeTool implementation
96// ---------------------------------------------------------------------------
97
98impl PrivilegeTool {
99    /// What: Return the shell binary name for this tool.
100    ///
101    /// Inputs: None.
102    ///
103    /// Output: `"sudo"` or `"doas"`.
104    ///
105    /// Details: Used in command construction and `which` lookups.
106    #[must_use]
107    pub const fn binary_name(self) -> &'static str {
108        match self {
109            Self::Sudo => "sudo",
110            Self::Doas => "doas",
111        }
112    }
113
114    /// What: Return the capability flags for this tool.
115    ///
116    /// Inputs: None.
117    ///
118    /// Output: [`PrivilegeCapabilities`] with tool-specific flags.
119    ///
120    /// Details:
121    /// - sudo: all capabilities enabled.
122    /// - doas: all capabilities disabled (see module-level docs for rationale).
123    #[must_use]
124    pub const fn capabilities(self) -> PrivilegeCapabilities {
125        match self {
126            Self::Sudo => PrivilegeCapabilities {
127                supports_stdin_password: true,
128                supports_credential_refresh: true,
129                supports_credential_invalidation: true,
130                supports_askpass: true,
131            },
132            Self::Doas => PrivilegeCapabilities {
133                supports_stdin_password: false,
134                supports_credential_refresh: false,
135                supports_credential_invalidation: false,
136                supports_askpass: false,
137            },
138        }
139    }
140
141    /// What: Check whether this tool's binary exists on `$PATH`.
142    ///
143    /// Inputs: None.
144    ///
145    /// Output: `true` if the binary is found.
146    ///
147    /// Details:
148    /// - In integration test context (`PACSEA_INTEGRATION_TEST=1`), honors
149    ///   `PACSEA_TEST_PRIVILEGE_AVAILABLE` (comma-separated list, or `"none"`).
150    /// - Production: delegates to `which::which`.
151    #[must_use]
152    pub fn is_available(self) -> bool {
153        if is_integration_test_context()
154            && let Ok(val) = std::env::var("PACSEA_TEST_PRIVILEGE_AVAILABLE")
155        {
156            if val == "none" {
157                return false;
158            }
159            return val.split(',').any(|t| t.trim() == self.binary_name());
160        }
161        which::which(self.binary_name()).is_ok()
162    }
163
164    /// What: Check whether passwordless privilege escalation is available.
165    ///
166    /// Inputs: None.
167    ///
168    /// Output: `Ok(true)` if non-interactive execution succeeds, `Err` if the check itself fails.
169    ///
170    /// # Errors
171    ///
172    /// Returns `Err` if the tool binary cannot be executed (e.g. not installed).
173    ///
174    /// Details:
175    /// - Runs `<tool> -n true` (`-n` = non-interactive, `true` = no-op command).
176    /// - Both sudo and doas support the `-n` flag.
177    /// - In integration test context, honors `PACSEA_TEST_SUDO_PASSWORDLESS`.
178    pub fn check_passwordless(self) -> Result<bool, String> {
179        if is_integration_test_context()
180            && let Ok(val) = std::env::var("PACSEA_TEST_SUDO_PASSWORDLESS")
181        {
182            tracing::debug!(
183                tool = self.binary_name(),
184                val = %val,
185                "Using test override for passwordless check"
186            );
187            return Ok(val == "1");
188        }
189
190        let status = Command::new(self.binary_name())
191            .args(["-n", "true"])
192            .stdin(std::process::Stdio::null())
193            .stdout(std::process::Stdio::null())
194            .stderr(std::process::Stdio::null())
195            .status()
196            .map_err(|e| format!("Failed to check passwordless {}: {e}", self.binary_name()))?;
197
198        Ok(status.success())
199    }
200}
201
202impl fmt::Display for PrivilegeTool {
203    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
204        f.write_str(self.binary_name())
205    }
206}
207
208// ---------------------------------------------------------------------------
209// PrivilegeMode implementation
210// ---------------------------------------------------------------------------
211
212impl PrivilegeMode {
213    /// What: Parse a config file value into a `PrivilegeMode`.
214    ///
215    /// Inputs:
216    /// - `val`: Raw config string (e.g. `"auto"`, `"sudo"`, `"doas"`).
217    ///
218    /// Output: `Some(mode)` on recognized value, `None` otherwise.
219    ///
220    /// Details: Case-insensitive matching after trim.
221    #[must_use]
222    pub fn from_config_key(val: &str) -> Option<Self> {
223        match val.trim().to_ascii_lowercase().as_str() {
224            "auto" => Some(Self::Auto),
225            "sudo" => Some(Self::Sudo),
226            "doas" => Some(Self::Doas),
227            _ => None,
228        }
229    }
230
231    /// What: Return the canonical config key string for this mode.
232    ///
233    /// Inputs: None.
234    ///
235    /// Output: `"auto"`, `"sudo"`, or `"doas"`.
236    ///
237    /// Details: Inverse of [`from_config_key`](Self::from_config_key).
238    #[must_use]
239    pub const fn as_config_key(self) -> &'static str {
240        match self {
241            Self::Auto => "auto",
242            Self::Sudo => "sudo",
243            Self::Doas => "doas",
244        }
245    }
246}
247
248impl fmt::Display for PrivilegeMode {
249    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
250        f.write_str(self.as_config_key())
251    }
252}
253
254// ---------------------------------------------------------------------------
255// AuthMode
256// ---------------------------------------------------------------------------
257
258/// What: Authentication strategy for privilege escalation.
259///
260/// Inputs: None (enum variant selection).
261///
262/// Output: Controls how Pacsea handles authentication before privileged operations.
263///
264/// Details:
265/// - `Prompt` (default): Pacsea shows its own password modal/prompt and uses stdin password
266///   piping (`sudo -S`) for stdin-capable tools.
267///   For tools without stdin support (doas), resolver logic coerces this mode to `Interactive`.
268/// - `PasswordlessOnly`: Skip password prompt only when `{tool} -n true` succeeds;
269///   fall back to `Prompt` otherwise.
270/// - `Interactive`: Skip Pacsea's password capture entirely and let the privilege
271///   tool handle authentication directly (fingerprint via PAM, terminal password, etc.).
272///   Works with both sudo and doas when PAM is configured.
273#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
274pub enum AuthMode {
275    /// Pacsea captures the password for stdin-capable tools; non-stdin tools are coerced to interactive.
276    #[default]
277    Prompt,
278    /// Skip password prompt only when passwordless escalation is available.
279    PasswordlessOnly,
280    /// Let the privilege tool handle authentication interactively (PAM fingerprint, etc.).
281    Interactive,
282}
283
284impl AuthMode {
285    /// What: Parse a config file value into an `AuthMode`.
286    ///
287    /// Inputs:
288    /// - `val`: Raw config string (e.g. `"prompt"`, `"passwordless_only"`, `"interactive"`).
289    ///
290    /// Output: `Some(mode)` on recognized value, `None` otherwise.
291    ///
292    /// Details: Case-insensitive matching after trim; accepts underscores and hyphens.
293    #[must_use]
294    pub fn from_config_key(val: &str) -> Option<Self> {
295        match val.trim().to_ascii_lowercase().replace('-', "_").as_str() {
296            "prompt" => Some(Self::Prompt),
297            "passwordless_only" | "passwordless" => Some(Self::PasswordlessOnly),
298            "interactive" => Some(Self::Interactive),
299            _ => None,
300        }
301    }
302
303    /// What: Return the canonical config key string for this mode.
304    ///
305    /// Inputs: None.
306    ///
307    /// Output: `"prompt"`, `"passwordless_only"`, or `"interactive"`.
308    ///
309    /// Details: Inverse of [`from_config_key`](Self::from_config_key).
310    #[must_use]
311    pub const fn as_config_key(self) -> &'static str {
312        match self {
313            Self::Prompt => "prompt",
314            Self::PasswordlessOnly => "passwordless_only",
315            Self::Interactive => "interactive",
316        }
317    }
318
319    /// What: Whether this mode skips Pacsea's password modal entirely.
320    ///
321    /// Inputs: None.
322    ///
323    /// Output: `true` for `Interactive`, `false` for `Prompt` and `PasswordlessOnly`.
324    ///
325    /// Details:
326    /// - `PasswordlessOnly` does not unconditionally skip — it still needs a runtime
327    ///   `{tool} -n true` check before skipping.
328    /// - `Interactive` always skips the modal because the tool handles auth directly.
329    #[must_use]
330    pub const fn always_skips_password_modal(self) -> bool {
331        matches!(self, Self::Interactive)
332    }
333}
334
335impl fmt::Display for AuthMode {
336    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
337        f.write_str(self.as_config_key())
338    }
339}
340
341// ---------------------------------------------------------------------------
342// Resolver
343// ---------------------------------------------------------------------------
344
345/// What: Resolve which privilege tool to use based on the configured mode.
346///
347/// Inputs:
348/// - `mode`: User-configured [`PrivilegeMode`].
349///
350/// Output: `Ok(tool)` on success, `Err` with actionable message on failure.
351///
352/// # Errors
353///
354/// - `Auto`: neither doas nor sudo found on `$PATH`.
355/// - `Sudo`/`Doas`: the explicitly requested tool is not on `$PATH`.
356///
357/// Details:
358/// - `Auto` prefers doas over sudo when both are available.
359/// - Explicit modes fail fast with a message suggesting config changes.
360pub fn resolve_privilege_tool(mode: PrivilegeMode) -> Result<PrivilegeTool, String> {
361    match mode {
362        PrivilegeMode::Auto => {
363            let doas_ok = PrivilegeTool::Doas.is_available();
364            let sudo_ok = PrivilegeTool::Sudo.is_available();
365            tracing::debug!(
366                mode = %mode,
367                doas_available = doas_ok,
368                sudo_available = sudo_ok,
369                "Resolving privilege tool"
370            );
371            if doas_ok {
372                tracing::info!(
373                    tool = "doas",
374                    reason = "auto: doas preferred when available",
375                    "Selected privilege tool"
376                );
377                Ok(PrivilegeTool::Doas)
378            } else if sudo_ok {
379                tracing::info!(
380                    tool = "sudo",
381                    reason = "auto: doas unavailable, falling back",
382                    "Selected privilege tool"
383                );
384                Ok(PrivilegeTool::Sudo)
385            } else {
386                Err("Neither doas nor sudo found on $PATH. \
387                     Install one to perform privileged operations: \
388                     `pacman -S sudo` or `pacman -S opendoas`."
389                    .to_string())
390            }
391        }
392        PrivilegeMode::Sudo => {
393            if PrivilegeTool::Sudo.is_available() {
394                tracing::info!(
395                    tool = "sudo",
396                    reason = "explicit config",
397                    "Selected privilege tool"
398                );
399                Ok(PrivilegeTool::Sudo)
400            } else {
401                Err(
402                    "sudo is not available on $PATH. Install sudo (`pacman -S sudo`) \
403                     or change privilege_tool to 'auto' or 'doas' in settings.conf."
404                        .to_string(),
405                )
406            }
407        }
408        PrivilegeMode::Doas => {
409            if PrivilegeTool::Doas.is_available() {
410                tracing::info!(
411                    tool = "doas",
412                    reason = "explicit config",
413                    "Selected privilege tool"
414                );
415                Ok(PrivilegeTool::Doas)
416            } else {
417                Err(
418                    "doas is not available on $PATH. Install opendoas (`pacman -S opendoas`) \
419                     or change privilege_tool to 'auto' or 'sudo' in settings.conf."
420                        .to_string(),
421                )
422            }
423        }
424    }
425}
426
427// ---------------------------------------------------------------------------
428// Convenience resolver
429// ---------------------------------------------------------------------------
430
431/// What: Resolve the privilege tool for a given mode, applying Auto-only fallback policy.
432///
433/// Inputs:
434/// - `mode`: Privilege selection mode (from settings, tests, or callers).
435///
436/// Output:
437/// - `Ok(PrivilegeTool)` when resolution succeeds, including `Ok(Sudo)` after `Auto`
438///   resolution failure (with warning logged).
439/// - `Err(String)` with an actionable message when `Sudo` or `Doas` mode is set but that
440///   binary is missing from `$PATH`.
441///
442/// Details:
443/// - Explicit modes never substitute the other tool; `Auto` may fall back to sudo so
444///   behaviour stays lenient when no privilege binary is installed.
445fn active_tool_for_mode(mode: PrivilegeMode) -> Result<PrivilegeTool, String> {
446    match resolve_privilege_tool(mode) {
447        Ok(tool) => Ok(tool),
448        Err(err) if mode == PrivilegeMode::Auto => {
449            tracing::warn!(
450                configured_mode = %mode,
451                error = %err,
452                fallback = "sudo",
453                "Privilege tool auto-resolution failed — falling back to sudo; privileged commands may fail if sudo is missing"
454            );
455            Ok(PrivilegeTool::Sudo)
456        }
457        Err(err) => Err(err),
458    }
459}
460
461/// What: Resolve the privilege tool using the cached application settings.
462///
463/// Inputs: None (reads `crate::theme::settings().privilege_mode`).
464///
465/// Output:
466/// - `Ok(PrivilegeTool)` for successful resolution, or `Ok(Sudo)` after `Auto` failure
467///   (see [`active_tool_for_mode`]).
468/// - `Err(String)` when explicit `sudo` or `doas` mode cannot be satisfied.
469///
470/// Details:
471/// - Callers should propagate or display `Err` so misconfiguration is visible.
472/// - Same fallback rules as [`active_tool_for_mode`].
473///
474/// # Errors
475///
476/// Returns `Err` when [`PrivilegeMode::Sudo`] or [`PrivilegeMode::Doas`] is configured but
477/// that tool is not available on `$PATH`. [`PrivilegeMode::Auto`] never errors here: it falls
478/// back to [`PrivilegeTool::Sudo`] after logging a warning.
479#[must_use = "caller should handle missing explicit privilege tools"]
480pub fn active_tool() -> Result<PrivilegeTool, String> {
481    let settings = crate::theme::settings();
482    active_tool_for_mode(settings.privilege_mode)
483}
484
485// ---------------------------------------------------------------------------
486// Interactive authentication
487// ---------------------------------------------------------------------------
488
489/// What: Run the privilege tool interactively to let the user authenticate.
490///
491/// Inputs:
492/// - `tool`: Resolved privilege tool (sudo or doas).
493///
494/// Output:
495/// - `Ok(true)` if authentication succeeded, `Ok(false)` if it failed.
496///
497/// # Errors
498///
499/// Returns `Err` if the tool binary cannot be executed.
500///
501/// Details:
502/// - For sudo: runs `sudo -v` which validates credentials without executing a command.
503///   On success, the credential cache is refreshed so subsequent `sudo` calls don't re-prompt.
504/// - For doas: runs `doas true` (a no-op command) to trigger authentication.
505///   If `persist` is configured in `doas.conf`, subsequent `doas` calls won't re-prompt.
506///   Without `persist`, each `doas` invocation will re-prompt (known limitation).
507/// - The caller is responsible for ensuring the terminal is in a state where the user
508///   can interact with the prompt (e.g. not in TUI raw mode).
509pub fn run_interactive_auth(tool: PrivilegeTool) -> Result<bool, String> {
510    if is_integration_test_context() {
511        tracing::debug!(tool = %tool, "Skipping interactive auth in integration test context");
512        return Ok(true);
513    }
514
515    let mut cmd = Command::new(tool.binary_name());
516    if tool.capabilities().supports_credential_refresh {
517        cmd.arg("-v");
518    } else {
519        cmd.arg("true");
520    }
521
522    let status = cmd.status().map_err(|e| {
523        format!(
524            "Failed to run {} for interactive authentication: {e}",
525            tool.binary_name()
526        )
527    })?;
528
529    Ok(status.success())
530}
531
532// ---------------------------------------------------------------------------
533// Fingerprint / PAM detection
534// ---------------------------------------------------------------------------
535
536/// What: Detect whether the active privilege tool's PAM configuration includes `pam_fprintd`.
537///
538/// Inputs:
539/// - `tool`: Resolved privilege tool (sudo or doas).
540///
541/// Output:
542/// - `true` if `/etc/pam.d/{tool}` exists and contains a reference to `pam_fprintd`.
543///
544/// Details:
545/// - Reads `/etc/pam.d/sudo` or `/etc/pam.d/doas` and checks for `pam_fprintd.so`.
546/// - Also checks `/etc/pam.d/system-auth` and `/etc/pam.d/system-local-login` as common
547///   include targets where `pam_fprintd` may be configured instead of the tool-specific file.
548/// - Informational only — never blocks execution.
549/// - Returns `false` on any I/O error (missing file, permission denied).
550pub fn detect_pam_fingerprint(tool: PrivilegeTool) -> bool {
551    use std::fs;
552
553    let tool_pam_path = format!("/etc/pam.d/{}", tool.binary_name());
554    let pam_files = [
555        tool_pam_path.as_str(),
556        "/etc/pam.d/system-auth",
557        "/etc/pam.d/system-local-login",
558    ];
559
560    for path in &pam_files {
561        if let Ok(contents) = fs::read_to_string(path)
562            && contents.contains("pam_fprintd")
563        {
564            tracing::debug!(path = %path, "Detected pam_fprintd in PAM configuration");
565            return true;
566        }
567    }
568
569    false
570}
571
572/// What: Check whether a fingerprint reader is enrolled via `fprintd-list`.
573///
574/// Inputs:
575/// - None.
576///
577/// Output:
578/// - `true` if `fprintd-list` reports at least one enrolled finger.
579///
580/// Details:
581/// - Runs `fprintd-list $USER` and checks the output for enrolled fingerprint entries.
582/// - Returns `false` if `fprintd-list` is not installed, the command fails, or no fingers
583///   are enrolled.
584/// - Does not require root; `fprintd-list` reads enrollment data via D-Bus.
585/// - Informational only — never blocks execution.
586pub fn detect_fprintd_enrolled() -> bool {
587    let username = std::env::var("USER").unwrap_or_default();
588    if username.is_empty() {
589        return false;
590    }
591
592    let output = Command::new("fprintd-list").arg(&username).output();
593
594    match output {
595        Ok(out) if out.status.success() => {
596            let stdout = String::from_utf8_lossy(&out.stdout);
597            let has_finger = stdout.lines().any(|line| {
598                let trimmed = line.trim().to_lowercase();
599                trimmed.contains("left-") || trimmed.contains("right-")
600            });
601            if has_finger {
602                tracing::debug!("fprintd-list reports enrolled fingerprint(s)");
603            }
604            has_finger
605        }
606        _ => false,
607    }
608}
609
610/// What: Cached result of fingerprint availability detection.
611///
612/// Details:
613/// - Combines PAM configuration check and `fprintd-list` enrollment check.
614/// - Cached via `OnceLock` since fingerprint availability doesn't change during a session.
615static FINGERPRINT_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
616
617/// What: Check whether fingerprint authentication appears to be available.
618///
619/// Inputs:
620/// - None (uses the active privilege tool from settings).
621///
622/// Output:
623/// - `true` if both PAM fingerprint integration and an enrolled finger are detected.
624///
625/// Details:
626/// - Result is cached for the lifetime of the process (checked once, never re-checked).
627/// - Requires both `pam_fprintd` in the tool's PAM stack AND at least one enrolled finger.
628/// - Informational only — used to show a hint in the password modal, never gates execution.
629#[must_use]
630pub fn is_fingerprint_available() -> bool {
631    *FINGERPRINT_AVAILABLE.get_or_init(|| {
632        let Ok(tool) = active_tool() else {
633            return false;
634        };
635
636        let pam_configured = detect_pam_fingerprint(tool);
637        if !pam_configured {
638            tracing::debug!(tool = %tool, "No pam_fprintd in PAM config for tool");
639            return false;
640        }
641
642        let enrolled = detect_fprintd_enrolled();
643        if !enrolled {
644            tracing::debug!("pam_fprintd configured but no enrolled fingerprints found");
645        }
646        enrolled
647    })
648}
649
650// ---------------------------------------------------------------------------
651// Command builders
652// ---------------------------------------------------------------------------
653
654/// What: Build a privilege-escalated command string.
655///
656/// Inputs:
657/// - `tool`: Resolved privilege tool.
658/// - `command`: The unprivileged command to wrap.
659///
660/// Output: Shell string like `"sudo pacman -S foo"` or `"doas pacman -S foo"`.
661///
662/// Details: Simple prefix — does not handle password piping.
663#[must_use]
664pub fn build_privilege_command(tool: PrivilegeTool, command: &str) -> String {
665    format!("{} {command}", tool.binary_name())
666}
667
668/// What: Build a command that pipes a password to the privilege tool via stdin.
669///
670/// Inputs:
671/// - `tool`: Resolved privilege tool.
672/// - `password`: Cleartext password.
673/// - `command`: The unprivileged command to wrap.
674///
675/// Output:
676/// - `Some(cmd)` for tools that support stdin password (sudo).
677/// - `None` for tools that do not (doas).
678///
679/// Details:
680/// - Uses `shell_single_quote` for safe password escaping.
681/// - Only sudo supports `-S` (read password from stdin).
682#[must_use]
683pub fn build_password_pipe(tool: PrivilegeTool, password: &str, command: &str) -> Option<String> {
684    if !tool.capabilities().supports_stdin_password {
685        return None;
686    }
687    let escaped = crate::install::shell_single_quote(password);
688    Some(format!(
689        "printf '%s\\n' {escaped} | {} -S {command}",
690        tool.binary_name()
691    ))
692}
693
694/// What: Build a credential warm-up command that caches the password.
695///
696/// Inputs:
697/// - `tool`: Resolved privilege tool.
698/// - `password`: Cleartext password.
699///
700/// Output:
701/// - `Some(cmd)` for tools that support credential refresh (sudo).
702/// - `None` for tools that do not (doas).
703///
704/// Details:
705/// - For sudo: `printf '%s\n' '<pass>' | sudo -S -v 2>/dev/null`
706/// - Warms up the credential cache so subsequent sudo calls don't re-prompt.
707#[must_use]
708pub fn build_credential_warmup(tool: PrivilegeTool, password: &str) -> Option<String> {
709    if !tool.capabilities().supports_credential_refresh {
710        return None;
711    }
712    let escaped = crate::install::shell_single_quote(password);
713    Some(format!(
714        "printf '%s\\n' {escaped} | {} -S -v 2>/dev/null",
715        tool.binary_name()
716    ))
717}
718
719/// What: Build a credential invalidation command.
720///
721/// Inputs:
722/// - `tool`: Resolved privilege tool.
723///
724/// Output:
725/// - `Some(cmd)` for tools that support credential invalidation (sudo).
726/// - `None` for tools that do not (doas).
727///
728/// Details:
729/// - For sudo: `sudo -k` invalidates cached credentials.
730/// - doas has no credential cache to invalidate.
731#[must_use]
732pub fn build_credential_invalidation(tool: PrivilegeTool) -> Option<String> {
733    if !tool.capabilities().supports_credential_invalidation {
734        return None;
735    }
736    Some(format!("{} -k", tool.binary_name()))
737}
738
739/// What: Validate a password against the privilege tool.
740///
741/// Inputs:
742/// - `tool`: Resolved privilege tool.
743/// - `password`: Password to validate.
744///
745/// Output:
746/// - `Ok(true)` if valid, `Ok(false)` if invalid.
747/// - `Err` if the tool doesn't support stdin password or the check fails.
748///
749/// # Errors
750///
751/// - Returns `Err` if the tool does not support stdin password validation.
752/// - Returns `Err` if the validation command cannot be executed.
753///
754/// Details:
755/// - Only works for tools with `supports_stdin_password` (currently sudo only).
756/// - First invalidates cached credentials, then tests the password.
757pub fn validate_password(tool: PrivilegeTool, password: &str) -> Result<bool, String> {
758    if !tool.capabilities().supports_stdin_password {
759        return Err(format!(
760            "{tool} does not support password validation via stdin. \
761             Configure passwordless {tool} or switch to sudo in settings.conf."
762        ));
763    }
764
765    let escaped = crate::install::shell_single_quote(password);
766    let bin = tool.binary_name();
767    let cmd = format!("{bin} -k ; printf '%s\\n' {escaped} | {bin} -S -v 2>&1");
768
769    let output = Command::new("sh")
770        .arg("-c")
771        .arg(&cmd)
772        .output()
773        .map_err(|e| format!("Failed to execute {bin} validation: {e}"))?;
774
775    Ok(output.status.success())
776}
777
778// ---------------------------------------------------------------------------
779// Internal helpers
780// ---------------------------------------------------------------------------
781
782/// What: Returns true only when running in integration test context.
783///
784/// Inputs: None (reads env var `PACSEA_INTEGRATION_TEST`).
785///
786/// Output: `true` if `PACSEA_INTEGRATION_TEST=1` is set, `false` otherwise.
787///
788/// Details: Guards test-only env overrides so production never honors them.
789fn is_integration_test_context() -> bool {
790    if !std::env::var("PACSEA_INTEGRATION_TEST").is_ok_and(|v| v == "1") {
791        return false;
792    }
793
794    // Keep test-only overrides available in integration tests even when they run in
795    // release mode (`cargo test --release`), while preventing normal release app runs
796    // from honoring these bypass env vars.
797    #[cfg(debug_assertions)]
798    {
799        true
800    }
801
802    #[cfg(not(debug_assertions))]
803    {
804        is_running_cargo_test_binary()
805    }
806}
807
808/// What: Determine whether the current executable is a Cargo-generated test binary.
809///
810/// Inputs:
811/// - None.
812///
813/// Output:
814/// - `true` when running from `target/*/deps/*` test artifacts, otherwise `false`.
815///
816/// Details:
817/// - Integration tests compile the library without `cfg(test)`, so this runtime check keeps
818///   `PACSEA_INTEGRATION_TEST` overrides functional for `cargo test --release`.
819/// - Normal `cargo run --release` / installed binaries do not execute from `target/*/deps`.
820#[cfg(not(debug_assertions))]
821fn is_running_cargo_test_binary() -> bool {
822    let Ok(exe) = std::env::current_exe() else {
823        return false;
824    };
825    exe.components()
826        .any(|component| component.as_os_str() == "deps")
827}
828
829/// What: Public wrapper for [`is_integration_test_context`].
830///
831/// Inputs: None.
832///
833/// Output: `true` when the process is running inside the integration test harness.
834///
835/// Details: Exposed so `password.rs` can gate test overrides.
836#[must_use]
837pub fn is_integration_test() -> bool {
838    is_integration_test_context()
839}
840
841// ---------------------------------------------------------------------------
842// Tests
843// ---------------------------------------------------------------------------
844
845#[cfg(test)]
846mod tests {
847    use super::*;
848
849    // -- PrivilegeTool -------------------------------------------------------
850
851    #[test]
852    fn tool_binary_name_sudo() {
853        assert_eq!(PrivilegeTool::Sudo.binary_name(), "sudo");
854    }
855
856    #[test]
857    fn tool_binary_name_doas() {
858        assert_eq!(PrivilegeTool::Doas.binary_name(), "doas");
859    }
860
861    #[test]
862    fn tool_display_matches_binary_name() {
863        assert_eq!(format!("{}", PrivilegeTool::Sudo), "sudo");
864        assert_eq!(format!("{}", PrivilegeTool::Doas), "doas");
865    }
866
867    #[test]
868    fn sudo_capabilities_all_enabled() {
869        let caps = PrivilegeTool::Sudo.capabilities();
870        assert!(caps.supports_stdin_password);
871        assert!(caps.supports_credential_refresh);
872        assert!(caps.supports_credential_invalidation);
873        assert!(caps.supports_askpass);
874    }
875
876    #[test]
877    fn doas_capabilities_all_disabled() {
878        let caps = PrivilegeTool::Doas.capabilities();
879        assert!(!caps.supports_stdin_password);
880        assert!(!caps.supports_credential_refresh);
881        assert!(!caps.supports_credential_invalidation);
882        assert!(!caps.supports_askpass);
883    }
884
885    // -- PrivilegeMode -------------------------------------------------------
886
887    #[test]
888    fn mode_from_config_key_valid() {
889        assert_eq!(
890            PrivilegeMode::from_config_key("auto"),
891            Some(PrivilegeMode::Auto)
892        );
893        assert_eq!(
894            PrivilegeMode::from_config_key("sudo"),
895            Some(PrivilegeMode::Sudo)
896        );
897        assert_eq!(
898            PrivilegeMode::from_config_key("doas"),
899            Some(PrivilegeMode::Doas)
900        );
901    }
902
903    #[test]
904    fn mode_from_config_key_case_insensitive() {
905        assert_eq!(
906            PrivilegeMode::from_config_key("AUTO"),
907            Some(PrivilegeMode::Auto)
908        );
909        assert_eq!(
910            PrivilegeMode::from_config_key("Sudo"),
911            Some(PrivilegeMode::Sudo)
912        );
913        assert_eq!(
914            PrivilegeMode::from_config_key("DOAS"),
915            Some(PrivilegeMode::Doas)
916        );
917    }
918
919    #[test]
920    fn mode_from_config_key_with_whitespace() {
921        assert_eq!(
922            PrivilegeMode::from_config_key("  auto  "),
923            Some(PrivilegeMode::Auto)
924        );
925    }
926
927    #[test]
928    fn mode_from_config_key_invalid() {
929        assert_eq!(PrivilegeMode::from_config_key(""), None);
930        assert_eq!(PrivilegeMode::from_config_key("su"), None);
931        assert_eq!(PrivilegeMode::from_config_key("runas"), None);
932    }
933
934    #[test]
935    fn mode_as_config_key_roundtrip() {
936        for mode in [
937            PrivilegeMode::Auto,
938            PrivilegeMode::Sudo,
939            PrivilegeMode::Doas,
940        ] {
941            let key = mode.as_config_key();
942            assert_eq!(PrivilegeMode::from_config_key(key), Some(mode));
943        }
944    }
945
946    #[test]
947    fn mode_default_is_auto() {
948        assert_eq!(PrivilegeMode::default(), PrivilegeMode::Auto);
949    }
950
951    #[test]
952    fn mode_display() {
953        assert_eq!(format!("{}", PrivilegeMode::Auto), "auto");
954        assert_eq!(format!("{}", PrivilegeMode::Sudo), "sudo");
955        assert_eq!(format!("{}", PrivilegeMode::Doas), "doas");
956    }
957
958    // -- AuthMode -------------------------------------------------------
959
960    #[test]
961    fn auth_mode_from_config_key_valid() {
962        assert_eq!(AuthMode::from_config_key("prompt"), Some(AuthMode::Prompt));
963        assert_eq!(
964            AuthMode::from_config_key("passwordless_only"),
965            Some(AuthMode::PasswordlessOnly)
966        );
967        assert_eq!(
968            AuthMode::from_config_key("interactive"),
969            Some(AuthMode::Interactive)
970        );
971    }
972
973    #[test]
974    fn auth_mode_from_config_key_case_insensitive() {
975        assert_eq!(AuthMode::from_config_key("PROMPT"), Some(AuthMode::Prompt));
976        assert_eq!(
977            AuthMode::from_config_key("Interactive"),
978            Some(AuthMode::Interactive)
979        );
980        assert_eq!(
981            AuthMode::from_config_key("PASSWORDLESS_ONLY"),
982            Some(AuthMode::PasswordlessOnly)
983        );
984    }
985
986    #[test]
987    fn auth_mode_from_config_key_hyphen_alias() {
988        assert_eq!(
989            AuthMode::from_config_key("passwordless-only"),
990            Some(AuthMode::PasswordlessOnly)
991        );
992    }
993
994    #[test]
995    fn auth_mode_from_config_key_short_alias() {
996        assert_eq!(
997            AuthMode::from_config_key("passwordless"),
998            Some(AuthMode::PasswordlessOnly)
999        );
1000    }
1001
1002    #[test]
1003    fn auth_mode_from_config_key_with_whitespace() {
1004        assert_eq!(
1005            AuthMode::from_config_key("  interactive  "),
1006            Some(AuthMode::Interactive)
1007        );
1008    }
1009
1010    #[test]
1011    fn auth_mode_from_config_key_invalid() {
1012        assert_eq!(AuthMode::from_config_key(""), None);
1013        assert_eq!(AuthMode::from_config_key("fingerprint"), None);
1014        assert_eq!(AuthMode::from_config_key("password"), None);
1015    }
1016
1017    #[test]
1018    fn auth_mode_as_config_key_roundtrip() {
1019        for mode in [
1020            AuthMode::Prompt,
1021            AuthMode::PasswordlessOnly,
1022            AuthMode::Interactive,
1023        ] {
1024            let key = mode.as_config_key();
1025            assert_eq!(AuthMode::from_config_key(key), Some(mode));
1026        }
1027    }
1028
1029    #[test]
1030    fn auth_mode_default_is_prompt() {
1031        assert_eq!(AuthMode::default(), AuthMode::Prompt);
1032    }
1033
1034    #[test]
1035    fn auth_mode_display() {
1036        assert_eq!(format!("{}", AuthMode::Prompt), "prompt");
1037        assert_eq!(
1038            format!("{}", AuthMode::PasswordlessOnly),
1039            "passwordless_only"
1040        );
1041        assert_eq!(format!("{}", AuthMode::Interactive), "interactive");
1042    }
1043
1044    #[test]
1045    fn auth_mode_always_skips_password_modal() {
1046        assert!(!AuthMode::Prompt.always_skips_password_modal());
1047        assert!(!AuthMode::PasswordlessOnly.always_skips_password_modal());
1048        assert!(AuthMode::Interactive.always_skips_password_modal());
1049    }
1050
1051    // -- Resolver (env-controlled) -------------------------------------------
1052
1053    #[test]
1054    fn resolve_auto_prefers_doas_when_both_available() {
1055        let _guard = crate::global_test_mutex_lock();
1056        unsafe {
1057            std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1058            std::env::set_var("PACSEA_TEST_PRIVILEGE_AVAILABLE", "sudo,doas");
1059        }
1060        let result = resolve_privilege_tool(PrivilegeMode::Auto);
1061        unsafe {
1062            std::env::remove_var("PACSEA_TEST_PRIVILEGE_AVAILABLE");
1063            std::env::remove_var("PACSEA_INTEGRATION_TEST");
1064        }
1065        assert_eq!(result, Ok(PrivilegeTool::Doas));
1066    }
1067
1068    #[test]
1069    fn resolve_auto_falls_back_to_sudo() {
1070        let _guard = crate::global_test_mutex_lock();
1071        unsafe {
1072            std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1073            std::env::set_var("PACSEA_TEST_PRIVILEGE_AVAILABLE", "sudo");
1074        }
1075        let result = resolve_privilege_tool(PrivilegeMode::Auto);
1076        unsafe {
1077            std::env::remove_var("PACSEA_TEST_PRIVILEGE_AVAILABLE");
1078            std::env::remove_var("PACSEA_INTEGRATION_TEST");
1079        }
1080        assert_eq!(result, Ok(PrivilegeTool::Sudo));
1081    }
1082
1083    #[test]
1084    fn resolve_auto_fails_when_none_available() {
1085        let _guard = crate::global_test_mutex_lock();
1086        unsafe {
1087            std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1088            std::env::set_var("PACSEA_TEST_PRIVILEGE_AVAILABLE", "none");
1089        }
1090        let result = resolve_privilege_tool(PrivilegeMode::Auto);
1091        unsafe {
1092            std::env::remove_var("PACSEA_TEST_PRIVILEGE_AVAILABLE");
1093            std::env::remove_var("PACSEA_INTEGRATION_TEST");
1094        }
1095        let err = result.expect_err("should fail when no tool available");
1096        assert!(err.contains("Neither doas nor sudo found"));
1097    }
1098
1099    #[test]
1100    fn resolve_explicit_sudo_succeeds() {
1101        let _guard = crate::global_test_mutex_lock();
1102        unsafe {
1103            std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1104            std::env::set_var("PACSEA_TEST_PRIVILEGE_AVAILABLE", "sudo");
1105        }
1106        let result = resolve_privilege_tool(PrivilegeMode::Sudo);
1107        unsafe {
1108            std::env::remove_var("PACSEA_TEST_PRIVILEGE_AVAILABLE");
1109            std::env::remove_var("PACSEA_INTEGRATION_TEST");
1110        }
1111        assert_eq!(result, Ok(PrivilegeTool::Sudo));
1112    }
1113
1114    #[test]
1115    fn resolve_explicit_sudo_fails_when_missing() {
1116        let _guard = crate::global_test_mutex_lock();
1117        unsafe {
1118            std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1119            std::env::set_var("PACSEA_TEST_PRIVILEGE_AVAILABLE", "doas");
1120        }
1121        let result = resolve_privilege_tool(PrivilegeMode::Sudo);
1122        unsafe {
1123            std::env::remove_var("PACSEA_TEST_PRIVILEGE_AVAILABLE");
1124            std::env::remove_var("PACSEA_INTEGRATION_TEST");
1125        }
1126        assert!(result.is_err());
1127        let err = result.expect_err("should fail when sudo unavailable");
1128        assert!(err.contains("sudo is not available"));
1129    }
1130
1131    #[test]
1132    fn resolve_explicit_doas_succeeds() {
1133        let _guard = crate::global_test_mutex_lock();
1134        unsafe {
1135            std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1136            std::env::set_var("PACSEA_TEST_PRIVILEGE_AVAILABLE", "doas");
1137        }
1138        let result = resolve_privilege_tool(PrivilegeMode::Doas);
1139        unsafe {
1140            std::env::remove_var("PACSEA_TEST_PRIVILEGE_AVAILABLE");
1141            std::env::remove_var("PACSEA_INTEGRATION_TEST");
1142        }
1143        assert_eq!(result, Ok(PrivilegeTool::Doas));
1144    }
1145
1146    #[test]
1147    fn resolve_explicit_doas_fails_when_missing() {
1148        let _guard = crate::global_test_mutex_lock();
1149        unsafe {
1150            std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1151            std::env::set_var("PACSEA_TEST_PRIVILEGE_AVAILABLE", "sudo");
1152        }
1153        let result = resolve_privilege_tool(PrivilegeMode::Doas);
1154        unsafe {
1155            std::env::remove_var("PACSEA_TEST_PRIVILEGE_AVAILABLE");
1156            std::env::remove_var("PACSEA_INTEGRATION_TEST");
1157        }
1158        assert!(result.is_err());
1159        let err = result.expect_err("should fail when doas unavailable");
1160        assert!(err.contains("doas is not available"));
1161    }
1162
1163    // -- Command builders ----------------------------------------------------
1164
1165    #[test]
1166    fn build_privilege_command_sudo() {
1167        let cmd = build_privilege_command(PrivilegeTool::Sudo, "pacman -S foo");
1168        assert_eq!(cmd, "sudo pacman -S foo");
1169    }
1170
1171    #[test]
1172    fn build_privilege_command_doas() {
1173        let cmd = build_privilege_command(PrivilegeTool::Doas, "pacman -S foo");
1174        assert_eq!(cmd, "doas pacman -S foo");
1175    }
1176
1177    #[test]
1178    fn build_password_pipe_sudo_returns_some() {
1179        let result = build_password_pipe(PrivilegeTool::Sudo, "secret", "pacman -S foo");
1180        let cmd = result.expect("sudo should support password pipe");
1181        assert!(cmd.contains("printf "));
1182        assert!(cmd.contains("sudo -S pacman -S foo"));
1183    }
1184
1185    #[test]
1186    fn build_password_pipe_doas_returns_none() {
1187        let result = build_password_pipe(PrivilegeTool::Doas, "secret", "pacman -S foo");
1188        assert!(result.is_none());
1189    }
1190
1191    #[test]
1192    fn build_credential_warmup_sudo_returns_some() {
1193        let result = build_credential_warmup(PrivilegeTool::Sudo, "secret");
1194        let cmd = result.expect("sudo should support credential warmup");
1195        assert!(cmd.contains("sudo -S -v"));
1196    }
1197
1198    #[test]
1199    fn build_credential_warmup_doas_returns_none() {
1200        let result = build_credential_warmup(PrivilegeTool::Doas, "secret");
1201        assert!(result.is_none());
1202    }
1203
1204    #[test]
1205    fn build_credential_invalidation_sudo_returns_some() {
1206        let cmd = build_credential_invalidation(PrivilegeTool::Sudo)
1207            .expect("sudo should support credential invalidation");
1208        assert_eq!(cmd, "sudo -k");
1209    }
1210
1211    #[test]
1212    fn build_credential_invalidation_doas_returns_none() {
1213        let result = build_credential_invalidation(PrivilegeTool::Doas);
1214        assert!(result.is_none());
1215    }
1216
1217    // -- Password validation -------------------------------------------------
1218
1219    #[test]
1220    fn validate_password_doas_returns_err() {
1221        let result = validate_password(PrivilegeTool::Doas, "any");
1222        let err = result.expect_err("doas should not support password validation");
1223        assert!(err.contains("does not support"));
1224    }
1225
1226    // -- Availability (env-controlled) ---------------------------------------
1227
1228    #[test]
1229    fn is_available_test_override_none() {
1230        let _guard = crate::global_test_mutex_lock();
1231        unsafe {
1232            std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1233            std::env::set_var("PACSEA_TEST_PRIVILEGE_AVAILABLE", "none");
1234        }
1235        assert!(!PrivilegeTool::Sudo.is_available());
1236        assert!(!PrivilegeTool::Doas.is_available());
1237        unsafe {
1238            std::env::remove_var("PACSEA_TEST_PRIVILEGE_AVAILABLE");
1239            std::env::remove_var("PACSEA_INTEGRATION_TEST");
1240        }
1241    }
1242
1243    #[test]
1244    fn is_available_test_override_sudo_only() {
1245        let _guard = crate::global_test_mutex_lock();
1246        unsafe {
1247            std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1248            std::env::set_var("PACSEA_TEST_PRIVILEGE_AVAILABLE", "sudo");
1249        }
1250        assert!(PrivilegeTool::Sudo.is_available());
1251        assert!(!PrivilegeTool::Doas.is_available());
1252        unsafe {
1253            std::env::remove_var("PACSEA_TEST_PRIVILEGE_AVAILABLE");
1254            std::env::remove_var("PACSEA_INTEGRATION_TEST");
1255        }
1256    }
1257
1258    #[test]
1259    fn is_available_test_override_both() {
1260        let _guard = crate::global_test_mutex_lock();
1261        unsafe {
1262            std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1263            std::env::set_var("PACSEA_TEST_PRIVILEGE_AVAILABLE", "sudo,doas");
1264        }
1265        assert!(PrivilegeTool::Sudo.is_available());
1266        assert!(PrivilegeTool::Doas.is_available());
1267        unsafe {
1268            std::env::remove_var("PACSEA_TEST_PRIVILEGE_AVAILABLE");
1269            std::env::remove_var("PACSEA_INTEGRATION_TEST");
1270        }
1271    }
1272
1273    // -- is_integration_test -------------------------------------------------
1274
1275    #[test]
1276    fn is_integration_test_when_set() {
1277        let _guard = crate::global_test_mutex_lock();
1278        unsafe {
1279            std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1280        }
1281        assert!(is_integration_test());
1282        unsafe {
1283            std::env::remove_var("PACSEA_INTEGRATION_TEST");
1284        }
1285    }
1286
1287    #[test]
1288    fn is_integration_test_when_unset() {
1289        let _guard = crate::global_test_mutex_lock();
1290        unsafe {
1291            std::env::remove_var("PACSEA_INTEGRATION_TEST");
1292        }
1293        assert!(!is_integration_test());
1294    }
1295
1296    #[test]
1297    fn is_integration_test_wrong_value() {
1298        let _guard = crate::global_test_mutex_lock();
1299        unsafe {
1300            std::env::set_var("PACSEA_INTEGRATION_TEST", "0");
1301        }
1302        assert!(!is_integration_test());
1303        unsafe {
1304            std::env::remove_var("PACSEA_INTEGRATION_TEST");
1305        }
1306    }
1307
1308    // -- active_tool ---------------------------------------------------------
1309
1310    #[test]
1311    fn active_tool_returns_ok_with_sudo_or_doas() {
1312        let tool = active_tool().expect("active_tool should resolve in this environment");
1313        assert!(
1314            tool == PrivilegeTool::Sudo || tool == PrivilegeTool::Doas,
1315            "active_tool should return Sudo or Doas"
1316        );
1317    }
1318
1319    #[test]
1320    fn active_tool_for_mode_explicit_doas_errors_when_missing() {
1321        let _guard = crate::global_test_mutex_lock();
1322        unsafe {
1323            std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1324            std::env::set_var("PACSEA_TEST_PRIVILEGE_AVAILABLE", "sudo");
1325        }
1326        let result = super::active_tool_for_mode(PrivilegeMode::Doas);
1327        unsafe {
1328            std::env::remove_var("PACSEA_TEST_PRIVILEGE_AVAILABLE");
1329            std::env::remove_var("PACSEA_INTEGRATION_TEST");
1330        }
1331        let err = result.expect_err("explicit doas without doas on PATH should error");
1332        assert!(err.contains("doas is not available"));
1333    }
1334
1335    #[test]
1336    fn active_tool_for_mode_explicit_sudo_errors_when_missing() {
1337        let _guard = crate::global_test_mutex_lock();
1338        unsafe {
1339            std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1340            std::env::set_var("PACSEA_TEST_PRIVILEGE_AVAILABLE", "doas");
1341        }
1342        let result = super::active_tool_for_mode(PrivilegeMode::Sudo);
1343        unsafe {
1344            std::env::remove_var("PACSEA_TEST_PRIVILEGE_AVAILABLE");
1345            std::env::remove_var("PACSEA_INTEGRATION_TEST");
1346        }
1347        let err = result.expect_err("explicit sudo without sudo on PATH should error");
1348        assert!(err.contains("sudo is not available"));
1349    }
1350
1351    #[test]
1352    fn active_tool_for_mode_auto_none_falls_back_to_sudo() {
1353        let _guard = crate::global_test_mutex_lock();
1354        unsafe {
1355            std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1356            std::env::set_var("PACSEA_TEST_PRIVILEGE_AVAILABLE", "none");
1357        }
1358        let result = super::active_tool_for_mode(PrivilegeMode::Auto);
1359        unsafe {
1360            std::env::remove_var("PACSEA_TEST_PRIVILEGE_AVAILABLE");
1361            std::env::remove_var("PACSEA_INTEGRATION_TEST");
1362        }
1363        assert_eq!(
1364            result,
1365            Ok(PrivilegeTool::Sudo),
1366            "Auto with no tools should still return Ok(Sudo) after fallback"
1367        );
1368    }
1369
1370    // -- Password pipe format ------------------------------------------------
1371
1372    #[test]
1373    fn build_password_pipe_uses_printf_not_echo() {
1374        let result = build_password_pipe(PrivilegeTool::Sudo, "pw", "cmd");
1375        let cmd = result.expect("sudo pipe should return Some");
1376        assert!(cmd.starts_with("printf "), "should use printf, not echo");
1377        assert!(cmd.contains("%s\\n"), "should use %s\\n format");
1378    }
1379
1380    #[test]
1381    fn build_password_pipe_escapes_special_chars() {
1382        let result = build_password_pipe(PrivilegeTool::Sudo, "pa's$word", "pacman -S foo");
1383        let cmd = result.expect("sudo pipe should return Some");
1384        assert!(cmd.contains("sudo -S pacman -S foo"));
1385        assert!(!cmd.contains("pa's$word"), "password must be shell-escaped");
1386    }
1387
1388    #[test]
1389    fn build_credential_warmup_uses_printf() {
1390        let result = build_credential_warmup(PrivilegeTool::Sudo, "pw");
1391        let cmd = result.expect("sudo warmup should return Some");
1392        assert!(cmd.starts_with("printf "), "warmup should use printf");
1393        assert!(cmd.contains("sudo -S -v"));
1394        assert!(cmd.contains("2>/dev/null"));
1395    }
1396
1397    // -- Doas command builders -----------------------------------------------
1398
1399    #[test]
1400    fn build_privilege_command_doas_format() {
1401        let cmd = build_privilege_command(PrivilegeTool::Doas, "pacman -Syu --noconfirm");
1402        assert_eq!(cmd, "doas pacman -Syu --noconfirm");
1403    }
1404
1405    #[test]
1406    fn doas_password_pipe_returns_none() {
1407        assert!(build_password_pipe(PrivilegeTool::Doas, "pw", "cmd").is_none());
1408    }
1409
1410    #[test]
1411    fn doas_credential_warmup_returns_none() {
1412        assert!(build_credential_warmup(PrivilegeTool::Doas, "pw").is_none());
1413    }
1414
1415    #[test]
1416    fn doas_credential_invalidation_returns_none() {
1417        assert!(build_credential_invalidation(PrivilegeTool::Doas).is_none());
1418    }
1419
1420    // -- Passwordless check --------------------------------------------------
1421
1422    #[test]
1423    fn check_passwordless_test_override_true() {
1424        let _guard = crate::global_test_mutex_lock();
1425        unsafe {
1426            std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1427            std::env::set_var("PACSEA_TEST_SUDO_PASSWORDLESS", "1");
1428        }
1429        let result = PrivilegeTool::Sudo.check_passwordless();
1430        unsafe {
1431            std::env::remove_var("PACSEA_TEST_SUDO_PASSWORDLESS");
1432            std::env::remove_var("PACSEA_INTEGRATION_TEST");
1433        }
1434        assert_eq!(result, Ok(true));
1435    }
1436
1437    #[test]
1438    fn check_passwordless_test_override_false() {
1439        let _guard = crate::global_test_mutex_lock();
1440        unsafe {
1441            std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1442            std::env::set_var("PACSEA_TEST_SUDO_PASSWORDLESS", "0");
1443        }
1444        let result = PrivilegeTool::Sudo.check_passwordless();
1445        unsafe {
1446            std::env::remove_var("PACSEA_TEST_SUDO_PASSWORDLESS");
1447            std::env::remove_var("PACSEA_INTEGRATION_TEST");
1448        }
1449        assert_eq!(result, Ok(false));
1450    }
1451
1452    #[test]
1453    fn check_passwordless_doas_test_override() {
1454        let _guard = crate::global_test_mutex_lock();
1455        unsafe {
1456            std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1457            std::env::set_var("PACSEA_TEST_SUDO_PASSWORDLESS", "1");
1458        }
1459        let result = PrivilegeTool::Doas.check_passwordless();
1460        unsafe {
1461            std::env::remove_var("PACSEA_TEST_SUDO_PASSWORDLESS");
1462            std::env::remove_var("PACSEA_INTEGRATION_TEST");
1463        }
1464        assert_eq!(result, Ok(true));
1465    }
1466
1467    // -- Validate password ---------------------------------------------------
1468
1469    #[test]
1470    fn validate_password_doas_returns_unsupported() {
1471        let result = validate_password(PrivilegeTool::Doas, "any");
1472        let err = result.expect_err("doas should not support password validation");
1473        assert!(
1474            err.contains("does not support"),
1475            "unexpected error message: {err}"
1476        );
1477    }
1478
1479    // -- Tool symmetry -------------------------------------------------------
1480
1481    #[test]
1482    fn both_tools_produce_distinct_commands() {
1483        let sudo_cmd = build_privilege_command(PrivilegeTool::Sudo, "pacman -S foo");
1484        let doas_cmd = build_privilege_command(PrivilegeTool::Doas, "pacman -S foo");
1485        assert_ne!(sudo_cmd, doas_cmd, "sudo and doas commands must differ");
1486        assert!(sudo_cmd.starts_with("sudo "));
1487        assert!(doas_cmd.starts_with("doas "));
1488    }
1489
1490    #[test]
1491    fn capabilities_are_complementary() {
1492        let sudo_caps = PrivilegeTool::Sudo.capabilities();
1493        let doas_caps = PrivilegeTool::Doas.capabilities();
1494        assert_ne!(
1495            sudo_caps.supports_stdin_password, doas_caps.supports_stdin_password,
1496            "sudo and doas should differ on stdin password support"
1497        );
1498    }
1499
1500    // -- Fingerprint detection ------------------------------------------------
1501
1502    #[test]
1503    fn detect_pam_fingerprint_sudo_does_not_panic() {
1504        // Returns true only if /etc/pam.d/sudo (or system-auth) has pam_fprintd.
1505        // In CI or most test machines this is false; we verify no panic.
1506        let _result = detect_pam_fingerprint(PrivilegeTool::Sudo);
1507    }
1508
1509    #[test]
1510    fn detect_pam_fingerprint_doas_does_not_panic() {
1511        let _result = detect_pam_fingerprint(PrivilegeTool::Doas);
1512    }
1513
1514    #[test]
1515    fn detect_fprintd_enrolled_does_not_panic() {
1516        let _result = detect_fprintd_enrolled();
1517    }
1518
1519    #[test]
1520    fn is_fingerprint_available_does_not_panic() {
1521        let _result = is_fingerprint_available();
1522    }
1523}