Coverage Report

Created: 2026-09-05 23:00

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
super-stt-install/src/escalate.rs
Line
Count
Source
1
// SPDX-License-Identifier: GPL-3.0-only
2
//! Privilege escalation: pick `sudo` (TTY) or `pkexec` (no TTY / GUI) and
3
//! re-exec this same binary under it for the `--root-phase` step.
4
5
use std::os::unix::fs::PermissionsExt;
6
use std::path::{Path, PathBuf};
7
8
use crate::errors::InstallError;
9
10
/// Which escalator was chosen to re-exec the root phase under.
11
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12
pub enum Method {
13
    Sudo,
14
    Pkexec,
15
}
16
17
/// Scan `path_env` (a `:`-separated `$PATH`-shaped string) for an executable
18
/// file named `bin`, returning the first match. Pure — takes `path_env`
19
/// explicitly rather than reading `$PATH` itself, so it's testable without
20
/// mutating process environment.
21
#[must_use]
22
8
pub fn which(bin: &str, path_env: &str) -> Option<PathBuf> {
23
42
    for dir in 
path_env8
.
split8
(':') {
24
42
        if dir.is_empty() {
25
0
            continue;
26
42
        }
27
42
        let candidate = Path::new(dir).join(bin);
28
42
        let Ok(
meta4
) = std::fs::metadata(&candidate) else {
29
38
            continue;
30
        };
31
4
        if !meta.is_file() {
32
0
            continue;
33
4
        }
34
4
        if meta.permissions().mode() & 0o111 != 0 {
35
2
            return Some(candidate);
36
2
        }
37
    }
38
6
    None
39
8
}
40
41
/// Choose the escalation method: `sudo` on a TTY (it can prompt for a
42
/// password interactively), else `pkexec` (its polkit agent shows its own
43
/// GUI dialog and doesn't need a controlling terminal).
44
///
45
/// # Errors
46
/// [`InstallError::EscalationUnavailable`] naming what's missing when
47
/// neither a usable `sudo` (with a TTY) nor `pkexec` is available.
48
8
pub fn pick_method(
49
8
    stderr_is_tty: bool,
50
8
    has_sudo: bool,
51
8
    has_pkexec: bool,
52
8
) -> Result<Method, InstallError> {
53
8
    if stderr_is_tty && 
has_sudo4
{
54
2
        return Ok(Method::Sudo);
55
6
    }
56
6
    if has_pkexec {
57
4
        return Ok(Method::Pkexec);
58
2
    }
59
2
    let reason = if has_sudo {
60
2
        "no controlling terminal for sudo, and pkexec is not installed"
61
    } else {
62
0
        "neither sudo nor pkexec is available"
63
    };
64
2
    Err(InstallError::EscalationUnavailable(reason.to_string()))
65
8
}
66
67
/// Classify a non-zero exit from the escalated `<escalator> <exe> --root-phase
68
/// <manifest>` command into the wire-contract error the app branches on
69
/// (extracted from `run_root_phase` per F2 so the whole denial matrix is
70
/// testable without ever invoking a real `sudo`/`pkexec`).
71
///
72
/// `stderr` is whatever text was actually captured for the failing process —
73
/// pass `""` when it was inherited instead (the `Method::Sudo` case; see
74
/// `run_root_phase`'s F3 doc comment). Callers must have already forced
75
/// `LANG=C`/`LC_ALL=C` on the escalated command (F1) so any captured `stderr`
76
/// text this matches against is guaranteed English, not gettext-localized.
77
///
78
/// # Mapping (C1: exit code `3` is `crate::root_phase::run`'s OWN failure
79
/// code, distinct from either escalator's denial codes — see that
80
/// function's doc comment for why. That's what makes the rest of this
81
/// mapping sound: an escalator's denial code and "the root phase ran and
82
/// failed" can never collide on the same exit code.)
83
/// - `pkexec` exit 126 (dialog dismissed) or 127 (not authorized) →
84
///   [`InstallError::EscalationDenied`].
85
/// - `sudo` exit 1, when `stderr` is empty or names a denial
86
///   (`"incorrect password"`/`"Sorry"`) → [`InstallError::EscalationDenied`].
87
///   Exit 1 is sudo's own refusal-or-cannot-run status — a bad/missing
88
///   password, but also a sudoers/config problem or a failure to exec the
89
///   command at all — rather than the invoked command's exit status, so we
90
///   treat it as a denial; the root phase's own failures are distinguishable
91
///   because they exit `3`, never `1`. The empty-`stderr` case is the
92
///   inherited-stdio case (F3): `stderr` is always `""` here for a real
93
///   invocation, since `Method::Sudo` inherits stderr rather than capturing
94
///   it.
95
/// - either escalator, exit `3` → [`InstallError::InstallFailed`]: the root
96
///   phase itself ran and failed. Carries the captured stderr when there is
97
///   any (pkexec always captures it); when `stderr` is empty instead, the
98
///   message is escalator-aware: for `sudo` (which inherits stderr rather
99
///   than capturing it — F3) it points at the terminal output above; for
100
///   `pkexec` (no terminal to point at) it says only that no reason was
101
///   reported — practically unreachable, since the root phase always prints
102
///   before exiting `3` and pkexec always captures that output.
103
/// - anything else → [`InstallError::InstallFailed`] naming the exit code and
104
///   trimmed stderr.
105
#[must_use]
106
22
pub fn classify_failure(escalator: &str, code: Option<i32>, stderr: &str) -> InstallError {
107
22
    match (escalator, code) {
108
22
        ("pkexec", Some(126 | 127)) => 
InstallError::EscalationDenied4
,
109
12
        ("sudo", Some(1))
110
6
            if stderr.is_empty()
111
4
                || stderr.contains("incorrect password")
112
6
                || 
stderr2
.
contains2
("Sorry") =>
113
        {
114
6
            InstallError::EscalationDenied
115
        }
116
        // C1: `root_phase::run`'s own failure code, for either escalator —
117
        // never a denial, regardless of which escalator propagated it.
118
8
        (_, Some(3)) if stderr.is_empty(
)6
=> {
119
6
            let msg = if escalator == "sudo" {
120
4
                "the root phase failed; see the terminal output above for the reason"
121
            } else {
122
2
                "the root phase failed without reporting a reason"
123
            };
124
6
            InstallError::InstallFailed(msg.to_string())
125
        }
126
        (_, Some(3)) => {
127
2
            InstallError::InstallFailed(format!("root phase failed: {}", stderr.trim()))
128
        }
129
4
        _ => InstallError::InstallFailed(format!("root phase exited {code:?}: {}", stderr.trim())),
130
    }
131
22
}
132
133
/// Re-exec this same running binary (`std::env::current_exe()`) under
134
/// `method`, invoking `<exe> --root-phase <manifest_path>`. Blocks until the
135
/// escalated process exits.
136
///
137
/// # Errors
138
/// [`InstallError::EscalationDenied`] when the user dismissed the pkexec
139
/// dialog (exit 126), was refused authorization (exit 127), or typed a wrong
140
/// sudo password; [`InstallError::EscalationUnavailable`] when the escalator
141
/// itself could not be spawned (e.g. no polkit agent running); otherwise
142
/// [`InstallError::InstallFailed`] naming the escalated process's exit code
143
/// and stderr.
144
0
pub async fn run_root_phase(method: Method, manifest_path: &Path) -> Result<(), InstallError> {
145
0
    let me = std::env::current_exe()
146
0
        .map_err(|e| InstallError::InstallFailed(format!("current_exe: {e}")))?;
147
0
    if matches!(method, Method::Sudo) {
148
        // Prime the sudo timestamp so the actual run doesn't re-prompt oddly.
149
0
        let ok = tokio::process::Command::new("sudo")
150
0
            .arg("-v")
151
0
            .env("LANG", "C")
152
0
            .env("LC_ALL", "C")
153
0
            .env_remove("LANGUAGE")
154
0
            .status()
155
0
            .await
156
0
            .map_err(|e| InstallError::EscalationUnavailable(e.to_string()))?;
157
0
        if !ok.success() {
158
0
            return Err(InstallError::EscalationDenied);
159
0
        }
160
0
    }
161
0
    let escalator = match method {
162
0
        Method::Sudo => "sudo",
163
0
        Method::Pkexec => "pkexec",
164
    };
165
0
    let mut cmd = tokio::process::Command::new(escalator);
166
0
    cmd.arg(&me)
167
0
        .arg("--root-phase")
168
0
        .arg(manifest_path)
169
0
        .stdin(std::process::Stdio::inherit())
170
0
        // F1: sudo/pkexec localize their diagnostics via gettext — force
171
0
        // English so `classify_failure`'s denial-phrase match below isn't
172
0
        // locale-dependent (a French/German/Spanish `LANG` would otherwise
173
0
        // misreport a wrong-password rejection as `InstallFailed`).
174
0
        .env("LANG", "C")
175
0
        .env("LC_ALL", "C")
176
0
        .env_remove("LANGUAGE");
177
178
0
    let (status, stderr) = match method {
179
        Method::Sudo => {
180
            // F3: `pick_method` only ever returns `Sudo` when stderr is a
181
            // TTY, so inherit it here instead of capturing via `.output()`:
182
            // if the `sudo -v` primer's timestamp expires in the window
183
            // between it and this call, sudo re-prompts, and an inherited
184
            // prompt is visible — a captured one is an invisible prompt into
185
            // a pipe nobody answers, hanging the process forever.
186
0
            cmd.stderr(std::process::Stdio::inherit());
187
0
            let status = cmd
188
0
                .status()
189
0
                .await
190
0
                .map_err(|e| InstallError::EscalationUnavailable(e.to_string()))?;
191
0
            (status, String::new())
192
        }
193
        Method::Pkexec => {
194
            // No TTY here by construction (`pick_method` only picks Pkexec
195
            // when sudo can't prompt), so there's no re-prompt-into-a-pipe
196
            // risk — capture stderr as before for classification.
197
0
            let out = cmd
198
0
                .output()
199
0
                .await
200
0
                .map_err(|e| InstallError::EscalationUnavailable(e.to_string()))?;
201
0
            (
202
0
                out.status,
203
0
                String::from_utf8_lossy(&out.stderr).into_owned(),
204
0
            )
205
        }
206
    };
207
0
    if status.success() {
208
0
        return Ok(());
209
0
    }
210
0
    Err(classify_failure(escalator, status.code(), &stderr))
211
0
}
212
213
#[cfg(test)]
214
mod tests {
215
    use super::*;
216
    use std::sync::atomic::{AtomicU64, Ordering};
217
218
    static COUNTER: AtomicU64 = AtomicU64::new(0);
219
220
    /// A fresh, empty per-test temp directory (per-pid, plus a per-call
221
    /// counter so parallel tests in this binary never collide).
222
4
    fn test_dir() -> PathBuf {
223
4
        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
224
4
        let dir =
225
4
            std::env::temp_dir().join(format!("sstt-install-escalate-{}-{n}", std::process::id()));
226
        // F6: clear a pre-existing directory first — the pid+counter name
227
        // is only unique within one process run, so PID reuse across
228
        // separate test-binary invocations could otherwise leak files from
229
        // a previous run into this one.
230
4
        let _ = std::fs::remove_dir_all(&dir);
231
4
        std::fs::create_dir_all(&dir).unwrap();
232
4
        dir
233
4
    }
234
235
    #[test]
236
2
    fn which_scans_path_entries() {
237
2
        let dir = test_dir();
238
2
        std::fs::create_dir_all(&dir).unwrap();
239
2
        let exe = dir.join("fakebin");
240
2
        std::fs::write(&exe, b"#!/bin/sh\n").unwrap();
241
        use std::os::unix::fs::PermissionsExt;
242
2
        std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
243
2
        let path_env = format!("/nonexistent:{}", dir.display());
244
2
        assert_eq!(which("fakebin", &path_env), Some(exe));
245
2
        assert_eq!(which("missing", &path_env), None);
246
2
    }
247
248
    #[test]
249
2
    fn method_choice_matrix() {
250
2
        assert!(
matches!0
(pick_method(true, true, true), Ok(Method::Sudo)));
251
2
        assert!(
matches!0
(pick_method(true, false, true), Ok(Method::Pkexec)));
252
2
        assert!(
matches!0
(pick_method(false, true, true), Ok(Method::Pkexec))); // no TTY -> sudo can't prompt
253
2
        assert!(
matches!0
(
254
2
            pick_method(false, true, false),
255
            Err(InstallError::EscalationUnavailable(_))
256
        ));
257
2
    }
258
259
    #[test]
260
2
    fn which_ignores_a_non_executable_file() {
261
2
        let dir = test_dir();
262
2
        let f = dir.join("not-executable");
263
2
        std::fs::write(&f, b"nope").unwrap();
264
2
        std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o644)).unwrap();
