zlayer-paths 0.14.0

Centralized filesystem path resolution for ZLayer
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
//! Privilege escalation that works with or without a controlling terminal.
//!
//! `zlayer` daemon-management verbs need root (system service unit, launchd
//! plist, SCM registration). When invoked from a real terminal we re-exec under
//! `sudo -E`; when there is no TTY (a GUI launcher, a desktop tray, an IDE task)
//! a bare `sudo` dies with "a terminal is required". This module bridges the gap
//! by routing the headless case through the platform's graphical escalator:
//! `sudo -A -E` with an osascript askpass on macOS, polkit on Linux (`pkexec`,
//! or `sudo -A` against a graphical askpass), and UAC on Windows
//! (`Start-Process -Verb RunAs`). The macOS native auth dialog
//! (`osascript ... with administrator privileges`) remains a fallback for when
//! `sudo` is unavailable.
//!
//! A graphical escalator is only attempted when something can actually render
//! it: a real Aqua login session on macOS (`launchctl print gui/<uid>`), or an
//! X11/Wayland display on Linux. Over SSH, in a container, or on a CI runner —
//! no TTY and no GUI — a dialog has nowhere to draw, so we never pop one (it
//! would hang). There we fall back to non-interactive `sudo -n` when sudo needs
//! no password (passwordless sudoers or a warm timestamp), and otherwise fail
//! fast with an actionable error rather than block on a prompt that can't appear.
//!
//! Caveat on macOS: a `sudo` spawned here does NOT inherit a timestamp a
//! *parent* shell primed. With no controlling terminal the default
//! `timestamp_type=tty` degrades to a per-parent-PID record, so our `sudo -A`
//! (parented to this process) authenticates on its own and pops a dialog even if
//! the calling installer just ran `sudo -v`. Callers that want a single prompt
//! must do their privileged work under their own cached ticket, not lean on this
//! path being a cache hit.
//!
//! Two layers are exposed:
//! - [`root_command`] builds the program+args to run `argv` as root for a given
//!   interactivity, leaving the actual spawn (exec-replace, blocking, or async)
//!   to the caller.
//! - [`run_as_root`] is the blocking convenience: detect the TTY, build, spawn,
//!   and hand back the child's [`ExitStatus`].

use std::ffi::{OsStr, OsString};
use std::io::IsTerminal;
use std::process::ExitStatus;

/// Ready-to-spawn invocation that runs the target command as root.
pub struct RootCommand {
    /// The escalator (or `sudo`) to launch.
    pub program: OsString,
    /// Its arguments, ending with the original `argv`.
    pub args: Vec<OsString>,
    /// Extra environment to set on the spawned process. Only the no-TTY macOS
    /// `sudo -A` path uses this (to point `SUDO_ASKPASS` at our helper); empty
    /// everywhere else.
    pub env: Vec<(OsString, OsString)>,
}

/// Failure modes for [`root_command`] / [`run_as_root`].
#[derive(Debug)]
pub enum EscalationError {
    /// No controlling terminal and no graphical escalator (polkit/askpass) to
    /// fall back on.
    NoGraphicalEscalator,
    /// Spawning the escalation helper failed.
    Spawn(std::io::Error),
}

impl std::fmt::Display for EscalationError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            EscalationError::NoGraphicalEscalator => f.write_str(
                "cannot escalate: no controlling terminal, no GUI session, and no \
                 non-interactive escalation (passwordless sudo / polkit) available — \
                 run in a terminal, as root, or configure passwordless sudo",
            ),
            EscalationError::Spawn(e) => write!(f, "failed to launch privilege escalation: {e}"),
        }
    }
}

impl std::error::Error for EscalationError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            EscalationError::Spawn(e) => Some(e),
            EscalationError::NoGraphicalEscalator => None,
        }
    }
}

/// Quote a string for safe interpolation into a `/bin/sh` command line.
#[must_use]
pub fn shell_quote(s: &str) -> String {
    format!("'{}'", s.replace('\'', "'\\''"))
}

/// Quote a string as an AppleScript string literal.
#[must_use]
pub fn applescript_quote(s: &str) -> String {
    format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
}

