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}