265
2
        let path_env = dir.display().to_string();
266
2
        assert_eq!(which("not-executable", &path_env), None);
267
2
    }
268
269
    // --- classify_failure (F2): pure, so the whole denial matrix is
270
    // testable without ever invoking a real sudo/pkexec. ---
271
272
    #[test]
273
2
    fn classify_pkexec_dialog_dismissed_or_unauthorized_as_denied() {
274
2
        assert!(
matches!0
(
275
2
            classify_failure("pkexec", Some(126), ""),
276
            InstallError::EscalationDenied
277
        ));
278
2
        assert!(
matches!0
(
279
2
            classify_failure("pkexec", Some(127), ""),
280
            InstallError::EscalationDenied
281
        ));
282
2
    }
283
284
    #[test]
285
2
    fn classify_sudo_denial_with_english_stderr() {
286
        // F1: LANG=C/LC_ALL=C on the escalated command guarantees sudo's
287
        // diagnostics are English before we ever get here to match them.
288
2
        assert!(
matches!0
(
289
2
            classify_failure("sudo", Some(1), "Sorry, try again.\n"),
290
            InstallError::EscalationDenied
291
        ));
292
2
        assert!(
matches!0
(
293
2
            classify_failure("sudo", Some(1), "sudo: 1 incorrect password attempt\n"),
294
            InstallError::EscalationDenied
295
        ));
296
2
    }
