Skip to main content

rusty_stack/installers/common/
askpass.rs

1//! `SUDO_ASKPASS` helper for non-interactive privileged operations.
2//!
3//! `sudo -n` (non-interactive) fails whenever a password is required and no
4//! NOPASSWD policy / cached timestamp covers it — which is the common case on a
5//! normal desktop. That breaks every privileged step (`/opt/rocm` removal,
6//! package-manager purge, and — on Arch — `yay`'s internal `sudo pacman`).
7//!
8//! This module writes the caller-supplied password to a mode-`0600` file owned
9//! by the current user, next to a mode-`0700` askpass script that `cat`s it,
10//! inside a private temp directory. Assign the script path to `SUDO_ASKPASS`
11//! and run `sudo -A …` (or, for `yay`, `yay --sudoflags=-A …`): sudo reads the
12//! password from the helper with no TTY and no interactive prompt.
13//!
14//! Notes:
15//! - `sudo` always honors `SUDO_ASKPASS` even under `env_reset`, and `-A`
16//!   selects the askpass path over a terminal prompt.
17//! - The password is never placed in a shell command line (no quoting risk);
18//!   it lives only in a root-unreadable, user-owned file for the operation's
19//!   duration. [`Askpass`] is an RAII guard that wipes both files on drop.
20//! - `yay` must run as the **user**, never under `sudo` (it refuses root for
21//!   AUR builds); its internal `sudo pacman` is what consumes the askpass
22//!   helper via `--sudoflags=-A`.
23
24use std::fs::{self, OpenOptions};
25use std::io::Write;
26#[cfg(unix)]
27use std::os::unix::fs::PermissionsExt;
28use std::path::PathBuf;
29use tempfile::TempDir;
30
31/// RAII askpass helper.
32///
33/// Holds a private temp directory (removed on drop) containing the password
34/// file (`pw`, `0600`) and the askpass script (`askpass.sh`, `0700`). Use
35/// [`Askpass::path`] as the value for `SUDO_ASKPASS`.
36pub struct Askpass {
37    // Kept alive so the dir (and its files) exist until the guard drops.
38    _dir: TempDir,
39    script_path: PathBuf,
40}
41
42impl Askpass {
43    /// Create an askpass helper for `password`.
44    ///
45    /// Returns a guard whose [`path`](Self::path) is the script to assign to
46    /// `SUDO_ASKPASS`. Both the password file and the script are wiped when
47    /// the guard is dropped.
48    pub fn new(password: &str) -> std::io::Result<Self> {
49        // Private dir (0700); tempfile's Builder already uses restrictive perms,
50        // set explicitly for clarity.
51        let mut builder = tempfile::Builder::new();
52        builder.prefix("mlstack-askpass-");
53        #[cfg(unix)]
54        builder.permissions(fs::Permissions::from_mode(0o700));
55        let dir = builder.tempdir()?;
56
57        // Password file — raw bytes, mode 0600. Written verbatim (no newline
58        // synthesis) so any password content is handled safely.
59        let pw_path = dir.path().join("pw");
60        {
61            let mut f = OpenOptions::new()
62                .write(true)
63                .create_new(true)
64                .open(&pw_path)?;
65            f.write_all(password.as_bytes())?;
66            #[cfg(unix)]
67            f.set_permissions(fs::Permissions::from_mode(0o600))?;
68        }
69
70        // Askpass script — `cat`s the sibling password file. The password
71        // never appears in the script and the path is quoted.
72        let script_path = dir.path().join("askpass.sh");
73        {
74            let mut f = OpenOptions::new()
75                .write(true)
76                .create_new(true)
77                .open(&script_path)?;
78            writeln!(f, "#!/bin/sh")?;
79            writeln!(f, "pw_dir=$(dirname \"$0\")")?;
80            writeln!(f, "exec cat \"$pw_dir/pw\"")?;
81            #[cfg(unix)]
82            f.set_permissions(fs::Permissions::from_mode(0o700))?;
83        }
84
85        Ok(Self {
86            _dir: dir,
87            script_path,
88        })
89    }
90
91    /// The script path to assign to `SUDO_ASKPASS`.
92    pub fn path(&self) -> &str {
93        self.script_path
94            .to_str()
95            .expect("tempdir paths are valid UTF-8")
96    }
97}
98
99#[cfg(all(test, unix))]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn script_cats_password_and_is_secure() {
105        let _env = crate::test_support::lock_env();
106        let helper = Askpass::new("s3cr!t'pass").unwrap();
107        let script = std::fs::read_to_string(helper.path()).unwrap();
108        assert!(script.contains("#!/bin/sh"));
109        assert!(script.contains("exec cat "));
110        // The password itself must NOT be embedded in the script.
111        assert!(!script.contains("s3cr"));
112
113        // Permissions: script 0700, sibling pw 0600.
114        use std::os::unix::fs::PermissionsExt;
115        let mode = std::fs::metadata(helper.path())
116            .unwrap()
117            .permissions()
118            .mode();
119        assert_eq!(mode & 0o777, 0o700);
120        let dir = std::path::Path::new(helper.path()).parent().unwrap();
121        let pw_mode = std::fs::metadata(dir.join("pw"))
122            .unwrap()
123            .permissions()
124            .mode();
125        assert_eq!(pw_mode & 0o777, 0o600);
126    }
127
128    #[test]
129    fn askpass_executes_and_returns_password() {
130        let _env = crate::test_support::lock_env();
131        // End-to-end: the script actually prints the password to stdout.
132        let helper = Askpass::new("hunter2").unwrap();
133        let out = std::process::Command::new(helper.path())
134            .output()
135            .expect("askpass script runs");
136        assert!(out.status.success(), "askpass exited non-zero");
137        assert_eq!(String::from_utf8_lossy(&out.stdout), "hunter2");
138    }
139}