Skip to main content

runner_manager_platform/wsl/
mod.rs

1// owner: a1-wsl-platform-adapter
2
3//! Managing a named WSL2 distribution as a first-class host: discovery,
4//! invocation, preflight, artifact install, the Windows lifecycle task, and
5//! the non-secret provider record.
6//!
7//! # What this module is, and what it deliberately is not
8//!
9//! It is the platform half of the managed WSL host feature — the part that
10//! knows about `wsl.exe`, `schtasks.exe`, ext4 renames and UTF-16 console
11//! output. The orchestration above it (the `wsl` command surface, the
12//! credential broker, the device flow) lives in the CLI, because none of that
13//! is platform-specific.
14//!
15//! `02-target-architecture.md` draws the line in one sentence: *"No PowerShell
16//! script, registry mutation, `.wslconfig` rewrite or distribution
17//! installation is hidden behind this adapter."* Nothing here writes the
18//! registry, edits `.wslconfig`, installs or unregisters a distribution, or
19//! runs a shell. The complete list of programs this module can start is
20//! `wsl.exe` and `schtasks.exe`, and everything either of them is asked to do
21//! is an argument vector built in one place.
22//!
23//! | Module | What it owns |
24//! |---|---|
25//! | [`exec`] | Literal-argv invocation, bounded capture, deadline, cancellation, and the anonymous stdin pipe a credential crosses on |
26//! | [`discovery`] | Decoding `wsl.exe`'s UTF-16/UTF-8 output, and reading `--list --verbose` into exact names |
27//! | [`probe`] | Selecting a distribution and the five preflight questions |
28//! | [`artifact`] | Exact-version release selection, SHA-256 verification, and the atomic install inside the distribution |
29//! | [`task`] | The per-distribution Windows login task: render, register, query, detach |
30//! | [`record`] | The non-secret provider record under the config directory |
31//!
32//! # Every build has this module; only Windows has a host to run it on
33//!
34//! `02-target-architecture.md` requires that on a non-Windows build `wsl` and
35//! `--host wsl:…` *"fail with an actionable unsupported-platform error rather
36//! than disappearing from help"*. A `#[cfg(windows)]` module would give the
37//! opposite: a command that exists on one platform and is a compile error to
38//! mention on the others.
39//!
40//! So the model is compiled everywhere and only [`WslHost::on_this_host`]
41//! refuses, with [`WslError::UnsupportedPlatform`]. That has a second benefit
42//! that is worth as much: the parsing, the rendering, the record and the
43//! argument vectors are all exercised by `cargo test` on the Linux and macOS
44//! CI legs, rather than by the one leg that has WSL.
45//!
46//! # Where the credential is, and is not
47//!
48//! `03-security-and-lifecycle.md` item 3 requires the stored credential
49//! document to cross the boundary *only* through an anonymous stdin pipe, and
50//! to be absent from argv, environment, provider records, logs, errors, status
51//! JSON, temporary files and scheduled-task XML. This module's part of that:
52//!
53//! * [`exec::PipedInput`] is the only way to give a child bytes, its `Debug`
54//!   prints a length, and [`exec::CommandRequest`] has no environment API at
55//!   all;
56//! * [`exec::CommandRequest::refuse_payload_in_argv`] refuses the launch when
57//!   the payload is also in the command line;
58//! * [`task::LifecycleTask`] has no field that could hold one, and the only
59//!   temporary file this module writes is that task's document;
60//! * [`record::WslProviderRecord`] has five non-secret fields and
61//!   `deny_unknown_fields`.
62//!
63//! `crates/platform/tests/no_wsl_credential_outside_child_stdin.rs` is the
64//! test that puts a canary through the whole path and looks everywhere else.
65
66pub mod artifact;
67pub mod discovery;
68pub mod exec;
69pub mod probe;
70pub mod record;
71pub mod task;
72
73use std::fmt;
74use std::path::PathBuf;
75
76use exec::{CommandRunner, HostCommandRunner};
77use probe::{WslExecutable, WslInvoker};
78use task::LifecycleTaskControl;
79
80/// Anything that can go wrong managing a WSL distribution.
81///
82/// One enum rather than one per module: every variant here is something an
83/// operator reads on their own terminal, and a chain of `From` conversions
84/// between six error types would add wrapping without adding a single fact.
85/// The variants are ordered as the work is: platform, process, discovery,
86/// preflight, artifact, task, record.
87#[derive(Debug, thiserror::Error)]
88pub enum WslError {
89    /// This build is not for Windows, so there is no WSL to manage.
90    #[error(
91        "{operation} is a Windows feature: WSL runs on Windows, and this is a {} build. \
92         Manage this host's own operating system with the ordinary commands instead.",
93        std::env::consts::OS
94    )]
95    UnsupportedPlatform {
96        /// What the caller was trying to do.
97        operation: &'static str,
98    },
99
100    /// The program could not be launched at all.
101    #[error("cannot start {}: {source}", program.display())]
102    Spawn {
103        /// The program that could not be launched.
104        program: PathBuf,
105        /// The underlying error.
106        #[source]
107        source: std::io::Error,
108    },
109
110    /// Waiting on or killing a child failed.
111    #[error("cannot control {}: {source}", program.display())]
112    ChildControl {
113        /// The program that could not be waited on.
114        program: PathBuf,
115        /// The underlying error.
116        #[source]
117        source: std::io::Error,
118    },
119
120    /// The stdin payload was about to be visible in a process listing.
121    ///
122    /// Deliberately does not quote the payload: an error message is one of the
123    /// places `03-security-and-lifecycle.md` says it must not appear.
124    #[error(
125        "refusing to start {}: the value meant for this process's stdin also appears in \
126         {location}, which would put it in this machine's process listing. Pass it on stdin \
127         only (`03-security-and-lifecycle.md`, item 3).",
128        program.display()
129    )]
130    SecretInCommandLine {
131        /// The program that would have been launched.
132        program: PathBuf,
133        /// Where the payload was found.
134        location: String,
135    },
136
137    /// A program ran and refused.
138    #[error("cannot {what} using {}: {detail}", program.display())]
139    CommandFailed {
140        /// What was being attempted.
141        what: &'static str,
142        /// The program that refused.
143        program: PathBuf,
144        /// Its exit code, when it had one.
145        exit_code: Option<i32>,
146        /// What it said.
147        detail: String,
148    },
149
150    /// The distribution name cannot be used at all.
151    #[error("{requested:?} is not a usable distribution name: {reason}")]
152    InvalidName {
153        /// What was asked for.
154        requested: String,
155        /// Which rule it broke.
156        reason: String,
157    },
158
159    /// No distribution of that name is installed.
160    #[error(
161        "no WSL distribution named {requested:?} is installed{}",
162        if available.is_empty() {
163            ". This host has none.".to_string()
164        } else {
165            format!(". This host has: {}. Names are matched exactly.", available.join(", "))
166        }
167    )]
168    NotInstalled {
169        /// What was asked for.
170        requested: String,
171        /// What is really there.
172        available: Vec<String>,
173    },
174
175    /// Two rows carry the name, so there is nothing safe to act on.
176    #[error(
177        "`wsl --list --verbose` reports {requested:?} twice, so this cannot tell which one \
178         was meant. Rename one of them."
179    )]
180    AmbiguousName {
181        /// The name that appeared twice.
182        requested: String,
183    },
184
185    /// The distribution is not WSL2.
186    #[error(
187        "{distribution} is WSL version {version}; this feature supports WSL2 only, because a \
188         WSL1 distribution has neither systemd nor a Linux kernel. \
189         Convert it with `wsl --set-version {distribution} 2`."
190    )]
191    NotWsl2 {
192        /// The distribution.
193        distribution: String,
194        /// The version WSL reported.
195        version: u8,
196    },
197
198    /// The distribution does not start as root.
199    #[error(
200        "{distribution} does not start as root, so the provider cannot install a system \
201         service or write /usr/local/bin in it: {detail}"
202    )]
203    NoRootAccess {
204        /// The distribution.
205        distribution: String,
206        /// What `id -u` said.
207        detail: String,
208    },
209
210    /// The distribution's architecture has no published artifact.
211    #[error(
212        "{distribution} reports the architecture {reported:?}, and runner-manager publishes no \
213         Linux release for it. Only x86-64 and 64-bit ARM are published."
214    )]
215    UnsupportedArchitecture {
216        /// The distribution.
217        distribution: String,
218        /// What `uname -m` said.
219        reported: String,
220    },
221
222    /// systemd is not running the distribution.
223    #[error(
224        "{distribution} is not running systemd, and the Linux runner-manager service is a \
225         systemd unit: {detail}. Enable it with `systemd=true` under `[boot]` in \
226         /etc/wsl.conf inside the distribution, then `wsl --terminate {distribution}`."
227    )]
228    SystemdUnavailable {
229        /// The distribution.
230        distribution: String,
231        /// What `systemctl` said.
232        detail: String,
233    },
234
235    /// The checksum document could not be read.
236    #[error("the release checksum document cannot be used: {detail}")]
237    UnreadableChecksums {
238        /// Why not.
239        detail: String,
240    },
241
242    /// The release publishes nothing for this version and architecture.
243    #[error(
244        "the release publishes no {triple} archive for version {version} (it publishes \
245         {published} assets), so there is no Linux binary to install that matches this \
246         Windows build."
247    )]
248    NoSuchArtifact {
249        /// The version that was asked for.
250        version: String,
251        /// The target triple that was asked for.
252        triple: String,
253        /// How many assets the document did list.
254        published: usize,
255    },
256
257    /// The release publishes more than one archive for this target.
258    #[error(
259        "the release publishes {count} {triple} archives for version {version}; refusing to \
260         guess which one is meant."
261    )]
262    AmbiguousArtifact {
263        /// The version that was asked for.
264        version: String,
265        /// The target triple.
266        triple: String,
267        /// How many matched.
268        count: usize,
269    },
270
271    /// The archive on disk could not be read.
272    #[error("the release archive at {} cannot be used: {detail}", path.display())]
273    UnreadableArchive {
274        /// The archive.
275        path: PathBuf,
276        /// Why not.
277        detail: String,
278    },
279
280    /// The archive is not the one that was published.
281    #[error(
282        "the release archive at {} hashes to {actual}, and the release says it should be \
283         {expected}. Nothing has been installed.",
284        path.display()
285    )]
286    DigestMismatch {
287        /// The archive.
288        path: PathBuf,
289        /// What the release published.
290        expected: String,
291        /// What it really hashes to.
292        actual: String,
293    },
294
295    /// The destination path cannot be installed to.
296    #[error("{path:?} is not a usable Linux destination: {reason}")]
297    InvalidDestination {
298        /// What was asked for.
299        path: String,
300        /// Which rule it broke.
301        reason: String,
302    },
303
304    /// The unpacked binary is not the version that was selected.
305    #[error(
306        "the unpacked binary reports {reported:?}, not version {expected}. It has not been \
307         installed and the existing binary is untouched."
308    )]
309    VersionMismatch {
310        /// The version that was selected.
311        expected: String,
312        /// What the binary said about itself.
313        reported: String,
314    },
315
316    /// A task of the product's name exists and is somebody else's.
317    #[error("the scheduled task {name} is not this product's, so it will not be changed: {detail}")]
318    ForeignTask {
319        /// The task name.
320        name: String,
321        /// Why it was judged foreign, and what to do.
322        detail: String,
323    },
324
325    /// There is no such task registered.
326    #[error("no scheduled task named {name} is registered on this host")]
327    NoSuchTask {
328        /// The task name.
329        name: String,
330    },
331
332    /// Task Scheduler refused.
333    #[error("cannot {operation} the scheduled task {name}: {detail}")]
334    TaskControl {
335        /// What was being attempted.
336        operation: &'static str,
337        /// The task name.
338        name: String,
339        /// What `schtasks` said.
340        detail: String,
341    },
342
343    /// Task Scheduler refused for want of privilege.
344    #[error(
345        "cannot {operation} the scheduled task {name} without elevation: {detail}. Run this \
346         command from an elevated prompt."
347    )]
348    NeedsElevation {
349        /// What was being attempted.
350        operation: &'static str,
351        /// The task name.
352        name: String,
353        /// What `schtasks` said.
354        detail: String,
355    },
356
357    /// A provider record could not be read or written.
358    #[error("cannot {operation} the provider record at {}: {detail}", path.display())]
359    Record {
360        /// What was being attempted.
361        operation: &'static str,
362        /// The record.
363        path: PathBuf,
364        /// Why not.
365        detail: String,
366    },
367
368    /// A provider record was written by a version this one does not know.
369    #[error(
370        "the provider record at {} was written under schema version {found}, and this build \
371         understands version {supported}. Refusing to read it rather than silently dropping \
372         what it does not understand; upgrade runner-manager.",
373        path.display()
374    )]
375    RecordSchema {
376        /// The record.
377        path: PathBuf,
378        /// The version in the file.
379        found: u32,
380        /// The version this build writes.
381        supported: u32,
382    },
383}
384
385impl WslError {
386    /// A short, stable token for a status document or a log field.
387    ///
388    /// Stable across message rewordings, which the prose above is not.
389    #[must_use]
390    pub fn kind(&self) -> &'static str {
391        match self {
392            Self::UnsupportedPlatform { .. } => "unsupported_platform",
393            Self::Spawn { .. } => "spawn",
394            Self::ChildControl { .. } => "child_control",
395            Self::SecretInCommandLine { .. } => "secret_in_command_line",
396            Self::CommandFailed { .. } => "command_failed",
397            Self::InvalidName { .. } => "invalid_name",
398            Self::NotInstalled { .. } => "not_installed",
399            Self::AmbiguousName { .. } => "ambiguous_name",
400            Self::NotWsl2 { .. } => "not_wsl2",
401            Self::NoRootAccess { .. } => "no_root_access",
402            Self::UnsupportedArchitecture { .. } => "unsupported_architecture",
403            Self::SystemdUnavailable { .. } => "systemd_unavailable",
404            Self::UnreadableChecksums { .. } => "unreadable_checksums",
405            Self::NoSuchArtifact { .. } => "no_such_artifact",
406            Self::AmbiguousArtifact { .. } => "ambiguous_artifact",
407            Self::UnreadableArchive { .. } => "unreadable_archive",
408            Self::DigestMismatch { .. } => "digest_mismatch",
409            Self::InvalidDestination { .. } => "invalid_destination",
410            Self::VersionMismatch { .. } => "version_mismatch",
411            Self::ForeignTask { .. } => "foreign_task",
412            Self::NoSuchTask { .. } => "no_such_task",
413            Self::TaskControl { .. } => "task_control",
414            Self::NeedsElevation { .. } => "needs_elevation",
415            Self::Record { .. } => "record",
416            Self::RecordSchema { .. } => "record_schema",
417        }
418    }
419
420    /// Whether this failure happened before anything was changed.
421    ///
422    /// The column `03-security-and-lifecycle.md`'s failure table is really
423    /// about: an operator wants to know whether to clean something up before
424    /// rerunning, and for every variant here the answer is "no" — the
425    /// mutating steps report [`Self::CommandFailed`], [`Self::TaskControl`] or
426    /// [`Self::Record`], and each of those is documented at its call site with
427    /// what it left behind.
428    #[must_use]
429    pub fn is_preflight(&self) -> bool {
430        matches!(
431            self,
432            Self::UnsupportedPlatform { .. }
433                | Self::SecretInCommandLine { .. }
434                | Self::InvalidName { .. }
435                | Self::NotInstalled { .. }
436                | Self::AmbiguousName { .. }
437                | Self::NotWsl2 { .. }
438                | Self::NoRootAccess { .. }
439                | Self::UnsupportedArchitecture { .. }
440                | Self::SystemdUnavailable { .. }
441                | Self::UnreadableChecksums { .. }
442                | Self::NoSuchArtifact { .. }
443                | Self::AmbiguousArtifact { .. }
444                | Self::UnreadableArchive { .. }
445                | Self::DigestMismatch { .. }
446                | Self::InvalidDestination { .. }
447        )
448    }
449}
450
451/// Whether this build can manage a WSL distribution at all.
452///
453/// # Errors
454///
455/// [`WslError::UnsupportedPlatform`] on every build that is not for Windows.
456pub fn require_windows(operation: &'static str) -> Result<(), WslError> {
457    if cfg!(windows) {
458        return Ok(());
459    }
460    Err(WslError::UnsupportedPlatform { operation })
461}
462
463/// The WSL adapter bound to a command runner.
464///
465/// Production builds one with [`WslHost::on_this_host`], which refuses off
466/// Windows. Tests build one with [`WslHost::with_runner`] and drive the whole
467/// adapter from a script, on any platform.
468pub struct WslHost {
469    runner: Box<dyn CommandRunner>,
470    executable: WslExecutable,
471}
472
473impl WslHost {
474    /// The real `wsl.exe` on this host.
475    ///
476    /// # Errors
477    ///
478    /// [`WslError::UnsupportedPlatform`] when this is not a Windows build.
479    pub fn on_this_host(operation: &'static str) -> Result<Self, WslError> {
480        require_windows(operation)?;
481        Ok(Self {
482            runner: Box::new(HostCommandRunner),
483            executable: WslExecutable::locate(),
484        })
485    }
486
487    /// An adapter over an injected runner, for a test or a fixture.
488    #[must_use]
489    pub fn with_runner(runner: Box<dyn CommandRunner>, executable: WslExecutable) -> Self {
490        Self { runner, executable }
491    }
492
493    /// The `wsl.exe` this will run.
494    #[must_use]
495    pub fn executable(&self) -> &WslExecutable {
496        &self.executable
497    }
498
499    /// Runs `wsl.exe` and the commands inside a distribution.
500    #[must_use]
501    pub fn invoker(&self) -> WslInvoker<'_> {
502        WslInvoker::new(self.runner.as_ref(), &self.executable)
503    }
504
505    /// Registers, reads and removes the Windows lifecycle task.
506    #[must_use]
507    pub fn tasks(&self) -> LifecycleTaskControl<'_> {
508        LifecycleTaskControl::new(self.runner.as_ref())
509    }
510}
511
512impl fmt::Debug for WslHost {
513    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
514        f.debug_struct("WslHost")
515            .field("executable", &self.executable)
516            .finish_non_exhaustive()
517    }
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    #[test]
525    fn a_non_windows_build_refuses_with_a_sentence_rather_than_not_compiling() {
526        let result = require_windows("`runner-manager wsl install`");
527        if cfg!(windows) {
528            assert!(result.is_ok());
529        } else {
530            let error = result.expect_err("not Windows");
531            assert_eq!(error.kind(), "unsupported_platform");
532            let message = error.to_string();
533            assert!(
534                message.contains("`runner-manager wsl install`"),
535                "{message}"
536            );
537            assert!(message.contains(std::env::consts::OS), "{message}");
538        }
539    }
540
541    #[test]
542    fn the_whole_model_is_available_on_every_platform() {
543        // The point of not `cfg`-gating the module: a non-Windows build can
544        // still name the types, render the documents and parse the tables, so
545        // the CI legs that are not Windows are really testing this feature.
546        let identity = task::LifecycleTaskIdentity::for_distribution("Ubuntu").expect("valid");
547        assert!(identity.name().starts_with(task::LIFECYCLE_TASK_PREFIX));
548        assert!(!discovery::DistributionTable::parse("  Ubuntu  Running  2\n").is_empty());
549        assert_eq!(
550            artifact::LinuxBinaryPath::default().as_path(),
551            artifact::DEFAULT_LINUX_DESTINATION
552        );
553    }
554
555    #[test]
556    fn every_error_has_a_distinct_stable_kind() {
557        // A status document and a log field are written from `kind`, so two
558        // variants sharing one token would make two different failures
559        // indistinguishable to anything reading them.
560        let kinds = [
561            WslError::UnsupportedPlatform { operation: "x" }.kind(),
562            WslError::InvalidName {
563                requested: String::new(),
564                reason: String::new(),
565            }
566            .kind(),
567            WslError::NotInstalled {
568                requested: String::new(),
569                available: Vec::new(),
570            }
571            .kind(),
572            WslError::NotWsl2 {
573                distribution: String::new(),
574                version: 1,
575            }
576            .kind(),
577            WslError::ForeignTask {
578                name: String::new(),
579                detail: String::new(),
580            }
581            .kind(),
582            WslError::RecordSchema {
583                path: PathBuf::new(),
584                found: 2,
585                supported: 1,
586            }
587            .kind(),
588        ];
589        let mut unique = kinds.to_vec();
590        unique.sort_unstable();
591        unique.dedup();
592        assert_eq!(unique.len(), kinds.len(), "{kinds:?}");
593    }
594
595    #[test]
596    fn a_preflight_failure_says_it_changed_nothing() {
597        assert!(
598            WslError::NotWsl2 {
599                distribution: "Legacy".to_string(),
600                version: 1,
601            }
602            .is_preflight()
603        );
604        assert!(
605            !WslError::TaskControl {
606                operation: "register",
607                name: String::new(),
608                detail: String::new(),
609            }
610            .is_preflight()
611        );
612    }
613
614    #[test]
615    fn a_host_over_a_scripted_runner_works_on_any_platform() {
616        let runner = exec::ScriptedRunner::new().always(
617            "--list --verbose",
618            exec::CommandOutput::exited(0, "* Ubuntu   Running   2\n", ""),
619        );
620        let host = WslHost::with_runner(Box::new(runner), WslExecutable::at("wsl.exe"));
621        let table = host.invoker().list().expect("scripted");
622        assert_eq!(table.names(), ["Ubuntu"]);
623        assert!(format!("{host:?}").contains("wsl.exe"));
624    }
625}