/// Build the invocation that runs `argv` as root.
///
/// `interactive` should be true only when a controlling terminal is attached
/// (see [`run_as_root`], which detects it). With a terminal we prefer an inline
/// `sudo -E` prompt; without one we use the platform's graphical escalator.
///
/// Fails only on Linux when there is no terminal and neither `pkexec` nor a
/// graphical askpass for `sudo -A` is available.
pub fn root_command<S: AsRef<OsStr>>(
    argv: &[S],
    interactive: bool,
) -> Result<RootCommand, EscalationError> {
    let argv: Vec<OsString> = argv.iter().map(|s| s.as_ref().to_os_string()).collect();

    #[cfg(unix)]
    {
        if interactive {
            // Inline terminal prompt; `-E` keeps the user's env (ZLAYER_* etc.).
            let mut args = vec![OsString::from("-E")];
            args.extend(argv);
            return Ok(RootCommand {
                program: OsString::from("sudo"),
                args,
                env: Vec::new(),
            });
        }

        #[cfg(target_os = "macos")]
        {
            // No TTY. An osascript dialog (the `sudo -A` askpass, or the native
            // auth dialog) can only render inside a real Aqua login session — over
            // SSH, in a container, or with no console user it has nowhere to draw
            // and would error or hang. Gate the GUI escalators on one being present.
            if macos_gui_session_available() {
                // Prefer `sudo -A -E`: a single elevation that carries our env
                // across. Don't assume it reuses a parent shell's primed timestamp
                // — with no TTY the default `tty` timestamp keys per parent PID, so
                // a `sudo` we parent here authenticates on its own (see the module
                // docs). Fall back to the native auth dialog only when sudo is
                // missing or unusable.
                return Ok(macos_sudo(&argv).unwrap_or_else(|| macos_osascript(&argv)));
            }
            // Headless: no dialog can appear. Use non-interactive sudo if it needs
            // no password (passwordless sudoers or a warm timestamp); otherwise
            // there is no way to prompt, so fail fast rather than hang.
            if sudo_noninteractive_available() {
                return Ok(sudo_noninteractive(&argv));
            }
            Err(EscalationError::NoGraphicalEscalator)
        }

        #[cfg(not(target_os = "macos"))]
        {
            linux_graphical(&argv)
        }
    }

    #[cfg(windows)]
    {
        let _ = interactive; // No sudo on Windows; always elevate via UAC.
        Ok(windows_runas(&argv))
    }

    #[cfg(not(any(unix, windows)))]
    {
        let _ = (interactive, argv);
        Err(EscalationError::NoGraphicalEscalator)
    }
}

/// Run `argv` as root and return the child's exit status. Detects whether a
/// terminal is attached and escalates accordingly. Unlike a `sudo` exec, this
/// never replaces the current image — the graphical escalators spawn a fresh
/// root process, so the caller gets the status back.
pub fn run_as_root<S: AsRef<OsStr>>(argv: &[S]) -> Result<ExitStatus, EscalationError> {
    let cmd = root_command(argv, std::io::stdin().is_terminal())?;
    std::process::Command::new(&cmd.program)
        .args(&cmd.args)
        .envs(cmd.env.iter().map(|(k, v)| (k, v)))
        .status()
        .map_err(EscalationError::Spawn)
}

/// macOS: build `sudo -A -E <argv>` with a `SUDO_ASKPASS` pointing at our
/// osascript helper. `-A` makes sudo invoke the askpass on a timestamp miss,
/// and `-E` preserves the env (`ZLAYER_*`) so no manual re-export is needed.
/// Returns `None` when `sudo` is not on PATH or the askpass can't be written,
/// so the caller can fall back to the native dialog.
#[cfg(target_os = "macos")]
fn macos_sudo(argv: &[OsString]) -> Option<RootCommand> {
    if !on_path("sudo") {
        return None;
    }
    let askpass = macos_askpass().ok()?;
    let mut args = vec![OsString::from("-A"), OsString::from("-E")];
    args.extend(argv.iter().cloned());
    Some(RootCommand {
        program: OsString::from("sudo"),
        args,
        env: vec![(OsString::from("SUDO_ASKPASS"), askpass)],
    })
}

