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    /// Verifies that the WSL platform itself answers even when there are no
296    /// distributions for `--list` to return.
297    pub fn platform_status(&self) -> Result<(), WslError> {
298        let request = CommandRequest::new(self.executable.path()).arg("--status");
299        let output = self.runner.run(&request)?;
300        if output.success() {
301            return Ok(());
302        }
303        Err(WslError::CommandFailed {
304            what: "read WSL platform status",
305            program: self.executable.path().to_path_buf(),
306            exit_code: output.exit_code(),
307            detail: output.diagnostic(),
308        })
309    }
310
311    /// Terminates exactly one validated distribution. This deliberately has
312    /// no whole-WSL counterpart: callers cannot accidentally turn recovery of
313    /// one runner host into `wsl --shutdown` for Docker and every other distro.
314    pub fn terminate_named(&self, distribution: &str) -> Result<(), WslError> {
315        validate_distribution_name(distribution)?;
316        let request = CommandRequest::new(self.executable.path())
317            .arg("--terminate")
318            .arg(distribution);
319        let output = self.runner.run(&request)?;
320        if output.success() {
321            return Ok(());
322        }
323        Err(WslError::CommandFailed {
324            what: "terminate the named WSL distribution",
325            program: self.executable.path().to_path_buf(),
326            exit_code: output.exit_code(),
327            detail: output.diagnostic(),
328        })
329    }
330
331    /// Runs one command inside a distribution.
332    ///
333    /// Takes the command **by value**, which is not an accident: a
334    /// [`ChildInput`] is deliberately not `Clone`, so moving it into the
335    /// request is the only way to run it. That both prevents a credential from
336    /// being duplicated on the heap by a stray `clone()` and keeps the
337    /// artifact installer from copying a fifteen-megabyte archive on its way
338    /// to the pipe.
339    ///
340    /// A non-zero exit is returned as a [`CommandOutput`], not as an error:
341    /// several callers here treat "it said no" as information rather than as a
342    /// failure.
343    ///
344    /// # Errors
345    ///
346    /// [`WslError::InvalidName`] before anything is launched;
347    /// [`WslError::Spawn`], [`WslError::SecretInCommandLine`] or
348    /// [`WslError::ChildControl`] from the runner.
349    pub fn exec(&self, command: LinuxCommand) -> Result<CommandOutput, WslError> {
350        validate_distribution_name(&command.distribution)?;
351        let request = CommandRequest::new(self.executable.path())
352            .args(command.wsl_arguments())
353            .with_timeout(command.timeout)
354            .with_limits(command.limits)
355            .with_input(command.input);
356        self.runner.run(&request)
357    }
358
359    /// Runs one command and refuses anything but a clean exit.
360    ///
361    /// # Errors
362    ///
363    /// As [`WslInvoker::exec`], plus [`WslError::CommandFailed`] when the
364    /// Linux program exited non-zero, timed out, or was cancelled.
365    pub fn exec_ok(
366        &self,
367        what: &'static str,
368        command: LinuxCommand,
369    ) -> Result<CommandOutput, WslError> {
370        let program = PathBuf::from(command.program());
371        let output = self.exec(command)?;
372        if output.success() {
373            return Ok(output);
374        }
375        Err(WslError::CommandFailed {
376            what,
377            program,
378            exit_code: output.exit_code(),
379            detail: output.diagnostic(),
380        })
381    }
382}
383
384// ---------------------------------------------------------------------------
385// Readiness
386// ---------------------------------------------------------------------------
387
388/// What `systemctl is-system-running` said.
389#[derive(Debug, Clone, PartialEq, Eq)]
390pub enum SystemdState {
391    /// Fully up.
392    Running,
393    /// Up, with at least one failed unit. Usable: the product's own unit is
394    /// what matters, and `service status` reports it separately.
395    Degraded,
396    /// Still coming up. Usable for the same reason, and the alternative —
397    /// refusing during the first seconds after a cold start — would make the
398    /// preflight flaky rather than strict.
399    Starting,
400    /// Not systemd, or systemd is not the init here. Refused.
401    Unavailable(String),
402}
403
404impl SystemdState {
405    /// Reads the one word `systemctl is-system-running` prints.
406    #[must_use]
407    pub fn from_report(word: &str) -> Self {
408        match word.trim() {
409            "running" => Self::Running,
410            "degraded" => Self::Degraded,
411            "initializing" | "starting" => Self::Starting,
412            "" => Self::Unavailable("it said nothing".to_string()),
413            other => Self::Unavailable(other.to_string()),
414        }
415    }
416
417    /// Whether the Linux service manager can be used.
418    #[must_use]
419    pub fn is_usable(&self) -> bool {
420        matches!(self, Self::Running | Self::Degraded | Self::Starting)
421    }
422}
423
424impl fmt::Display for SystemdState {
425    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
426        match self {
427            Self::Running => f.write_str("running"),
428            Self::Degraded => f.write_str("degraded"),
429            Self::Starting => f.write_str("starting"),
430            Self::Unavailable(word) => write!(f, "unavailable ({word})"),
431        }
432    }
433}
434
435/// Maps `uname -m` onto the architectures the release publishes.
436///
437/// `Arch::Arm32` is deliberately absent: `crates/app/src/cli/update.rs`'s
438/// target table publishes `x86_64-unknown-linux-gnu` and
439/// `aarch64-unknown-linux-gnu` and nothing else, so a 32-bit ARM distribution
440/// has no artifact and must be refused here rather than fail at download.
441///
442/// # Errors
443///
444/// [`WslError::UnsupportedArchitecture`] naming what the distribution said.
445pub fn architecture_from_uname(distribution: &str, machine: &str) -> Result<Arch, WslError> {
446    match machine.trim() {
447        "x86_64" | "amd64" => Ok(Arch::X64),
448        "aarch64" | "arm64" => Ok(Arch::Arm64),
449        other => Err(WslError::UnsupportedArchitecture {
450            distribution: distribution.to_string(),
451            reported: other.to_string(),
452        }),
453    }
454}
455
456/// Everything the preflight established about one distribution.
457#[derive(Debug, Clone, PartialEq, Eq)]
458pub struct DistributionReadiness {
459    name: String,
460    wsl_version: u8,
461    default: bool,
462    architecture: Arch,
463    machine: String,
464    systemd: SystemdState,
465}
466
467impl DistributionReadiness {
468    /// The exact name, as WSL spells it.
469    #[must_use]
470    pub fn name(&self) -> &str {
471        &self.name
472    }
473
474    /// Always 2 — [`probe_readiness`] refuses anything else — but carried so a
475    /// status line can state it rather than assert it.
476    #[must_use]
477    pub fn wsl_version(&self) -> u8 {
478        self.wsl_version
479    }
480
481    /// Whether WSL marks it as the default distribution.
482    #[must_use]
483    pub fn is_default(&self) -> bool {
484        self.default
485    }
486
487    /// The architecture the release artifact must match.
488    #[must_use]
489    pub fn architecture(&self) -> Arch {
490        self.architecture
491    }
492
493    /// What `uname -m` actually said, for a status line.
494    #[must_use]
495    pub fn machine(&self) -> &str {
496        &self.machine
497    }
498
499    /// The service manager's state.
500    #[must_use]
501    pub fn systemd(&self) -> &SystemdState {
502        &self.systemd
503    }
504}
505
506/// Asks the five preflight questions. Mutates nothing.
507///
508/// # Errors
509///
510/// [`WslError::InvalidName`], [`WslError::NotInstalled`],
511/// [`WslError::AmbiguousName`], [`WslError::NotWsl2`],
512/// [`WslError::NoRootAccess`], [`WslError::UnsupportedArchitecture`] or
513/// [`WslError::SystemdUnavailable`] — each naming the one thing an operator
514/// would have to change — plus anything [`WslInvoker::exec`] can report.
515pub fn probe_readiness(
516    invoker: &WslInvoker<'_>,
517    name: &str,
518) -> Result<DistributionReadiness, WslError> {
519    validate_distribution_name(name)?;
520    let table = invoker.list()?;
521    let installed = table.exactly(name)?;
522    installed.require_wsl2()?;
523    let wsl_version = installed.wsl_version();
524    let default = installed.is_default();
525
526    // Root. `id -u` rather than `whoami`, because the answer is a number in
527    // every locale and `whoami`'s is a name that a localised system may
528    // translate.
529    let identity = invoker.exec(LinuxCommand::new(name, "id").args(["-u"]))?;
530    if !identity.success() {
531        // A failed `wsl.exe --user root --exec id -u` does not establish
532        // anything about root. In particular, WSL service/VM startup errors
533        // are returned as this command's stderr and used to be mislabeled as
534        // a permanently ineligible distribution. Preserve the transport
535        // failure as a retryable provisioning error instead.
536        return Err(WslError::CommandFailed {
537            what: "verify root access in the distribution",
538            program: PathBuf::from("id"),
539            exit_code: identity.exit_code(),
540            detail: identity.diagnostic(),
541        });
542    }
543    let reported = identity.stdout_text();
544    if reported != "0" {
545        return Err(WslError::NoRootAccess {
546            distribution: name.to_string(),
547            detail: format!("`id -u` answered {reported}, not 0"),
548        });
549    }
550
551    let uname = invoker.exec_ok(
552        "read the distribution's architecture",
553        LinuxCommand::new(name, "uname").args(["-m"]),
554    )?;
555    let machine = uname.stdout_text();
556    let architecture = architecture_from_uname(name, &machine)?;
557
558    // `is-system-running` exits non-zero for `degraded` and for `starting`,
559    // both of which are usable, so the word is read rather than the code.
560    let systemd_report =
561        invoker.exec(LinuxCommand::new(name, "systemctl").args(["is-system-running"]))?;
562    let systemd = SystemdState::from_report(&systemd_report.stdout_text());
563    if !systemd.is_usable() {
564        return Err(WslError::SystemdUnavailable {
565            distribution: name.to_string(),
566            detail: match &systemd {
567                SystemdState::Unavailable(word) => {
568                    let stderr = systemd_report.stderr_text();
569                    if stderr.is_empty() {
570                        format!("`systemctl is-system-running` answered `{word}`")
571                    } else {
572                        format!("`systemctl is-system-running` answered `{word}`: {stderr}")
573                    }
574                }
575                other => other.to_string(),
576            },
577        });
578    }
579
580    Ok(DistributionReadiness {
581        name: name.to_string(),
582        wsl_version,
583        default,
584        architecture,
585        machine,
586        systemd,
587    })
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593    use crate::wsl::exec::{PipedInput, ScriptedRunner};
594    use secrecy::{ExposeSecret, SecretString};
595
596    fn executable() -> WslExecutable {
597        WslExecutable::at("wsl.exe")
598    }
599
600    fn table() -> CommandOutput {
601        CommandOutput::exited(
602            0,
603            concat!(
604                "  NAME       STATE       VERSION\n",
605                "* Ubuntu     Running     2\n",
606                "  Legacy     Stopped     1\n",
607            ),
608            "",
609        )
610    }
611
612    /// A runner that answers every preflight question the way a healthy
613    /// `Ubuntu` would.
614    fn healthy() -> ScriptedRunner {
615        ScriptedRunner::new()
616            .always("--list --verbose", table())
617            .always("--exec id -u", CommandOutput::exited(0, "0\n", ""))
618            .always("--exec uname -m", CommandOutput::exited(0, "x86_64\n", ""))
619            .always(
620                "--exec systemctl is-system-running",
621                CommandOutput::exited(0, "running\n", ""),
622            )
623    }
624
625    // -- The invocation shape ------------------------------------------------
626
627    #[test]
628    fn a_linux_command_is_an_argument_vector_with_no_shell_in_it() {
629        let command = LinuxCommand::new("Debian GNU/Linux 12", "/usr/local/bin/runner-manager")
630            .args(["service", "install", "--start-at", "boot"]);
631        assert_eq!(
632            command.wsl_arguments(),
633            vec![
634                "--distribution",
635                "Debian GNU/Linux 12",
636                "--user",
637                "root",
638                "--exec",
639                "/usr/local/bin/runner-manager",
640                "service",
641                "install",
642                "--start-at",
643                "boot",
644            ]
645        );
646    }
647
648    #[test]
649    fn a_hostile_distribution_name_stays_one_argument() {
650        let command = LinuxCommand::new("Ubuntu; rm -rf /", "id").args(["-u"]);
651        let argv = command.wsl_arguments();
652        assert_eq!(argv[1], "Ubuntu; rm -rf /");
653        assert!(
654            argv.iter().all(|argument| argument != "rm"),
655            "the name must never become its own argument: {argv:?}"
656        );
657        // And `--exec` is present, which is what keeps the Linux side from
658        // handing it to a shell.
659        assert!(argv.contains(&"--exec".to_string()));
660    }
661
662    #[test]
663    fn the_invoker_passes_the_vector_through_untouched() {
664        let runner = healthy();
665        let executable = executable();
666        let invoker = WslInvoker::new(&runner, &executable);
667        invoker
668            .exec(LinuxCommand::new("Ubuntu", "id").args(["-u"]))
669            .expect("scripted");
670        let recorded = runner.recorded();
671        assert_eq!(recorded.len(), 1);
672        assert_eq!(
673            recorded[0].arguments,
674            vec![
675                "--distribution",
676                "Ubuntu",
677                "--user",
678                "root",
679                "--exec",
680                "id",
681                "-u"
682            ]
683        );
684    }
685
686    #[test]
687    fn a_name_that_reads_as_an_option_is_refused_before_anything_is_launched() {
688        let runner = ScriptedRunner::new();
689        let executable = executable();
690        let invoker = WslInvoker::new(&runner, &executable);
691        let error = invoker
692            .exec(LinuxCommand::new("--shutdown", "id"))
693            .expect_err("refused");
694        assert!(matches!(error, WslError::InvalidName { .. }), "{error:?}");
695        assert_eq!(runner.call_count(), 0, "nothing may be launched");
696    }
697
698    #[test]
699    fn recovery_can_terminate_only_one_validated_named_distribution() {
700        let runner =
701            ScriptedRunner::new().always("--terminate Ubuntu", CommandOutput::exited(0, "", ""));
702        let executable = executable();
703        let invoker = WslInvoker::new(&runner, &executable);
704        invoker
705            .terminate_named("Ubuntu")
706            .expect("named termination");
707        assert_eq!(
708            runner.recorded()[0].arguments,
709            vec!["--terminate", "Ubuntu"]
710        );
711        assert!(
712            runner
713                .recorded()
714                .iter()
715                .all(|request| !request.arguments.iter().any(|arg| arg == "--shutdown"))
716        );
717
718        let error = invoker
719            .terminate_named("--shutdown")
720            .expect_err("option-like names are refused");
721        assert!(matches!(error, WslError::InvalidName { .. }));
722        assert_eq!(runner.call_count(), 1);
723    }
724
725    #[test]
726    fn a_piped_payload_reaches_the_child_and_no_argument() {
727        let secret = SecretString::from(format!("{}{}", "ghu_", "a1ProbeFixtureNotARealToken0000"));
728        let runner = ScriptedRunner::new();
729        let executable = executable();
730        let invoker = WslInvoker::new(&runner, &executable);
731        invoker
732            .exec(
733                LinuxCommand::new("Ubuntu", "/usr/local/bin/runner-manager")
734                    .args(["auth", "receive", "--start-at", "boot"])
735                    .with_input(ChildInput::Piped(PipedInput::from_secret_text(&secret))),
736            )
737            .expect("scripted");
738        assert_eq!(runner.piped_input(), secret.expose_secret().as_bytes());
739        assert!(
740            runner
741                .command_lines()
742                .iter()
743                .all(|line| !line.contains(secret.expose_secret())),
744            "the canary must not be in any command line"
745        );
746    }
747
748    // -- Readiness -----------------------------------------------------------
749
750    #[test]
751    fn a_healthy_distribution_reports_every_fact_it_established() {
752        let runner = healthy();
753        let executable = executable();
754        let ready =
755            probe_readiness(&WslInvoker::new(&runner, &executable), "Ubuntu").expect("healthy");
756        assert_eq!(ready.name(), "Ubuntu");
757        assert_eq!(ready.wsl_version(), 2);
758        assert!(ready.is_default());
759        assert_eq!(ready.architecture(), Arch::X64);
760        assert_eq!(ready.machine(), "x86_64");
761        assert_eq!(ready.systemd(), &SystemdState::Running);
762    }
763
764    #[test]
765    fn a_wsl1_distribution_is_refused_before_any_linux_command_runs() {
766        let runner = healthy();
767        let executable = executable();
768        let error =
769            probe_readiness(&WslInvoker::new(&runner, &executable), "Legacy").expect_err("WSL1");
770        assert!(matches!(error, WslError::NotWsl2 { .. }), "{error:?}");
771        assert_eq!(
772            runner.call_count(),
773            1,
774            "only `--list` should have run: {:?}",
775            runner.command_lines()
776        );
777    }
778
779    #[test]
780    fn a_distribution_that_is_not_installed_lists_the_ones_that_are() {
781        let runner = healthy();
782        let executable = executable();
783        let error =
784            probe_readiness(&WslInvoker::new(&runner, &executable), "Fedora").expect_err("absent");
785        assert!(error.to_string().contains("Ubuntu"), "{error}");
786    }
787
788    #[test]
789    fn a_distribution_that_does_not_start_as_root_is_refused() {
790        let runner = ScriptedRunner::new()
791            .always("--list --verbose", table())
792            .always("--exec id -u", CommandOutput::exited(0, "1000\n", ""));
793        let executable = executable();
794        let error = probe_readiness(&WslInvoker::new(&runner, &executable), "Ubuntu")
795            .expect_err("not root");
796        let WslError::NoRootAccess { detail, .. } = &error else {
797            panic!("unexpected error: {error:?}");
798        };
799        assert!(detail.contains("1000"), "{detail}");
800    }
801
802    #[test]
803    fn a_wsl_startup_failure_is_not_mislabeled_as_missing_root_access() {
804        let diagnostic = concat!(
805            "A connection attempt failed because the connected party did not respond.\n",
806            "Error code: Wsl/Service/0x8007274c\n",
807        );
808        let runner = ScriptedRunner::new()
809            .always("--list --verbose", table())
810            .always("--exec id -u", CommandOutput::exited(-1, "", diagnostic));
811        let executable = executable();
812        let error = probe_readiness(&WslInvoker::new(&runner, &executable), "Ubuntu")
813            .expect_err("WSL startup failed");
814
815        let WslError::CommandFailed {
816            what,
817            exit_code,
818            detail,
819            ..
820        } = &error
821        else {
822            panic!("unexpected error: {error:?}");
823        };
824        assert_eq!(*what, "verify root access in the distribution");
825        assert_eq!(*exit_code, Some(-1));
826        assert!(detail.contains("Wsl/Service/0x8007274c"), "{detail}");
827        assert!(
828            !error.to_string().contains("does not start as root"),
829            "{error}"
830        );
831    }
832
833    #[test]
834    fn an_unsupported_architecture_names_what_the_distribution_said() {
835        let runner = ScriptedRunner::new()
836            .always("--list --verbose", table())
837            .always("--exec id -u", CommandOutput::exited(0, "0\n", ""))
838            .always("--exec uname -m", CommandOutput::exited(0, "armv7l\n", ""));
839        let executable = executable();
840        let error = probe_readiness(&WslInvoker::new(&runner, &executable), "Ubuntu")
841            .expect_err("armv7l has no published artifact");
842        assert!(
843            matches!(&error, WslError::UnsupportedArchitecture { reported, .. } if reported == "armv7l"),
844            "{error:?}"
845        );
846        assert!(error.to_string().contains("armv7l"));
847    }
848
849    #[test]
850    fn architecture_mapping_covers_both_published_linux_targets_and_nothing_else() {
851        assert_eq!(
852            architecture_from_uname("d", "x86_64").expect("x64"),
853            Arch::X64
854        );
855        assert_eq!(
856            architecture_from_uname("d", "amd64").expect("x64"),
857            Arch::X64
858        );
859        assert_eq!(
860            architecture_from_uname("d", "aarch64").expect("arm64"),
861            Arch::Arm64
862        );
863        assert_eq!(
864            architecture_from_uname("d", "arm64\n").expect("arm64"),
865            Arch::Arm64
866        );
867        for machine in ["armv7l", "i686", "riscv64", "s390x", ""] {
868            assert!(
869                architecture_from_uname("d", machine).is_err(),
870                "{machine} has no published Linux artifact"
871            );
872        }
873    }
874
875    #[test]
876    fn a_distribution_without_systemd_is_refused_with_what_it_answered() {
877        let runner = ScriptedRunner::new()
878            .always("--list --verbose", table())
879            .always("--exec id -u", CommandOutput::exited(0, "0\n", ""))
880            .always("--exec uname -m", CommandOutput::exited(0, "x86_64\n", ""))
881            .always(
882                "--exec systemctl is-system-running",
883                CommandOutput::exited(1, "offline\n", ""),
884            );
885        let executable = executable();
886        let error = probe_readiness(&WslInvoker::new(&runner, &executable), "Ubuntu")
887            .expect_err("no systemd");
888        let WslError::SystemdUnavailable { detail, .. } = &error else {
889            panic!("unexpected error: {error:?}");
890        };
891        assert!(detail.contains("offline"), "{detail}");
892    }
893
894    #[test]
895    fn degraded_and_starting_systemd_are_usable_because_the_products_own_unit_is_what_matters() {
896        for word in ["degraded", "starting", "initializing"] {
897            let runner = ScriptedRunner::new()
898                .always("--list --verbose", table())
899                .always("--exec id -u", CommandOutput::exited(0, "0\n", ""))
900                .always("--exec uname -m", CommandOutput::exited(0, "x86_64\n", ""))
901                .always(
902                    "--exec systemctl is-system-running",
903                    CommandOutput::exited(1, format!("{word}\n"), ""),
904                );
905            let executable = executable();
906            let ready = probe_readiness(&WslInvoker::new(&runner, &executable), "Ubuntu")
907                .unwrap_or_else(|error| panic!("{word} should be usable: {error}"));
908            assert!(ready.systemd().is_usable());
909        }
910    }
911
912    #[test]
913    fn the_preflight_mutates_nothing() {
914        // Every command it runs is a read. Stated as a test because the
915        // failure table's first row -- "no mutation and no device login" -- is
916        // otherwise a property of a code path nobody re-reads.
917        let runner = healthy();
918        let executable = executable();
919        probe_readiness(&WslInvoker::new(&runner, &executable), "Ubuntu").expect("healthy");
920        for line in runner.command_lines() {
921            assert!(
922                [
923                    "--list --verbose",
924                    "id -u",
925                    "uname -m",
926                    "systemctl is-system-running"
927                ]
928                .iter()
929                .any(|read| line.contains(read)),
930                "the preflight ran something that is not a read: {line}"
931            );
932        }
933    }
934
935    // -- Locating wsl.exe ----------------------------------------------------
936
937    #[test]
938    fn a_missing_system_root_falls_back_to_the_path_lookup() {
939        assert_eq!(locate_from(None, "wsl.exe"), PathBuf::from("wsl.exe"));
940        assert_eq!(
941            locate_from(Some(PathBuf::from("Q:\\NoSuchWindows")), "wsl.exe"),
942            PathBuf::from("wsl.exe")
943        );
944    }
945
946    #[test]
947    fn a_system_root_that_really_holds_the_executable_is_used() {
948        let root = tempfile::tempdir().expect("a temporary directory");
949        let system32 = root.path().join("System32");
950        std::fs::create_dir_all(&system32).expect("create System32");
951        std::fs::write(system32.join("wsl.exe"), b"not really an executable")
952            .expect("write the stand-in");
953        assert_eq!(
954            locate_from(Some(root.path().to_path_buf()), "wsl.exe"),
955            system32.join("wsl.exe")
956        );
957    }
958}