297
298
    #[test]
299
2
    fn classify_sudo_denial_with_inherited_empty_stderr() {
300
        // F3: for `Method::Sudo` stderr is inherited (visible to the user),
301
        // not captured — `run_root_phase` passes `""` in that case. C1: a
302
        // sudo exit code of 1 is unambiguously a denial regardless of
303
        // stderr content — `root_phase::run` never exits 1 (it exits `3` on
304
        // failure), so 1 can only mean sudo itself refused to run the
305
        // command at all.
306
2
        assert!(
matches!0
(
307
2
            classify_failure("sudo", Some(1), ""),
308
            InstallError::EscalationDenied
309
        ));
310
2
    }
311
312
    #[test]
313
2
    fn classify_pkexec_other_code_is_install_failed_with_details() {
314
2
        let e = classify_failure("pkexec", Some(1), "some polkit error\n");
315
2
        match e {
316
2
            InstallError::InstallFailed(msg) => {
317
2
                assert!(msg.contains('1'), "{msg}");
318
2
                assert!(msg.contains("some polkit error"), "{msg}");
319
            }
320
0
            other => panic!("expected InstallFailed, got {other:?}"),
321
        }
322
2
    }
323
324
    #[test]
325
2
    fn classify_sudo_other_code_is_install_failed_with_details() {
326
2
        let e = classify_failure("sudo", Some(2), "unexpected failure\n");
327
2
        match e {
328
2
            InstallError::InstallFailed(msg) => {
329
2
                assert!(msg.contains('2'), "{msg}");
330
2
                assert!(msg.contains("unexpected failure"), "{msg}");
331
2
                assert!(!msg.ends_with('\n'), "stderr must be trimmed: {msg:?}");
332
            }
333
0
            other => panic!("expected InstallFailed, got {other:?}"),
334
        }
335
2
    }
