Skip to main content

qcode/ui/setup/
gates.rs

1//! The gates of the setup wizard: what each step needs before the wizard may leave it.
2//!
3//! The rule of the design is that the wizard never jumps to the step the config file
4//! remembers. Every gate is asked in the order of the steps, and the first one that does not
5//! hold is where the wizard opens. [`Gates`] is plain data and [`Gates::entry`] is a pure
6//! function of it, so a test hands in any answer it likes without a disk or a container engine
7//! anywhere near it. Only [`Gates::probe`], [`check_engine`] and [`check_location`] touch the
8//! machine.
9
10use std::path::{Path, PathBuf};
11
12use crate::engine::known::{self, Known};
13use crate::engine::{Engine, EngineKind, Unavailable, detect};
14
15use super::install::IdRanges;
16use crate::store::{Config, SetupStep, Store};
17
18/// Why the chosen engine cannot be used.
19///
20/// The same distinctions the engine layer makes, kept in a shape the screen can hold on to and
21/// show: each one asks something different of the person.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum EngineProblem {
24    /// No binary anywhere QCode looked; the engine has to be installed.
25    NotInstalled,
26    /// Docker is installed and its daemon is not answering; it has to be started.
27    DaemonStopped {
28        /// What docker said.
29        output: String,
30    },
31    /// Podman is installed and the Linux virtual machine it works through is not running.
32    MachineStopped {
33        /// What podman said.
34        output: String,
35    },
36    /// The engine ran and refused for a reason QCode does not recognise.
37    Refused {
38        /// Its exit code, when it had one.
39        code: Option<i32>,
40        /// What it said.
41        output: String,
42    },
43    /// Docker answers and this account may not use it: it is not in the `docker` group, or it
44    /// was just added and this login began before that.
45    NoPermission {
46        /// What docker said.
47        output: String,
48        /// Whether `/etc/group` already names the account in `docker`, so only a new login is
49        /// missing.
50        in_group: bool,
51    },
52    /// Podman works without root and this account has no subordinate id ranges, which every
53    /// image with more than one user needs.
54    NoIdRanges {
55        /// The line that adds them, with a range no other account holds.
56        line: String,
57        /// What podman said, when it was a refusal that told; empty when the wizard found it
58        /// in the files before podman had to refuse anything.
59        output: String,
60    },
61    /// A binary was found and could not be started at all.
62    NotRunnable {
63        /// The binary QCode found.
64        bin: PathBuf,
65        /// What the operating system said about starting it.
66        message: String,
67    },
68}
69
70impl EngineProblem {
71    /// The engine's own words, when it had any. They are shown unchanged: a refusal QCode
72    /// cannot read is still readable by the person.
73    #[must_use]
74    pub fn output(&self) -> Option<&str> {
75        match self {
76            Self::NotInstalled => None,
77            Self::DaemonStopped { output }
78            | Self::MachineStopped { output }
79            | Self::Refused { output, .. }
80            | Self::NoPermission { output, .. }
81            | Self::NoIdRanges { output, .. } => Some(output).filter(|text| !text.is_empty()).map(String::as_str),
82            Self::NotRunnable { message, .. } => Some(message),
83        }
84    }
85}
86
87impl From<Unavailable> for EngineProblem {
88    fn from(unavailable: Unavailable) -> Self {
89        match unavailable {
90            Unavailable::NotInstalled => Self::NotInstalled,
91            Unavailable::DaemonStopped { output } => Self::DaemonStopped { output },
92            Unavailable::MachineStopped { output } => Self::MachineStopped { output },
93            Unavailable::InfoFailed { code, output } => Self::Refused { code, output },
94            Unavailable::NotRunnable { bin, error } => Self::NotRunnable { bin, message: error.to_string() },
95        }
96    }
97}
98
99/// What the machine answered when the chosen engine was asked whether it works.
100///
101/// It is the engine step's whole state as well as its gate: nothing asked yet, being asked,
102/// working, or broken with the reason.
103#[derive(Debug, Clone, Default, PartialEq, Eq)]
104pub enum EngineCheck {
105    /// Nobody has asked yet.
106    #[default]
107    Unknown,
108    /// The question is out; the answer has not come back.
109    Running,
110    /// The engine answered: it is there and it works.
111    Working,
112    /// The engine cannot be used.
113    Broken(EngineProblem),
114}
115
116/// Why the store folder cannot be used.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub enum LocationProblem {
119    /// No folder has been chosen yet.
120    Unset,
121    /// The path names something that is not a folder.
122    NotAFolder,
123    /// The folder could not be made, read or written, in the system's own words.
124    Blocked(String),
125}
126
127/// What the machine answered about the store folder.
128#[derive(Debug, Clone, Default, PartialEq, Eq)]
129pub enum LocationCheck {
130    /// Nobody has asked yet.
131    #[default]
132    Unknown,
133    /// The question is out; the answer has not come back.
134    Running,
135    /// The folder is there and QCode can write in it.
136    Usable,
137    /// The folder cannot be used.
138    Broken(LocationProblem),
139}
140
141/// What each step of the wizard needs, in the order the steps are walked.
142///
143/// This is data on purpose. [`entry`](Self::entry) reads it and nothing else, so every path
144/// through the wizard's opening rule is a test that needs neither a disk nor an engine.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct Gates {
147    /// Whether the config file names a language QCode has words for.
148    pub language: bool,
149    /// What the chosen engine answered.
150    pub engine: EngineCheck,
151    /// What the store folder answered.
152    pub location: LocationCheck,
153}
154
155impl Gates {
156    /// The step the wizard opens on: the first gate that does not hold, or `None` when they all
157    /// hold and the wizard has nothing left to ask.
158    ///
159    /// The step the config file remembers is never jumped to. A person who got as far as the
160    /// last step and then lost their engine opens on the engine step again.
161    #[must_use]
162    pub fn entry(&self) -> Option<SetupStep> {
163        if !self.language {
164            return Some(SetupStep::Language);
165        }
166        if self.engine != EngineCheck::Working {
167            return Some(SetupStep::Engine);
168        }
169        if self.location != LocationCheck::Usable {
170            return Some(SetupStep::Location);
171        }
172        None
173    }
174
175    /// Asks the machine every gate of `config`, in order, and stops at the first one that does
176    /// not hold: the gates after it stay [`Unknown`](EngineCheck::Unknown), because their
177    /// answer could not change where the wizard opens and asking costs a container engine or a
178    /// disk.
179    ///
180    /// Blocks on both, so it belongs on a background thread, never in `view`.
181    #[must_use]
182    pub fn probe(config: &Config) -> Self {
183        Self::probe_with(config, &detect).0
184    }
185
186    /// The same gates, with the engine that answered handed back beside them, and every engine
187    /// question put to `detect` rather than to the machine.
188    ///
189    /// The engine itself is what every screen that reaches into a container needs, and asking
190    /// for it separately is a second `podman info` on the way to the first frame: podman asks the
191    /// package manager about netavark, aardvark-dns, pasta, crun and conmon before it answers,
192    /// so the question costs about 0.4 s and a start is half a second longer for having asked it
193    /// twice. So the one question is asked once and its answer kept, by
194    /// [`check_engine_found`] and here.
195    ///
196    /// `detect` is a parameter for the reason [`Installer`](super::install::Installer) is one: a test counts what the gates
197    /// ask of the engines without either engine installed.
198    #[must_use]
199    pub fn probe_with(
200        config: &Config,
201        detect: &dyn Fn(EngineKind) -> Result<Engine, Unavailable>,
202    ) -> (Self, Option<Engine>) {
203        let mut gates = Self {
204            language: config.settings().language().is_some(),
205            engine: EngineCheck::Unknown,
206            location: LocationCheck::Unknown,
207        };
208        if !gates.language {
209            return (gates, None);
210        }
211        let mut found = None;
212        gates.engine = match config.engine_kind().and_then(EngineKind::from_name) {
213            Some(kind) => {
214                let (check, engine) = check_engine_found(kind, detect);
215                found = engine;
216                check
217            }
218            None => EngineCheck::Unknown,
219        };
220        if gates.engine != EngineCheck::Working {
221            return (gates, found);
222        }
223        gates.location = match config.folder_path() {
224            Some(path) => check_location(&path),
225            None => LocationCheck::Broken(LocationProblem::Unset),
226        };
227        (gates, found)
228    }
229}
230
231/// Asks `kind` whether it is installed and working.
232///
233/// Blocks on starting the engine, so it belongs on a background thread.
234#[must_use]
235pub fn check_engine(kind: EngineKind) -> EngineCheck {
236    check_engine_found(kind, &detect).0
237}
238
239/// The same question as [`check_engine`], with the engine that answered handed back beside the
240/// answer and `detect` put in place of the machine.
241///
242/// The engine is kept rather than looked up again, for the reason [`Gates::probe_with`] gives.
243/// An engine that cannot be used has none to hand back, so the answer there is
244/// [`Broken`](EngineCheck::Broken) with nothing beside it.
245///
246/// Blocks on starting the engine, so it belongs on a background thread.
247#[must_use]
248pub fn check_engine_found(
249    kind: EngineKind,
250    detect: &dyn Fn(EngineKind) -> Result<Engine, Unavailable>,
251) -> (EngineCheck, Option<Engine>) {
252    match detect(kind) {
253        Ok(engine) => (EngineCheck::Working, Some(engine)),
254        Err(unavailable) => {
255            let group = std::fs::read_to_string("/etc/group").unwrap_or_default();
256            let user = IdRanges::here().user;
257            (EngineCheck::Broken(refine(EngineProblem::from(unavailable), &group, &user)), None)
258        }
259    }
260}
261
262/// What the setup wizard's engine step asks: [`check_engine`], and for podman on Linux also
263/// whether the account has the id ranges podman needs to run QCode's images without root.
264///
265/// Only the wizard asks the second question. An engine that answers is left working everywhere
266/// else, and a container it then cannot make says why in a sentence of its own; the wizard is
267/// where the person is putting their engine in order, and where the line that adds the ranges
268/// can be run for them.
269#[must_use]
270pub fn check_engine_for_setup(kind: EngineKind) -> EngineCheck {
271    let check = check_engine(kind);
272    let linux = crate::store::Platform::host() == crate::store::Platform::Linux;
273    if check == EngineCheck::Working && kind == EngineKind::Podman && linux {
274        return with_ranges(check, &IdRanges::here());
275    }
276    check
277}
278
279/// A working podman on an account without id ranges is not working yet.
280fn with_ranges(check: EngineCheck, ranges: &IdRanges) -> EngineCheck {
281    if check == EngineCheck::Working && !ranges.present() {
282        return EngineCheck::Broken(EngineProblem::NoIdRanges { line: ranges.line(), output: String::new() });
283    }
284    check
285}
286
287/// Reads a refusal whose words QCode recognises as the problem it is: an account docker will
288/// not let in, told apart by whether `/etc/group` (`group`) already names `user` in `docker`;
289/// or an account podman has no id ranges for.
290fn refine(problem: EngineProblem, group: &str, user: &str) -> EngineProblem {
291    let EngineProblem::Refused { output, .. } = &problem else { return problem };
292    match known::recognise(output) {
293        Some(Known::NoPermission) => {
294            EngineProblem::NoPermission { output: output.clone(), in_group: in_group(group, "docker", user) }
295        }
296        Some(Known::NoIdRanges) => EngineProblem::NoIdRanges { line: IdRanges::here().line(), output: output.clone() },
297        _ => problem,
298    }
299}
300
301/// Whether the text of `/etc/group` names `user` among the members of `name`.
302fn in_group(text: &str, name: &str, user: &str) -> bool {
303    text.lines().any(|line| {
304        let mut fields = line.split(':');
305        fields.next() == Some(name)
306            && fields.nth(2).is_some_and(|members| members.split(',').any(|member| member.trim() == user))
307    })
308}
309
310/// Makes sure the store at `path` is there and can be written in.
311///
312/// The folder is made when it is missing: placing the store is the wizard's whole job, and
313/// the same call at every later start repairs a store whose folders were removed. Writing
314/// is proven by writing, not guessed from permission bits, because a read-only mount and a full
315/// disk both look writable until something is written.
316///
317/// Blocks on the file system, so it belongs on a background thread.
318#[must_use]
319pub fn check_location(path: &Path) -> LocationCheck {
320    if path.is_file() {
321        return LocationCheck::Broken(LocationProblem::NotAFolder);
322    }
323    let store = Store::new(path);
324    if let Err(diagnostic) = store.prepare() {
325        return LocationCheck::Broken(LocationProblem::Blocked(diagnostic.message));
326    }
327    let probe = path.join(format!(".qcode-write-{}", std::process::id()));
328    match std::fs::write(&probe, b"") {
329        Ok(()) => {
330            let _ = std::fs::remove_file(&probe);
331            LocationCheck::Usable
332        }
333        Err(error) => LocationCheck::Broken(LocationProblem::Blocked(error.to_string())),
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use crate::store::{Config, SetupStep};
341
342    fn working() -> Gates {
343        Gates { language: true, engine: EngineCheck::Working, location: LocationCheck::Usable }
344    }
345
346    #[test]
347    fn a_wizard_whose_gates_all_hold_has_no_entry_step() {
348        assert_eq!(working().entry(), None);
349    }
350
351    #[test]
352    fn the_language_gate_is_asked_first() {
353        let gates = Gates { language: false, ..working() };
354        assert_eq!(gates.entry(), Some(SetupStep::Language));
355    }
356
357    #[test]
358    fn a_broken_engine_is_the_entry_even_when_every_later_gate_holds() {
359        let gates = Gates { engine: EngineCheck::Broken(EngineProblem::NotInstalled), ..working() };
360        assert_eq!(gates.entry(), Some(SetupStep::Engine));
361    }
362
363    #[test]
364    fn an_engine_that_was_never_asked_does_not_pass_its_gate() {
365        let gates = Gates { engine: EngineCheck::Unknown, ..working() };
366        assert_eq!(gates.entry(), Some(SetupStep::Engine));
367        let gates = Gates { engine: EngineCheck::Running, ..working() };
368        assert_eq!(gates.entry(), Some(SetupStep::Engine));
369    }
370
371    #[test]
372    fn an_unusable_location_is_the_entry_when_everything_before_it_holds() {
373        let gates = Gates { location: LocationCheck::Broken(LocationProblem::Unset), ..working() };
374        assert_eq!(gates.entry(), Some(SetupStep::Location));
375    }
376
377    #[test]
378    fn a_recorded_step_is_never_jumped_to_over_a_gate_that_fell() {
379        // The config may say the person got as far as the location; the engine gate still wins.
380        let gates = Gates {
381            language: true,
382            engine: EngineCheck::Broken(EngineProblem::DaemonStopped { output: "no socket".to_owned() }),
383            location: LocationCheck::Usable,
384        };
385        assert_eq!(gates.entry(), Some(SetupStep::Engine));
386    }
387
388    #[test]
389    fn probing_a_fresh_config_stops_at_the_language_gate() {
390        // Nothing after the first gate that falls is asked, so no engine is started and no disk
391        // is touched by this test.
392        let gates = Gates::probe(&Config::parse_str("code.conf", ""));
393        assert!(!gates.language);
394        assert_eq!(gates.engine, EngineCheck::Unknown);
395        assert_eq!(gates.location, LocationCheck::Unknown);
396        assert_eq!(gates.entry(), Some(SetupStep::Language));
397    }
398
399    #[test]
400    fn a_probe_whose_engine_cannot_be_used_says_the_same_thing_as_asking_that_engine_alone() {
401        // What the gates say is unchanged by being asked through a detector of their own: only
402        // the engine that answered is new, and an engine that refused has none to hand back.
403        let config = Config::parse_str("code.conf", "language = \"tr\"\n\n[engine]\nkind = \"podman\"\n");
404        let refused = |_kind: EngineKind| Err::<Engine, Unavailable>(Unavailable::NotInstalled);
405        let (gates, found) = Gates::probe_with(&config, &refused);
406        assert_eq!(gates.engine, EngineCheck::Broken(EngineProblem::NotInstalled), "{:?}", gates.engine);
407        assert_eq!(gates.engine, check_engine_found(EngineKind::Podman, &refused).0);
408        assert_eq!(found, None, "an engine that cannot be used is none to keep");
409    }
410
411    #[test]
412    fn probing_a_config_without_an_engine_stops_at_the_engine_gate() {
413        let gates = Gates::probe(&Config::parse_str("code.conf", "language = \"tr\"\n"));
414        assert!(gates.language);
415        assert_eq!(gates.engine, EngineCheck::Unknown);
416        assert_eq!(gates.location, LocationCheck::Unknown);
417    }
418
419    #[test]
420    fn a_folder_that_can_be_made_and_written_passes_the_location_gate() {
421        let dir = std::env::temp_dir().join(format!("qcode-setup-usable-{}", std::process::id()));
422        assert_eq!(check_location(&dir), LocationCheck::Usable);
423        assert!(dir.join("Workspaces").is_dir(), "the store tree is made while it is checked");
424        std::fs::remove_dir_all(&dir).expect("the test cleans up after itself");
425    }
426
427    #[test]
428    fn a_path_that_names_a_file_is_not_a_folder() {
429        let file = std::env::temp_dir().join(format!("qcode-setup-file-{}", std::process::id()));
430        std::fs::write(&file, "not a folder").expect("the temporary directory takes a file");
431        assert_eq!(check_location(&file), LocationCheck::Broken(LocationProblem::NotAFolder));
432        std::fs::remove_file(&file).expect("the test cleans up after itself");
433    }
434
435    #[test]
436    fn a_folder_that_cannot_be_made_carries_the_systems_own_reason() {
437        let file = std::env::temp_dir().join(format!("qcode-setup-block-{}", std::process::id()));
438        std::fs::write(&file, "in the way").expect("the temporary directory takes a file");
439        let LocationCheck::Broken(LocationProblem::Blocked(reason)) = check_location(&file.join("QCode")) else {
440            panic!("a folder below a file cannot be made")
441        };
442        assert!(!reason.is_empty(), "the reason is the system's own words");
443        std::fs::remove_file(&file).expect("the test cleans up after itself");
444    }
445
446    #[test]
447    fn every_reason_an_engine_gives_keeps_what_the_screen_has_to_say_about_it() {
448        let problem = EngineProblem::from(Unavailable::NotInstalled);
449        assert_eq!(problem, EngineProblem::NotInstalled);
450        assert_eq!(problem.output(), None);
451
452        let stopped = EngineProblem::from(Unavailable::DaemonStopped { output: "no socket".to_owned() });
453        assert_eq!(stopped.output(), Some("no socket"));
454
455        let machine = EngineProblem::from(Unavailable::MachineStopped { output: "no machine".to_owned() });
456        assert_eq!(machine.output(), Some("no machine"));
457
458        let refused = EngineProblem::from(Unavailable::InfoFailed { code: Some(125), output: "no tty".to_owned() });
459        assert_eq!(refused, EngineProblem::Refused { code: Some(125), output: "no tty".to_owned() });
460
461        let error = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
462        let unrunnable = EngineProblem::from(Unavailable::NotRunnable { bin: "/usr/bin/podman".into(), error });
463        let EngineProblem::NotRunnable { bin, message } = unrunnable else { panic!("it stays unrunnable") };
464        assert_eq!(bin, std::path::PathBuf::from("/usr/bin/podman"));
465        assert!(!message.is_empty());
466    }
467
468    #[test]
469    fn a_docker_socket_this_account_may_not_open_asks_for_the_group_or_only_for_a_new_login() {
470        let output = "permission denied while trying to connect to the docker API at unix:///var/run/docker.sock";
471        let refused = || EngineProblem::Refused { code: Some(1), output: output.to_owned() };
472        let group = "wheel:x:998:ada\ndocker:x:951:bob,ada\n";
473        assert_eq!(
474            super::refine(refused(), group, "ada"),
475            EngineProblem::NoPermission { output: output.to_owned(), in_group: true }
476        );
477        assert_eq!(
478            super::refine(refused(), group, "carol"),
479            EngineProblem::NoPermission { output: output.to_owned(), in_group: false }
480        );
481        let other = EngineProblem::Refused { code: Some(1), output: "no tty".to_owned() };
482        assert_eq!(super::refine(other.clone(), group, "ada"), other, "anything else is left as it was");
483    }
484
485    #[test]
486    fn a_working_podman_on_an_account_without_id_ranges_is_not_ready_yet() {
487        let ranges = |subuid: &str| super::IdRanges {
488            user: "ada".to_owned(),
489            from_env: true,
490            uid: 1001,
491            subuid: subuid.to_owned(),
492            subgid: subuid.to_owned(),
493        };
494        let missing = super::with_ranges(EngineCheck::Working, &ranges(""));
495        let EngineCheck::Broken(EngineProblem::NoIdRanges { line, .. }) = missing else { panic!("{missing:?}") };
496        assert!(line.contains("--add-subuids 100000-165535"), "{line}");
497        assert_eq!(super::with_ranges(EngineCheck::Working, &ranges("ada:100000:65536\n")), EngineCheck::Working);
498    }
499}