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}