336
337
    // --- C1: `root_phase::run`'s own failure code (3), distinct from
338
    // sudo's/pkexec's own escalator-denial codes, so a root-phase failure
339
    // (containment rejection, disk full, missing staged source, ...) is
340
    // never misclassified as the user having declined authorization. ---
341
342
    #[test]
343
2
    fn classify_root_phase_failure_exit_code_is_install_failed_not_denied() {
344
        // Exit 3 is `root_phase::run`'s OWN failure code, propagated
345
        // verbatim by both escalators — it must always mean "the root phase
346
        // ran and failed", never "authorization was denied".
347
4
        for escalator in 
["sudo", "pkexec"]2
{
348
4
            let e = classify_failure(escalator, Some(3), "");
349
4
            assert!(
350
4
                
matches!0
(e, InstallError::InstallFailed(_)),
351
                "{escalator}: expected InstallFailed, got {e:?}"
352
            );
353
        }
354
2
    }
355
356
    #[test]
357
2
    fn classify_root_phase_failure_carries_captured_stderr() {
358
        // pkexec's stderr is captured (not inherited) — the real error the
359
        // root phase printed must survive into the reported message.
360
2
        let e = classify_failure("pkexec", Some(3), "error: staging missing foo\n");
361
2
        match e {
362
2
            InstallError::InstallFailed(msg) => {
363
2
                assert!(msg.contains("staging missing foo"), "{msg}");
364
            }
365
0
            other => panic!("expected InstallFailed, got {other:?}"),
366
        }
367
2
    }
368
369
    #[test]
370
2
    fn classify_root_phase_failure_with_inherited_stderr_points_at_the_terminal() {
371
        // sudo's stderr is always inherited (never captured — see
372
        // `run_root_phase`'s F3 doc comment), so `classify_failure` sees an
373
        // empty string here for a real invocation. The message must still
374
        // tell the user something useful: that the real error was already
375
        // printed to their terminal, not just "root phase exited Some(3): ".
376
2
        let e = classify_failure("sudo", Some(3), "");
377
2
        match e {
378
2
            InstallError::InstallFailed(msg) => {
379
2
                assert!(
380
2
                    msg.to_lowercase().contains("terminal"),
381
                    "expected a message pointing at the inherited terminal output: {msg}"
382
                );
383
            }
384
0
            other => panic!("expected InstallFailed, got {other:?}"),
385
        }
386
2
    }
387
}