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 | | } |