Skip to main content

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}