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