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}