Skip to main content

runner_manager_platform/wsl/
probe.rs

1// owner: a1-wsl-platform-adapter
2
3//! Selecting a distribution and asking it the five questions that decide
4//! whether the product may manage it.
5//!
6//! # One invocation shape, and only one
7//!
8//! Every Linux command this adapter runs is
9//!
10//! ```text
11//! wsl.exe --distribution <NAME> --user <USER> --exec <PROGRAM> [ARG ...]
12//! ```
13//!
14//! as an argument *vector* — [`LinuxCommand::wsl_arguments`] builds it, and it
15//! is the only thing that does. `--exec` is what makes that true end to end:
16//! Microsoft documents it as *"execute the specified command without using the
17//! default Linux shell"*, so the arguments reach `execvp` as they were written
18//! and no `;`, `&&`, `$(…)` or quote in a distribution name, a path or a
19//! version string is ever interpreted by anything
20//! (<https://learn.microsoft.com/en-us/windows/wsl/basic-commands>).
21//!
22//! The Windows half is the same story: [`super::exec::CommandRequest`] holds a
23//! `Vec<OsString>` and `std::process::Command` quotes each element for
24//! `CommandLineToArgvW`. There is no point in the chain at which a string is
25//! split back into arguments.
26//!
27//! # The five preflight questions
28//!
29//! `02-target-architecture.md` step 1 is *"validate that `NAME` is installed as
30//! WSL2, starts as root, and runs systemd"*, and `03-security-and-lifecycle.md`
31//! adds the architecture row to the failure table. [`probe_readiness`] asks
32//! them in the order in which a failing answer is most useful:
33//!
34//! 1. **Installed, exactly.** `wsl --list --verbose`, matched case-sensitively.
35//! 2. **WSL2.** A WSL1 distribution has no systemd and no separate kernel;
36//!    refusing here is `Compatibility`'s "explicit preflight failure".
37//! 3. **Root.** `id -u` must answer `0`. The provider installs a system
38//!    service and writes `/usr/local/bin`; discovering that it cannot halfway
39//!    through is the failure mode this question removes.
40//! 4. **A supported Linux architecture.** `uname -m`, mapped to the
41//!    architectures the release actually publishes.
42//! 5. **systemd.** `systemctl is-system-running`. WSL runs systemd only when
43//!    the distribution opts in and the WSL build is 0.67.6 or newer
44//!    (<https://learn.microsoft.com/en-us/windows/wsl/systemd>), and the Linux
45//!    daemon is a system unit.
46//!
47//! **No question in this list mutates anything.** That is what
48//! `03-security-and-lifecycle.md`'s "WSL/systemd preflight — no mutation and no
49//! device login" means in code: a failure here happens before a credential has
50//! been issued, before a byte has been written into the distribution, and
51//! before a Windows task exists.
52
53use std::fmt;
54use std::path::{Path, PathBuf};
55use std::time::Duration;
56
57use runner_manager_domain::model::Arch;
58
59use super::WslError;
60use super::discovery::{DistributionTable, validate_distribution_name};
61use super::exec::{ChildInput, CommandOutput, CommandRequest, CommandRunner, OutputLimits};
62
63/// The Linux account every managed command runs as.
64///
65/// Fixed rather than configurable. The provider's whole job — installing a
66/// binary under `/usr/local/bin`, registering a system unit, writing a machine
67/// secret store — is root's, and a second account would mean a second set of
68/// permissions to reason about for no gain
69/// (`02-target-architecture.md`: `--user root`).
70pub const LINUX_USER: &str = "root";
71
72/// `wsl.exe`, as found on this host or as a test says it is.
73///
74/// # Why `%SystemRoot%` is used here and refused in [`crate::runner_root`]
75///
76/// `runner_root` derives the default runner root from `GetSystemDirectoryW`
77/// and says explicitly that it will not read `%SystemDrive%`, because that
78/// value *decides where an ACL'd directory is created* — a wrong answer there
79/// is a security-relevant mistake that nothing downstream would notice.
80///
81/// This is the weaker question of where one well-known executable is, and its
82/// wrong answer is loud: `wsl.exe` is either at the path or it is not, and a
83/// missing program is a [`WslError::Spawn`] naming the path it tried. So the
84/// environment is used as a *hint*, with a documented fallback to a bare
85/// `wsl.exe`, which resolves through `PATH` exactly as it would in an
86/// operator's own shell.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct WslExecutable(PathBuf);
89
90impl WslExecutable {
91    /// Where `wsl.exe` is on this host.
92    ///
93    /// `%SystemRoot%\System32\wsl.exe` when `%SystemRoot%` names a directory
94    /// that has it, and a bare `wsl.exe` otherwise.
95    #[must_use]
96    pub fn locate() -> Self {
97        Self(locate_in_system32("wsl.exe"))
98    }
99
100    /// A named executable, for a test or for a caller that already knows.
101    #[must_use]
102    pub fn at(path: impl Into<PathBuf>) -> Self {
103        Self(path.into())
104    }
105
106    /// The path that will be launched.
107    #[must_use]
108    pub fn path(&self) -> &Path {
109        &self.0
110    }
111}
112
113impl Default for WslExecutable {
114    fn default() -> Self {
115        Self::locate()
116    }
117}
118
119impl fmt::Display for WslExecutable {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        write!(f, "{}", self.0.display())
122    }
123}
124
125/// `%SystemRoot%` + `System32` + the program, when that file is really there,
126/// and a bare program name -- resolved through `PATH` -- otherwise.
127///
128/// Shared by [`WslExecutable`] and by [`crate::wsl::task`]'s `schtasks.exe`,
129/// which need the same lookup under the same reasoning.
130pub(crate) fn locate_in_system32(program: &str) -> PathBuf {
131    locate_from(std::env::var_os("SystemRoot").map(PathBuf::from), program)
132}
133
134/// The pure half of [`locate_in_system32`], so both branches are testable on a
135/// machine that has neither `%SystemRoot%` nor the program.
136fn locate_from(system_root: Option<PathBuf>, program: &str) -> PathBuf {
137    if let Some(root) = system_root {
138        let candidate = root.join("System32").join(program);
139        if candidate.is_file() {
140            return candidate;
141        }
142    }
143    PathBuf::from(program)
144}
145
146// ---------------------------------------------------------------------------
147// One Linux command
148// ---------------------------------------------------------------------------
149
150/// A program to run inside a named distribution, as a literal argument vector.
151#[derive(Debug)]
152pub struct LinuxCommand {
153    distribution: String,
154    user: String,
155    program: String,
156    arguments: Vec<String>,
157    input: ChildInput,
158    timeout: Duration,
159    limits: OutputLimits,
160}
161
162impl LinuxCommand {
163    /// Runs `program` in `distribution`, as root, with no arguments.
164    #[must_use]
165    pub fn new(distribution: impl Into<String>, program: impl Into<String>) -> Self {
166        Self {
167            distribution: distribution.into(),
168            user: LINUX_USER.to_string(),
169            program: program.into(),
170            arguments: Vec::new(),
171            input: ChildInput::Empty,
172            timeout: super::exec::DEFAULT_TIMEOUT,
173            limits: OutputLimits::default(),
174        }
175    }
176
177    /// Appends arguments, verbatim and in order.
178    #[must_use]
179    pub fn args<I, S>(mut self, arguments: I) -> Self
180    where
181        I: IntoIterator<Item = S>,
182        S: Into<String>,
183    {
184        self.arguments.extend(arguments.into_iter().map(Into::into));
185        self
186    }
187
188    /// Gives the Linux process something on its stdin.
189    #[must_use]
190    pub fn with_input(mut self, input: ChildInput) -> Self {
191        self.input = input;
192        self
193    }
194
195    /// Replaces the default deadline.
196    #[must_use]
197    pub fn with_timeout(mut self, timeout: Duration) -> Self {
198        self.timeout = timeout;
199        self
200    }
201
202    /// Replaces the default capture bounds.
203    #[must_use]
204    pub fn with_limits(mut self, limits: OutputLimits) -> Self {
205        self.limits = limits;
206        self
207    }
208
209    /// The distribution this runs in.
210    #[must_use]
211    pub fn distribution(&self) -> &str {
212        &self.distribution
213    }
214
215    /// The Linux program.
216    #[must_use]
217    pub fn program(&self) -> &str {
218        &self.program
219    }
220
221    /// The argument vector handed to `wsl.exe`.
222    ///
223    /// The single place the invocation shape is written down, and therefore
224    /// the single thing a test has to assert to know that no shell is
225    /// involved anywhere.
226    #[must_use]
227    pub fn wsl_arguments(&self) -> Vec<String> {
228        let mut argv = vec![
229            "--distribution".to_string(),
230            self.distribution.clone(),
231            "--user".to_string(),
232            self.user.clone(),
233            "--exec".to_string(),
234            self.program.clone(),
235        ];
236        argv.extend(self.arguments.iter().cloned());
237        argv
238    }
239}
240
241// ---------------------------------------------------------------------------
242// The invoker
243// ---------------------------------------------------------------------------
244
245/// Runs `wsl.exe`, through whatever [`CommandRunner`] it was given.
246#[derive(Debug, Clone, Copy)]
247pub struct WslInvoker<'runner> {
248    runner: &'runner dyn CommandRunner,
249    executable: &'runner WslExecutable,
250}
251
252impl<'runner> WslInvoker<'runner> {
253    /// Binds a runner and an executable.
254    #[must_use]
255    pub fn new(runner: &'runner dyn CommandRunner, executable: &'runner WslExecutable) -> Self {
256        Self { runner, executable }
257    }
258
259    /// The executable it will launch.
260    #[must_use]
261    pub fn executable(&self) -> &WslExecutable {
262        self.executable
263    }
264
265    /// `wsl.exe --list --verbose`.
266    ///
267    /// # Errors
268    ///
269    /// [`WslError::Spawn`] when `wsl.exe` is not there — which on a Windows
270    /// host without WSL is the honest answer — and
271    /// [`WslError::CommandFailed`] when it ran and refused.
272    pub fn list(&self) -> Result<DistributionTable, WslError> {
273        let request = CommandRequest::new(self.executable.path())
274            .arg("--list")
275            .arg("--verbose");
276        let output = self.runner.run(&request)?;
277        if !output.success() {
278            // `wsl --list` exits non-zero when WSL is installed but has no
279            // distributions, and the table is then legitimately empty. That is
280            // told apart from a real failure by whether anything parsed.
281            let table = DistributionTable::from_console_output(output.stdout());
282            if table.is_empty() && table.unreadable().is_empty() {
283                return Err(WslError::CommandFailed {
284                    what: "list the installed WSL distributions",
285                    program: self.executable.path().to_path_buf(),
286                    exit_code: output.exit_code(),
287                    detail: output.diagnostic(),
288                });
289            }
290            return Ok(table);
291        }
292        Ok(DistributionTable::from_console_output(output.stdout()))
293    }
294
295    /// Runs one command inside a distribution.
296    ///
297    /// Takes the command **by value**, which is not an accident: a
298    /// [`ChildInput`] is deliberately not `Clone`, so moving it into the
299    /// request is the only way to run it. That both prevents a credential from
300    /// being duplicated on the heap by a stray `clone()` and keeps the
301    /// artifact installer from copying a fifteen-megabyte archive on its way
302    /// to the pipe.
303    ///
304    /// A non-zero exit is returned as a [`CommandOutput`], not as an error:
305    /// several callers here treat "it said no" as information rather than as a
306    /// failure.
307    ///
308    /// # Errors
309    ///
310    /// [`WslError::InvalidName`] before anything is launched;
311    /// [`WslError::Spawn`], [`WslError::SecretInCommandLine`] or
312    /// [`WslError::ChildControl`] from the runner.
313    pub fn exec(&self, command: LinuxCommand) -> Result<CommandOutput, WslError> {
314        validate_distribution_name(&command.distribution)?;
315        let request = CommandRequest::new(self.executable.path())
316            .args(command.wsl_arguments())
317            .with_timeout(command.timeout)
318            .with_limits(command.limits)
319            .with_input(command.input);
320        self.runner.run(&request)
321    }
322
323    /// Runs one command and refuses anything but a clean exit.
324    ///
325    /// # Errors
326    ///
327    /// As [`WslInvoker::exec`], plus [`WslError::CommandFailed`] when the
328    /// Linux program exited non-zero, timed out, or was cancelled.
329    pub fn exec_ok(
330        &self,
331        what: &'static str,
332        command: LinuxCommand,
333    ) -> Result<CommandOutput, WslError> {
334        let program = PathBuf::from(command.program());
335        let output = self.exec(command)?;
336        if output.success() {
337            return Ok(output);
338        }
339        Err(WslError::CommandFailed {
340            what,
341            program,
342            exit_code: output.exit_code(),
343            detail: output.diagnostic(),
344        })
345    }
346}
347
348// ---------------------------------------------------------------------------
349// Readiness
350// ---------------------------------------------------------------------------
351
352/// What `systemctl is-system-running` said.
353#[derive(Debug, Clone, PartialEq, Eq)]
354pub enum SystemdState {
355    /// Fully up.
356    Running,
357    /// Up, with at least one failed unit. Usable: the product's own unit is
358    /// what matters, and `service status` reports it separately.
359    Degraded,
360    /// Still coming up. Usable for the same reason, and the alternative —
361    /// refusing during the first seconds after a cold start — would make the
362    /// preflight flaky rather than strict.
363    Starting,
364    /// Not systemd, or systemd is not the init here. Refused.
365    Unavailable(String),
366}
367
368impl SystemdState {
369    /// Reads the one word `systemctl is-system-running` prints.
370    #[must_use]
371    pub fn from_report(word: &str) -> Self {
372        match word.trim() {
373            "running" => Self::Running,
374            "degraded" => Self::Degraded,
375            "initializing" | "starting" => Self::Starting,
376            "" => Self::Unavailable("it said nothing".to_string()),
377            other => Self::Unavailable(other.to_string()),
378        }
379    }
380
381    /// Whether the Linux service manager can be used.
382    #[must_use]
383    pub fn is_usable(&self) -> bool {
384        matches!(self, Self::Running | Self::Degraded | Self::Starting)
385    }
386}
387
388impl fmt::Display for SystemdState {
389    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
390        match self {
391            Self::Running => f.write_str("running"),
392            Self::Degraded => f.write_str("degraded"),
393            Self::Starting => f.write_str("starting"),
394            Self::Unavailable(word) => write!(f, "unavailable ({word})"),
395        }
396    }
397}
398
399/// Maps `uname -m` onto the architectures the release publishes.
400///
401/// `Arch::Arm32` is deliberately absent: `crates/app/src/cli/update.rs`'s
402/// target table publishes `x86_64-unknown-linux-gnu` and
403/// `aarch64-unknown-linux-gnu` and nothing else, so a 32-bit ARM distribution
404/// has no artifact and must be refused here rather than fail at download.
405///
406/// # Errors
407///
408/// [`WslError::UnsupportedArchitecture`] naming what the distribution said.
409pub fn architecture_from_uname(distribution: &str, machine: &str) -> Result<Arch, WslError> {
410    match machine.trim() {
411        "x86_64" | "amd64" => Ok(Arch::X64),
412        "aarch64" | "arm64" => Ok(Arch::Arm64),
413        other => Err(WslError::UnsupportedArchitecture {
414            distribution: distribution.to_string(),
415            reported: other.to_string(),
416        }),
417    }
418}
419
420/// Everything the preflight established about one distribution.
421#[derive(Debug, Clone, PartialEq, Eq)]
422pub struct DistributionReadiness {
423    name: String,
424    wsl_version: u8,
425    default: bool,
426    architecture: Arch,
427    machine: String,
428    systemd: SystemdState,
429}
430
431impl DistributionReadiness {
432    /// The exact name, as WSL spells it.
433    #[must_use]
434    pub fn name(&self) -> &str {
435        &self.name
436    }
437
438    /// Always 2 — [`probe_readiness`] refuses anything else — but carried so a
439    /// status line can state it rather than assert it.
440    #[must_use]
441    pub fn wsl_version(&self) -> u8 {
442        self.wsl_version
443    }
444
445    /// Whether WSL marks it as the default distribution.
446    #[must_use]
447    pub fn is_default(&self) -> bool {
448        self.default
449    }
450
451    /// The architecture the release artifact must match.
452    #[must_use]
453    pub fn architecture(&self) -> Arch {
454        self.architecture
455    }
456
457    /// What `uname -m` actually said, for a status line.
458    #[must_use]
459    pub fn machine(&self) -> &str {
460        &self.machine
461    }
462
463    /// The service manager's state.
464    #[must_use]
465    pub fn systemd(&self) -> &SystemdState {
466        &self.systemd
467    }
468}
469
470/// Asks the five preflight questions. Mutates nothing.
471///
472/// # Errors
473///
474/// [`WslError::InvalidName`], [`WslError::NotInstalled`],
475/// [`WslError::AmbiguousName`], [`WslError::NotWsl2`],
476/// [`WslError::NoRootAccess`], [`WslError::UnsupportedArchitecture`] or
477/// [`WslError::SystemdUnavailable`] — each naming the one thing an operator
478/// would have to change — plus anything [`WslInvoker::exec`] can report.
479pub fn probe_readiness(
480    invoker: &WslInvoker<'_>,
481    name: &str,
482) -> Result<DistributionReadiness, WslError> {
483    validate_distribution_name(name)?;
484    let table = invoker.list()?;
485    let installed = table.exactly(name)?;
486    installed.require_wsl2()?;
487    let wsl_version = installed.wsl_version();
488    let default = installed.is_default();
489
490    // Root. `id -u` rather than `whoami`, because the answer is a number in
491    // every locale and `whoami`'s is a name that a localised system may
492    // translate.
493    let identity = invoker.exec(LinuxCommand::new(name, "id").args(["-u"]))?;
494    let reported = identity.stdout_text();
495    if !identity.success() || reported != "0" {
496        return Err(WslError::NoRootAccess {
497            distribution: name.to_string(),
498            detail: if identity.success() {
499                format!("`id -u` answered {reported}, not 0")
500            } else {
501                identity.diagnostic()
502            },
503        });
504    }
505
506    let uname = invoker.exec_ok(
507        "read the distribution's architecture",
508        LinuxCommand::new(name, "uname").args(["-m"]),
509    )?;
510    let machine = uname.stdout_text();
511    let architecture = architecture_from_uname(name, &machine)?;
512
513    // `is-system-running` exits non-zero for `degraded` and for `starting`,
514    // both of which are usable, so the word is read rather than the code.
515    let systemd_report =
516        invoker.exec(LinuxCommand::new(name, "systemctl").args(["is-system-running"]))?;
517    let systemd = SystemdState::from_report(&systemd_report.stdout_text());
518    if !systemd.is_usable() {
519        return Err(WslError::SystemdUnavailable {
520            distribution: name.to_string(),
521            detail: match &systemd {
522                SystemdState::Unavailable(word) => {
523                    let stderr = systemd_report.stderr_text();
524                    if stderr.is_empty() {
525                        format!("`systemctl is-system-running` answered `{word}`")
526                    } else {
527                        format!("`systemctl is-system-running` answered `{word}`: {stderr}")
528                    }
529                }
530                other => other.to_string(),
531            },
532        });
533    }
534
535    Ok(DistributionReadiness {
536        name: name.to_string(),
537        wsl_version,
538        default,
539        architecture,
540        machine,
541        systemd,
542    })
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548    use crate::wsl::exec::{PipedInput, ScriptedRunner};
549    use secrecy::{ExposeSecret, SecretString};
550
551    fn executable() -> WslExecutable {
552        WslExecutable::at("wsl.exe")
553    }
554
555    fn table() -> CommandOutput {
556        CommandOutput::exited(
557            0,
558            concat!(
559                "  NAME       STATE       VERSION\n",
560                "* Ubuntu     Running     2\n",
561                "  Legacy     Stopped     1\n",
562            ),
563            "",
564        )
565    }
566
567    /// A runner that answers every preflight question the way a healthy
568    /// `Ubuntu` would.
569    fn healthy() -> ScriptedRunner {
570        ScriptedRunner::new()
571            .always("--list --verbose", table())
572            .always("--exec id -u", CommandOutput::exited(0, "0\n", ""))
573            .always("--exec uname -m", CommandOutput::exited(0, "x86_64\n", ""))
574            .always(
575                "--exec systemctl is-system-running",
576                CommandOutput::exited(0, "running\n", ""),
577            )
578    }
579
580    // -- The invocation shape ------------------------------------------------
581
582    #[test]
583    fn a_linux_command_is_an_argument_vector_with_no_shell_in_it() {
584        let command = LinuxCommand::new("Debian GNU/Linux 12", "/usr/local/bin/runner-manager")
585            .args(["service", "install", "--start-at", "boot"]);
586        assert_eq!(
587            command.wsl_arguments(),
588            vec![
589                "--distribution",
590                "Debian GNU/Linux 12",
591                "--user",
592                "root",
593                "--exec",
594                "/usr/local/bin/runner-manager",
595                "service",
596                "install",
597                "--start-at",
598                "boot",
599            ]
600        );
601    }
602
603    #[test]
604    fn a_hostile_distribution_name_stays_one_argument() {
605        let command = LinuxCommand::new("Ubuntu; rm -rf /", "id").args(["-u"]);
606        let argv = command.wsl_arguments();
607        assert_eq!(argv[1], "Ubuntu; rm -rf /");
608        assert!(
609            argv.iter().all(|argument| argument != "rm"),
610            "the name must never become its own argument: {argv:?}"
611        );
612        // And `--exec` is present, which is what keeps the Linux side from
613        // handing it to a shell.
614        assert!(argv.contains(&"--exec".to_string()));
615    }
616
617    #[test]
618    fn the_invoker_passes_the_vector_through_untouched() {
619        let runner = healthy();
620        let executable = executable();
621        let invoker = WslInvoker::new(&runner, &executable);
622        invoker
623            .exec(LinuxCommand::new("Ubuntu", "id").args(["-u"]))
624            .expect("scripted");
625        let recorded = runner.recorded();
626        assert_eq!(recorded.len(), 1);
627        assert_eq!(
628            recorded[0].arguments,
629            vec![
630                "--distribution",
631                "Ubuntu",
632                "--user",
633                "root",
634                "--exec",
635                "id",
636                "-u"
637            ]
638        );
639    }
640
641    #[test]
642    fn a_name_that_reads_as_an_option_is_refused_before_anything_is_launched() {
643        let runner = ScriptedRunner::new();
644        let executable = executable();
645        let invoker = WslInvoker::new(&runner, &executable);
646        let error = invoker
647            .exec(LinuxCommand::new("--shutdown", "id"))
648            .expect_err("refused");
649        assert!(matches!(error, WslError::InvalidName { .. }), "{error:?}");
650        assert_eq!(runner.call_count(), 0, "nothing may be launched");
651    }
652
653    #[test]
654    fn a_piped_payload_reaches_the_child_and_no_argument() {
655        let secret = SecretString::from(format!("{}{}", "ghu_", "a1ProbeFixtureNotARealToken0000"));
656        let runner = ScriptedRunner::new();
657        let executable = executable();
658        let invoker = WslInvoker::new(&runner, &executable);
659        invoker
660            .exec(
661                LinuxCommand::new("Ubuntu", "/usr/local/bin/runner-manager")
662                    .args(["auth", "receive", "--start-at", "boot"])
663                    .with_input(ChildInput::Piped(PipedInput::from_secret_text(&secret))),
664            )
665            .expect("scripted");
666        assert_eq!(runner.piped_input(), secret.expose_secret().as_bytes());
667        assert!(
668            runner
669                .command_lines()
670                .iter()
671                .all(|line| !line.contains(secret.expose_secret())),
672            "the canary must not be in any command line"
673        );
674    }
675
676    // -- Readiness -----------------------------------------------------------
677
678    #[test]
679    fn a_healthy_distribution_reports_every_fact_it_established() {
680        let runner = healthy();
681        let executable = executable();
682        let ready =
683            probe_readiness(&WslInvoker::new(&runner, &executable), "Ubuntu").expect("healthy");
684        assert_eq!(ready.name(), "Ubuntu");
685        assert_eq!(ready.wsl_version(), 2);
686        assert!(ready.is_default());
687        assert_eq!(ready.architecture(), Arch::X64);
688        assert_eq!(ready.machine(), "x86_64");
689        assert_eq!(ready.systemd(), &SystemdState::Running);
690    }
691
692    #[test]
693    fn a_wsl1_distribution_is_refused_before_any_linux_command_runs() {
694        let runner = healthy();
695        let executable = executable();
696        let error =
697            probe_readiness(&WslInvoker::new(&runner, &executable), "Legacy").expect_err("WSL1");
698        assert!(matches!(error, WslError::NotWsl2 { .. }), "{error:?}");
699        assert_eq!(
700            runner.call_count(),
701            1,
702            "only `--list` should have run: {:?}",
703            runner.command_lines()
704        );
705    }
706
707    #[test]
708    fn a_distribution_that_is_not_installed_lists_the_ones_that_are() {
709        let runner = healthy();
710        let executable = executable();
711        let error =
712            probe_readiness(&WslInvoker::new(&runner, &executable), "Fedora").expect_err("absent");
713        assert!(error.to_string().contains("Ubuntu"), "{error}");
714    }
715
716    #[test]
717    fn a_distribution_that_does_not_start_as_root_is_refused() {
718        let runner = ScriptedRunner::new()
719            .always("--list --verbose", table())
720            .always("--exec id -u", CommandOutput::exited(0, "1000\n", ""));
721        let executable = executable();
722        let error = probe_readiness(&WslInvoker::new(&runner, &executable), "Ubuntu")
723            .expect_err("not root");
724        let WslError::NoRootAccess { detail, .. } = &error else {
725            panic!("unexpected error: {error:?}");
726        };
727        assert!(detail.contains("1000"), "{detail}");
728    }
729
730    #[test]
731    fn an_unsupported_architecture_names_what_the_distribution_said() {
732        let runner = ScriptedRunner::new()
733            .always("--list --verbose", table())
734            .always("--exec id -u", CommandOutput::exited(0, "0\n", ""))
735            .always("--exec uname -m", CommandOutput::exited(0, "armv7l\n", ""));
736        let executable = executable();
737        let error = probe_readiness(&WslInvoker::new(&runner, &executable), "Ubuntu")
738            .expect_err("armv7l has no published artifact");
739        assert!(
740            matches!(&error, WslError::UnsupportedArchitecture { reported, .. } if reported == "armv7l"),
741            "{error:?}"
742        );
743        assert!(error.to_string().contains("armv7l"));
744    }
745
746    #[test]
747    fn architecture_mapping_covers_both_published_linux_targets_and_nothing_else() {
748        assert_eq!(
749            architecture_from_uname("d", "x86_64").expect("x64"),
750            Arch::X64
751        );
752        assert_eq!(
753            architecture_from_uname("d", "amd64").expect("x64"),
754            Arch::X64
755        );
756        assert_eq!(
757            architecture_from_uname("d", "aarch64").expect("arm64"),
758            Arch::Arm64
759        );
760        assert_eq!(
761            architecture_from_uname("d", "arm64\n").expect("arm64"),
762            Arch::Arm64
763        );
764        for machine in ["armv7l", "i686", "riscv64", "s390x", ""] {
765            assert!(
766                architecture_from_uname("d", machine).is_err(),
767                "{machine} has no published Linux artifact"
768            );
769        }
770    }
771
772    #[test]
773    fn a_distribution_without_systemd_is_refused_with_what_it_answered() {
774        let runner = ScriptedRunner::new()
775            .always("--list --verbose", table())
776            .always("--exec id -u", CommandOutput::exited(0, "0\n", ""))
777            .always("--exec uname -m", CommandOutput::exited(0, "x86_64\n", ""))
778            .always(
779                "--exec systemctl is-system-running",
780                CommandOutput::exited(1, "offline\n", ""),
781            );
782        let executable = executable();
783        let error = probe_readiness(&WslInvoker::new(&runner, &executable), "Ubuntu")
784            .expect_err("no systemd");
785        let WslError::SystemdUnavailable { detail, .. } = &error else {
786            panic!("unexpected error: {error:?}");
787        };
788        assert!(detail.contains("offline"), "{detail}");
789    }
790
791    #[test]
792    fn degraded_and_starting_systemd_are_usable_because_the_products_own_unit_is_what_matters() {
793        for word in ["degraded", "starting", "initializing"] {
794            let runner = ScriptedRunner::new()
795                .always("--list --verbose", table())
796                .always("--exec id -u", CommandOutput::exited(0, "0\n", ""))
797                .always("--exec uname -m", CommandOutput::exited(0, "x86_64\n", ""))
798                .always(
799                    "--exec systemctl is-system-running",
800                    CommandOutput::exited(1, format!("{word}\n"), ""),
801                );
802            let executable = executable();
803            let ready = probe_readiness(&WslInvoker::new(&runner, &executable), "Ubuntu")
804                .unwrap_or_else(|error| panic!("{word} should be usable: {error}"));
805            assert!(ready.systemd().is_usable());
806        }
807    }
808
809    #[test]
810    fn the_preflight_mutates_nothing() {
811        // Every command it runs is a read. Stated as a test because the
812        // failure table's first row -- "no mutation and no device login" -- is
813        // otherwise a property of a code path nobody re-reads.
814        let runner = healthy();
815        let executable = executable();
816        probe_readiness(&WslInvoker::new(&runner, &executable), "Ubuntu").expect("healthy");
817        for line in runner.command_lines() {
818            assert!(
819                [
820                    "--list --verbose",
821                    "id -u",
822                    "uname -m",
823                    "systemctl is-system-running"
824                ]
825                .iter()
826                .any(|read| line.contains(read)),
827                "the preflight ran something that is not a read: {line}"
828            );
829        }
830    }
831
832    // -- Locating wsl.exe ----------------------------------------------------
833
834    #[test]
835    fn a_missing_system_root_falls_back_to_the_path_lookup() {
836        assert_eq!(locate_from(None, "wsl.exe"), PathBuf::from("wsl.exe"));
837        assert_eq!(
838            locate_from(Some(PathBuf::from("Q:\\NoSuchWindows")), "wsl.exe"),
839            PathBuf::from("wsl.exe")
840        );
841    }
842
843    #[test]
844    fn a_system_root_that_really_holds_the_executable_is_used() {
845        let root = tempfile::tempdir().expect("a temporary directory");
846        let system32 = root.path().join("System32");
847        std::fs::create_dir_all(&system32).expect("create System32");
848        std::fs::write(system32.join("wsl.exe"), b"not really an executable")
849            .expect("write the stand-in");
850        assert_eq!(
851            locate_from(Some(root.path().to_path_buf()), "wsl.exe"),
852            system32.join("wsl.exe")
853        );
854    }
855}