/// Resolve the askpass helper `sudo -A` should drive. An inherited, executable
/// `$SUDO_ASKPASS` (the dev installer exports one) wins as-is; otherwise we
/// write a small osascript helper once to a stable per-user path under the data
/// dir so repeated escalations reuse it instead of littering a temp file per
/// call (the `exec` path in privilege.rs can't clean up after itself).
#[cfg(target_os = "macos")]
pub fn macos_askpass() -> std::io::Result<OsString> {
    if let Some(existing) = std::env::var_os("SUDO_ASKPASS") {
        if !existing.is_empty() && std::path::Path::new(&existing).is_file() {
            return Ok(existing);
        }
    }

    use std::os::unix::fs::PermissionsExt as _;

    let dir = crate::ZLayerDirs::default_data_dir();
    std::fs::create_dir_all(&dir)?;
    let path = dir.join("askpass.sh");

    // Pops the native password dialog; `text returned of result` is what sudo
    // reads from stdout. Rewrite each time so a stale/edited copy self-heals.
    const SCRIPT: &str = "#!/bin/sh\nexec osascript \
-e 'display dialog \"ZLayer needs administrator access…\" default answer \"\" with hidden answer with title \"ZLayer\"' \
-e 'text returned of result'\n";
    std::fs::write(&path, SCRIPT)?;
    std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700))?;
    Ok(path.into_os_string())
}

/// Whether an actual macOS GUI (Aqua) login session exists for the current user.
///
/// An `osascript ... display dialog` (the `sudo -A` askpass) or `with
/// administrator privileges` prompt can only draw inside a logged-in Aqua
/// session; over SSH, in a container, or with no console user it has nowhere to
/// render and either errors or blocks. macOS materializes a per-user GUI launchd
/// domain `gui/<uid>` exactly when that Aqua session is present, so `launchctl
/// print gui/<uid>` exits 0 iff a usable GUI session exists — making it the
/// reliable gate for whether a password dialog can appear at all. Returns false
/// everywhere there is no such domain (the headless/SSH/CI case).
#[cfg(target_os = "macos")]
#[must_use]
pub fn macos_gui_session_available() -> bool {
    // SAFETY: `getuid` is always safe to call and is thread-safe.
    let uid = unsafe { libc::getuid() };
    std::process::Command::new("launchctl")
        .args(["print", &format!("gui/{uid}")])
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()
        .is_ok_and(|s| s.success())
}

/// Env vars to carry into a graphical-root child. `with administrator
/// privileges` runs under a clean root `/bin/sh` that inherits nothing, so the
/// `ZLAYER_*` config (and `PATH`) `sudo -E` would have preserved must be
/// re-exported explicitly.
#[cfg(target_os = "macos")]
fn forwarded_env() -> Vec<(String, String)> {
    let mut env: Vec<(String, String)> = std::env::vars()
        .filter(|(k, _)| k.starts_with("ZLAYER_"))
        .collect();
    if let Ok(path) = std::env::var("PATH") {
        env.push((String::from("PATH"), path));
    }
    env
}

/// macOS: wrap `argv` in an AppleScript that re-exports the forwarded env and
/// `exec`s the command, then runs it via the native administrator dialog.
#[cfg(target_os = "macos")]
fn macos_osascript(argv: &[OsString]) -> RootCommand {
    use std::fmt::Write as _;

    let mut script = String::new();
    for (key, value) in forwarded_env() {
        let _ = write!(script, "export {key}={}; ", shell_quote(&value));
    }
    script.push_str("exec");
    for arg in argv {
        script.push(' ');
        script.push_str(&shell_quote(&arg.to_string_lossy()));
    }

    let apple = format!(
        "do shell script {} with administrator privileges",
        applescript_quote(&script)
    );
    RootCommand {
        program: OsString::from("/usr/bin/osascript"),
        args: vec![OsString::from("-e"), OsString::from(apple)],
        env: Vec::new(),
    }
}

