zlayer_paths/escalate.rs
1//! Privilege escalation that works with or without a controlling terminal.
2//!
3//! `zlayer` daemon-management verbs need root (system service unit, launchd
4//! plist, SCM registration). When invoked from a real terminal we re-exec under
5//! `sudo -E`; when there is no TTY (a GUI launcher, a desktop tray, an IDE task)
6//! a bare `sudo` dies with "a terminal is required". This module bridges the gap
7//! by routing the headless case through the platform's graphical escalator:
8//! `sudo -A -E` with an osascript askpass on macOS, polkit on Linux (`pkexec`,
9//! or `sudo -A` against a graphical askpass), and UAC on Windows
10//! (`Start-Process -Verb RunAs`). The macOS native auth dialog
11//! (`osascript ... with administrator privileges`) remains a fallback for when
12//! `sudo` is unavailable.
13//!
14//! A graphical escalator is only attempted when something can actually render
15//! it: a real Aqua login session on macOS (`launchctl print gui/<uid>`), or an
16//! X11/Wayland display on Linux. Over SSH, in a container, or on a CI runner —
17//! no TTY and no GUI — a dialog has nowhere to draw, so we never pop one (it
18//! would hang). There we fall back to non-interactive `sudo -n` when sudo needs
19//! no password (passwordless sudoers or a warm timestamp), and otherwise fail
20//! fast with an actionable error rather than block on a prompt that can't appear.
21//!
22//! Caveat on macOS: a `sudo` spawned here does NOT inherit a timestamp a
23//! *parent* shell primed. With no controlling terminal the default
24//! `timestamp_type=tty` degrades to a per-parent-PID record, so our `sudo -A`
25//! (parented to this process) authenticates on its own and pops a dialog even if
26//! the calling installer just ran `sudo -v`. Callers that want a single prompt
27//! must do their privileged work under their own cached ticket, not lean on this
28//! path being a cache hit.
29//!
30//! Two layers are exposed:
31//! - [`root_command`] builds the program+args to run `argv` as root for a given
32//! interactivity, leaving the actual spawn (exec-replace, blocking, or async)
33//! to the caller.
34//! - [`run_as_root`] is the blocking convenience: detect the TTY, build, spawn,
35//! and hand back the child's [`ExitStatus`].
36
37use std::ffi::{OsStr, OsString};
38use std::io::IsTerminal;
39use std::process::ExitStatus;
40
41/// Ready-to-spawn invocation that runs the target command as root.
42pub struct RootCommand {
43 /// The escalator (or `sudo`) to launch.
44 pub program: OsString,
45 /// Its arguments, ending with the original `argv`.
46 pub args: Vec<OsString>,
47 /// Extra environment to set on the spawned process. Only the no-TTY macOS
48 /// `sudo -A` path uses this (to point `SUDO_ASKPASS` at our helper); empty
49 /// everywhere else.
50 pub env: Vec<(OsString, OsString)>,
51}
52
53/// Failure modes for [`root_command`] / [`run_as_root`].
54#[derive(Debug)]
55pub enum EscalationError {
56 /// No controlling terminal and no graphical escalator (polkit/askpass) to
57 /// fall back on.
58 NoGraphicalEscalator,
59 /// Spawning the escalation helper failed.
60 Spawn(std::io::Error),
61}
62
63impl std::fmt::Display for EscalationError {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 match self {
66 EscalationError::NoGraphicalEscalator => f.write_str(
67 "cannot escalate: no controlling terminal, no GUI session, and no \
68 non-interactive escalation (passwordless sudo / polkit) available — \
69 run in a terminal, as root, or configure passwordless sudo",
70 ),
71 EscalationError::Spawn(e) => write!(f, "failed to launch privilege escalation: {e}"),
72 }
73 }
74}
75
76impl std::error::Error for EscalationError {
77 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
78 match self {
79 EscalationError::Spawn(e) => Some(e),
80 EscalationError::NoGraphicalEscalator => None,
81 }
82 }
83}
84
85/// Quote a string for safe interpolation into a `/bin/sh` command line.
86#[must_use]
87pub fn shell_quote(s: &str) -> String {
88 format!("'{}'", s.replace('\'', "'\\''"))
89}
90
91/// Quote a string as an AppleScript string literal.
92#[must_use]
93pub fn applescript_quote(s: &str) -> String {
94 format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
95}
96
97/// Build the invocation that runs `argv` as root.
98///
99/// `interactive` should be true only when a controlling terminal is attached
100/// (see [`run_as_root`], which detects it). With a terminal we prefer an inline
101/// `sudo -E` prompt; without one we use the platform's graphical escalator.
102///
103/// Fails only on Linux when there is no terminal and neither `pkexec` nor a
104/// graphical askpass for `sudo -A` is available.
105pub fn root_command<S: AsRef<OsStr>>(
106 argv: &[S],
107 interactive: bool,
108) -> Result<RootCommand, EscalationError> {
109 let argv: Vec<OsString> = argv.iter().map(|s| s.as_ref().to_os_string()).collect();
110
111 #[cfg(unix)]
112 {
113 if interactive {
114 // Inline terminal prompt; `-E` keeps the user's env (ZLAYER_* etc.).
115 let mut args = vec![OsString::from("-E")];
116 args.extend(argv);
117 return Ok(RootCommand {
118 program: OsString::from("sudo"),
119 args,
120 env: Vec::new(),
121 });
122 }
123
124 #[cfg(target_os = "macos")]
125 {
126 // No TTY. An osascript dialog (the `sudo -A` askpass, or the native
127 // auth dialog) can only render inside a real Aqua login session — over
128 // SSH, in a container, or with no console user it has nowhere to draw
129 // and would error or hang. Gate the GUI escalators on one being present.
130 if macos_gui_session_available() {
131 // Prefer `sudo -A -E`: a single elevation that carries our env
132 // across. Don't assume it reuses a parent shell's primed timestamp
133 // — with no TTY the default `tty` timestamp keys per parent PID, so
134 // a `sudo` we parent here authenticates on its own (see the module
135 // docs). Fall back to the native auth dialog only when sudo is
136 // missing or unusable.
137 return Ok(macos_sudo(&argv).unwrap_or_else(|| macos_osascript(&argv)));
138 }
139 // Headless: no dialog can appear. Use non-interactive sudo if it needs
140 // no password (passwordless sudoers or a warm timestamp); otherwise
141 // there is no way to prompt, so fail fast rather than hang.
142 if sudo_noninteractive_available() {
143 return Ok(sudo_noninteractive(&argv));
144 }
145 Err(EscalationError::NoGraphicalEscalator)
146 }
147
148 #[cfg(not(target_os = "macos"))]
149 {
150 linux_graphical(&argv)
151 }
152 }
153
154 #[cfg(windows)]
155 {
156 let _ = interactive; // No sudo on Windows; always elevate via UAC.
157 Ok(windows_runas(&argv))
158 }
159
160 #[cfg(not(any(unix, windows)))]
161 {
162 let _ = (interactive, argv);
163 Err(EscalationError::NoGraphicalEscalator)
164 }
165}
166
167/// Run `argv` as root and return the child's exit status. Detects whether a
168/// terminal is attached and escalates accordingly. Unlike a `sudo` exec, this
169/// never replaces the current image — the graphical escalators spawn a fresh
170/// root process, so the caller gets the status back.
171pub fn run_as_root<S: AsRef<OsStr>>(argv: &[S]) -> Result<ExitStatus, EscalationError> {
172 let cmd = root_command(argv, std::io::stdin().is_terminal())?;
173 std::process::Command::new(&cmd.program)
174 .args(&cmd.args)
175 .envs(cmd.env.iter().map(|(k, v)| (k, v)))
176 .status()
177 .map_err(EscalationError::Spawn)
178}
179
180/// macOS: build `sudo -A -E <argv>` with a `SUDO_ASKPASS` pointing at our
181/// osascript helper. `-A` makes sudo invoke the askpass on a timestamp miss,
182/// and `-E` preserves the env (`ZLAYER_*`) so no manual re-export is needed.
183/// Returns `None` when `sudo` is not on PATH or the askpass can't be written,
184/// so the caller can fall back to the native dialog.
185#[cfg(target_os = "macos")]
186fn macos_sudo(argv: &[OsString]) -> Option<RootCommand> {
187 if !on_path("sudo") {
188 return None;
189 }
190 let askpass = macos_askpass().ok()?;
191 let mut args = vec![OsString::from("-A"), OsString::from("-E")];
192 args.extend(argv.iter().cloned());
193 Some(RootCommand {
194 program: OsString::from("sudo"),
195 args,
196 env: vec![(OsString::from("SUDO_ASKPASS"), askpass)],
197 })
198}
199
200/// Resolve the askpass helper `sudo -A` should drive. An inherited, executable
201/// `$SUDO_ASKPASS` (the dev installer exports one) wins as-is; otherwise we
202/// write a small osascript helper once to a stable per-user path under the data
203/// dir so repeated escalations reuse it instead of littering a temp file per
204/// call (the `exec` path in privilege.rs can't clean up after itself).
205#[cfg(target_os = "macos")]
206pub fn macos_askpass() -> std::io::Result<OsString> {
207 if let Some(existing) = std::env::var_os("SUDO_ASKPASS") {
208 if !existing.is_empty() && std::path::Path::new(&existing).is_file() {
209 return Ok(existing);
210 }
211 }
212
213 use std::os::unix::fs::PermissionsExt as _;
214
215 let dir = crate::ZLayerDirs::default_data_dir();
216 std::fs::create_dir_all(&dir)?;
217 let path = dir.join("askpass.sh");
218
219 // Pops the native password dialog; `text returned of result` is what sudo
220 // reads from stdout. Rewrite each time so a stale/edited copy self-heals.
221 const SCRIPT: &str = "#!/bin/sh\nexec osascript \
222-e 'display dialog \"ZLayer needs administrator access…\" default answer \"\" with hidden answer with title \"ZLayer\"' \
223-e 'text returned of result'\n";
224 std::fs::write(&path, SCRIPT)?;
225 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700))?;
226 Ok(path.into_os_string())
227}
228
229/// Whether an actual macOS GUI (Aqua) login session exists for the current user.
230///
231/// An `osascript ... display dialog` (the `sudo -A` askpass) or `with
232/// administrator privileges` prompt can only draw inside a logged-in Aqua
233/// session; over SSH, in a container, or with no console user it has nowhere to
234/// render and either errors or blocks. macOS materializes a per-user GUI launchd
235/// domain `gui/<uid>` exactly when that Aqua session is present, so `launchctl
236/// print gui/<uid>` exits 0 iff a usable GUI session exists — making it the
237/// reliable gate for whether a password dialog can appear at all. Returns false
238/// everywhere there is no such domain (the headless/SSH/CI case).
239#[cfg(target_os = "macos")]
240#[must_use]
241pub fn macos_gui_session_available() -> bool {
242 // SAFETY: `getuid` is always safe to call and is thread-safe.
243 let uid = unsafe { libc::getuid() };
244 std::process::Command::new("launchctl")
245 .args(["print", &format!("gui/{uid}")])
246 .stdout(std::process::Stdio::null())
247 .stderr(std::process::Stdio::null())
248 .status()
249 .is_ok_and(|s| s.success())
250}
251
252/// Env vars to carry into a graphical-root child. `with administrator
253/// privileges` runs under a clean root `/bin/sh` that inherits nothing, so the
254/// `ZLAYER_*` config (and `PATH`) `sudo -E` would have preserved must be
255/// re-exported explicitly.
256#[cfg(target_os = "macos")]
257fn forwarded_env() -> Vec<(String, String)> {
258 let mut env: Vec<(String, String)> = std::env::vars()
259 .filter(|(k, _)| k.starts_with("ZLAYER_"))
260 .collect();
261 if let Ok(path) = std::env::var("PATH") {
262 env.push((String::from("PATH"), path));
263 }
264 env
265}
266
267/// macOS: wrap `argv` in an AppleScript that re-exports the forwarded env and
268/// `exec`s the command, then runs it via the native administrator dialog.
269#[cfg(target_os = "macos")]
270fn macos_osascript(argv: &[OsString]) -> RootCommand {
271 use std::fmt::Write as _;
272
273 let mut script = String::new();
274 for (key, value) in forwarded_env() {
275 let _ = write!(script, "export {key}={}; ", shell_quote(&value));
276 }
277 script.push_str("exec");
278 for arg in argv {
279 script.push(' ');
280 script.push_str(&shell_quote(&arg.to_string_lossy()));
281 }
282
283 let apple = format!(
284 "do shell script {} with administrator privileges",
285 applescript_quote(&script)
286 );
287 RootCommand {
288 program: OsString::from("/usr/bin/osascript"),
289 args: vec![OsString::from("-e"), OsString::from(apple)],
290 env: Vec::new(),
291 }
292}
293
294/// Linux: prefer polkit (`pkexec`) or `sudo -A` against a graphical askpass —
295/// but ONLY when a display can actually render the prompt; then non-interactive
296/// `sudo -n` when no password is needed; else there is no headless path.
297#[cfg(all(unix, not(target_os = "macos")))]
298fn linux_graphical(argv: &[OsString]) -> Result<RootCommand, EscalationError> {
299 // An explicit `SUDO_ASKPASS` the caller pointed at a runnable helper works
300 // headlessly (it might be a CLI prompt), so honor it regardless of a display.
301 let have_askpass_env = std::env::var_os("SUDO_ASKPASS").is_some();
302
303 // pkexec's GUI agent and a graphical ssh-askpass both need an X11/Wayland
304 // display to draw on. With no TTY and no display (SSH / container / CI) they
305 // can't prompt, so don't route through them blindly — that hangs.
306 let graphical = has_graphical_display();
307
308 if graphical && on_path("pkexec") {
309 return Ok(RootCommand {
310 program: OsString::from("pkexec"),
311 args: argv.to_vec(),
312 env: Vec::new(),
313 });
314 }
315
316 if have_askpass_env || (graphical && known_askpass_present()) {
317 let mut args = vec![OsString::from("-A"), OsString::from("-E")];
318 args.extend(argv.iter().cloned());
319 return Ok(RootCommand {
320 program: OsString::from("sudo"),
321 args,
322 env: Vec::new(),
323 });
324 }
325
326 // No usable GUI escalator. Fall back to non-interactive sudo when it needs no
327 // password (passwordless sudoers or a warm timestamp); otherwise there is no
328 // way to prompt → fail fast with guidance instead of hanging.
329 if sudo_noninteractive_available() {
330 return Ok(sudo_noninteractive(argv));
331 }
332
333 Err(EscalationError::NoGraphicalEscalator)
334}
335
336/// Whether an X11/Wayland display is reachable — the prerequisite for any
337/// graphical escalator (`pkexec`'s GUI agent, a graphical `ssh-askpass`) to draw
338/// a prompt. False over SSH / in a container / on a headless runner.
339#[cfg(all(unix, not(target_os = "macos")))]
340fn has_graphical_display() -> bool {
341 let nonempty = |k: &str| std::env::var_os(k).is_some_and(|v| !v.is_empty());
342 nonempty("DISPLAY") || nonempty("WAYLAND_DISPLAY")
343}
344
345/// Whether `sudo` can run a command without prompting — passwordless sudoers or
346/// a still-warm timestamp. Probed with `sudo -n true`.
347#[cfg(unix)]
348fn sudo_noninteractive_available() -> bool {
349 on_path("sudo")
350 && std::process::Command::new("sudo")
351 .args(["-n", "true"])
352 .stdout(std::process::Stdio::null())
353 .stderr(std::process::Stdio::null())
354 .status()
355 .is_ok_and(|s| s.success())
356}
357
358/// Build `sudo -n -E <argv>`: non-interactive (never prompts; fails instead) and
359/// env-preserving so the user's `ZLAYER_*`/`PATH` flow through.
360#[cfg(unix)]
361fn sudo_noninteractive(argv: &[OsString]) -> RootCommand {
362 let mut args = vec![OsString::from("-n"), OsString::from("-E")];
363 args.extend(argv.iter().cloned());
364 RootCommand {
365 program: OsString::from("sudo"),
366 args,
367 env: Vec::new(),
368 }
369}
370
371/// Whether `name` resolves to a file on `PATH`.
372#[cfg(unix)]
373fn on_path(name: &str) -> bool {
374 let Some(paths) = std::env::var_os("PATH") else {
375 return false;
376 };
377 std::env::split_paths(&paths).any(|dir| dir.join(name).is_file())
378}
379
380/// Common graphical-askpass install locations `sudo -A` can drive.
381#[cfg(all(unix, not(target_os = "macos")))]
382fn known_askpass_present() -> bool {
383 const CANDIDATES: &[&str] = &[
384 "/usr/bin/ssh-askpass",
385 "/usr/libexec/openssh/ssh-askpass",
386 "/usr/lib/ssh/ssh-askpass",
387 ];
388 CANDIDATES.iter().any(|p| std::path::Path::new(p).exists())
389}
390
391/// Windows: relaunch `argv` elevated via UAC, blocking on its exit code.
392#[cfg(windows)]
393fn windows_runas(argv: &[OsString]) -> RootCommand {
394 let file = argv
395 .first()
396 .map(|p| p.to_string_lossy().replace('\'', "''"))
397 .unwrap_or_default();
398 let arg_list = argv[1..]
399 .iter()
400 .map(|a| format!("'{}'", a.to_string_lossy().replace('\'', "''")))
401 .collect::<Vec<_>>()
402 .join(",");
403
404 let ps = if arg_list.is_empty() {
405 format!(
406 "$p = Start-Process -FilePath '{file}' -Verb RunAs -WindowStyle Hidden -Wait -PassThru; exit $p.ExitCode"
407 )
408 } else {
409 format!(
410 "$p = Start-Process -FilePath '{file}' -ArgumentList {arg_list} -Verb RunAs -WindowStyle Hidden -Wait -PassThru; exit $p.ExitCode"
411 )
412 };
413
414 RootCommand {
415 program: OsString::from("powershell"),
416 args: vec![
417 OsString::from("-NoProfile"),
418 OsString::from("-NonInteractive"),
419 OsString::from("-Command"),
420 OsString::from(ps),
421 ],
422 env: Vec::new(),
423 }
424}