/// Linux: prefer polkit (`pkexec`) or `sudo -A` against a graphical askpass —
/// but ONLY when a display can actually render the prompt; then non-interactive
/// `sudo -n` when no password is needed; else there is no headless path.
#[cfg(all(unix, not(target_os = "macos")))]
fn linux_graphical(argv: &[OsString]) -> Result<RootCommand, EscalationError> {
    // An explicit `SUDO_ASKPASS` the caller pointed at a runnable helper works
    // headlessly (it might be a CLI prompt), so honor it regardless of a display.
    let have_askpass_env = std::env::var_os("SUDO_ASKPASS").is_some();

    // pkexec's GUI agent and a graphical ssh-askpass both need an X11/Wayland
    // display to draw on. With no TTY and no display (SSH / container / CI) they
    // can't prompt, so don't route through them blindly — that hangs.
    let graphical = has_graphical_display();

    if graphical && on_path("pkexec") {
        return Ok(RootCommand {
            program: OsString::from("pkexec"),
            args: argv.to_vec(),
            env: Vec::new(),
        });
    }

    if have_askpass_env || (graphical && known_askpass_present()) {
        let mut args = vec![OsString::from("-A"), OsString::from("-E")];
        args.extend(argv.iter().cloned());
        return Ok(RootCommand {
            program: OsString::from("sudo"),
            args,
            env: Vec::new(),
        });
    }

    // No usable GUI escalator. Fall back to non-interactive sudo when it needs no
    // password (passwordless sudoers or a warm timestamp); otherwise there is no
    // way to prompt → fail fast with guidance instead of hanging.
    if sudo_noninteractive_available() {
        return Ok(sudo_noninteractive(argv));
    }

    Err(EscalationError::NoGraphicalEscalator)
}

/// Whether an X11/Wayland display is reachable — the prerequisite for any
/// graphical escalator (`pkexec`'s GUI agent, a graphical `ssh-askpass`) to draw
/// a prompt. False over SSH / in a container / on a headless runner.
#[cfg(all(unix, not(target_os = "macos")))]
fn has_graphical_display() -> bool {
    let nonempty = |k: &str| std::env::var_os(k).is_some_and(|v| !v.is_empty());
    nonempty("DISPLAY") || nonempty("WAYLAND_DISPLAY")
}

/// Whether `sudo` can run a command without prompting — passwordless sudoers or
/// a still-warm timestamp. Probed with `sudo -n true`.
#[cfg(unix)]
fn sudo_noninteractive_available() -> bool {
    on_path("sudo")
        && std::process::Command::new("sudo")
            .args(["-n", "true"])
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .status()
            .is_ok_and(|s| s.success())
}

/// Build `sudo -n -E <argv>`: non-interactive (never prompts; fails instead) and
/// env-preserving so the user's `ZLAYER_*`/`PATH` flow through.
#[cfg(unix)]
fn sudo_noninteractive(argv: &[OsString]) -> RootCommand {
    let mut args = vec![OsString::from("-n"), OsString::from("-E")];
    args.extend(argv.iter().cloned());
    RootCommand {
        program: OsString::from("sudo"),
        args,
        env: Vec::new(),
    }
}

/// Whether `name` resolves to a file on `PATH`.
#[cfg(unix)]
fn on_path(name: &str) -> bool {
    let Some(paths) = std::env::var_os("PATH") else {
        return false;
    };
    std::env::split_paths(&paths).any(|dir| dir.join(name).is_file())
}

/// Common graphical-askpass install locations `sudo -A` can drive.
#[cfg(all(unix, not(target_os = "macos")))]
fn known_askpass_present() -> bool {
    const CANDIDATES: &[&str] = &[
        "/usr/bin/ssh-askpass",
        "/usr/libexec/openssh/ssh-askpass",
        "/usr/lib/ssh/ssh-askpass",
    ];
    CANDIDATES.iter().any(|p| std::path::Path::new(p).exists())
}

/// Windows: relaunch `argv` elevated via UAC, blocking on its exit code.
#[cfg(windows)]
fn windows_runas(argv: &[OsString]) -> RootCommand {
    let file = argv
        .first()
        .map(|p| p.to_string_lossy().replace('\'', "''"))
        .unwrap_or_default();
    let arg_list = argv[1..]
        .iter()
        .map(|a| format!("'{}'", a.to_string_lossy().replace('\'', "''")))
        .collect::<Vec<_>>()
        .join(",");

    let ps = if arg_list.is_empty() {
        format!(
            "$p = Start-Process -FilePath '{file}' -Verb RunAs -WindowStyle Hidden -Wait -PassThru; exit $p.ExitCode"
        )
    } else {
        format!(
            "$p = Start-Process -FilePath '{file}' -ArgumentList {arg_list} -Verb RunAs -WindowStyle Hidden -Wait -PassThru; exit $p.ExitCode"
        )
    };

    RootCommand {
        program: OsString::from("powershell"),
        args: vec![
            OsString::from("-NoProfile"),
            OsString::from("-NonInteractive"),
            OsString::from("-Command"),
            OsString::from(ps),
        ],
        env: Vec::new(),
    }
}