1use 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
63pub const LINUX_USER: &str = "root";
71
72#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct WslExecutable(PathBuf);
89
90impl WslExecutable {
91 #[must_use]
96 pub fn locate() -> Self {
97 Self(locate_in_system32("wsl.exe"))
98 }
99
100 #[must_use]
102 pub fn at(path: impl Into<PathBuf>) -> Self {
103 Self(path.into())
104 }
105
106 #[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
125pub(crate) fn locate_in_system32(program: &str) -> PathBuf {
131 locate_from(std::env::var_os("SystemRoot").map(PathBuf::from), program)
132}
133
134fn 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#[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 #[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 #[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 #[must_use]
190 pub fn with_input(mut self, input: ChildInput) -> Self {
191 self.input = input;
192 self
193 }
194
195 #[must_use]
197 pub fn with_timeout(mut self, timeout: Duration) -> Self {
198 self.timeout = timeout;
199 self
200 }
201
202 #[must_use]
204 pub fn with_limits(mut self, limits: OutputLimits) -> Self {
205 self.limits = limits;
206 self
207 }
208
209 #[must_use]
211 pub fn distribution(&self) -> &str {
212 &self.distribution
213 }
214
215 #[must_use]
217 pub fn program(&self) -> &str {
218 &self.program
219 }
220
221 #[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#[derive(Debug, Clone, Copy)]
247pub struct WslInvoker<'runner> {
248 runner: &'runner dyn CommandRunner,
249 executable: &'runner WslExecutable,
250}
251
252impl<'runner> WslInvoker<'runner> {
253 #[must_use]
255 pub fn new(runner: &'runner dyn CommandRunner, executable: &'runner WslExecutable) -> Self {
256 Self { runner, executable }
257 }
258
259 #[must_use]
261 pub fn executable(&self) -> &WslExecutable {
262 self.executable
263 }
264
265 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 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 pub fn exec(&self, command: LinuxCommand) -> Result<CommandOutput, WslError> {
314 validate_distribution_name(&command.distribution)?;
315 let request = CommandRequest::new(self.executable.path())
316 .args(command.wsl_arguments())
317 .with_timeout(command.timeout)
318 .with_limits(command.limits)
319 .with_input(command.input);
320 self.runner.run(&request)
321 }
322
323 pub fn exec_ok(
330 &self,
331 what: &'static str,
332 command: LinuxCommand,
333 ) -> Result<CommandOutput, WslError> {
334 let program = PathBuf::from(command.program());
335 let output = self.exec(command)?;
336 if output.success() {
337 return Ok(output);
338 }
339 Err(WslError::CommandFailed {
340 what,
341 program,
342 exit_code: output.exit_code(),
343 detail: output.diagnostic(),
344 })
345 }
346}
347
348#[derive(Debug, Clone, PartialEq, Eq)]
354pub enum SystemdState {
355 Running,
357 Degraded,
360 Starting,
364 Unavailable(String),
366}
367
368impl SystemdState {
369 #[must_use]
371 pub fn from_report(word: &str) -> Self {
372 match word.trim() {
373 "running" => Self::Running,
374 "degraded" => Self::Degraded,
375 "initializing" | "starting" => Self::Starting,
376 "" => Self::Unavailable("it said nothing".to_string()),
377 other => Self::Unavailable(other.to_string()),
378 }
379 }
380
381 #[must_use]
383 pub fn is_usable(&self) -> bool {
384 matches!(self, Self::Running | Self::Degraded | Self::Starting)
385 }
386}
387
388impl fmt::Display for SystemdState {
389 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
390 match self {
391 Self::Running => f.write_str("running"),
392 Self::Degraded => f.write_str("degraded"),
393 Self::Starting => f.write_str("starting"),
394 Self::Unavailable(word) => write!(f, "unavailable ({word})"),
395 }
396 }
397}
398
399pub fn architecture_from_uname(distribution: &str, machine: &str) -> Result<Arch, WslError> {
410 match machine.trim() {
411 "x86_64" | "amd64" => Ok(Arch::X64),
412 "aarch64" | "arm64" => Ok(Arch::Arm64),
413 other => Err(WslError::UnsupportedArchitecture {
414 distribution: distribution.to_string(),
415 reported: other.to_string(),
416 }),
417 }
418}
419
420#[derive(Debug, Clone, PartialEq, Eq)]
422pub struct DistributionReadiness {
423 name: String,
424 wsl_version: u8,
425 default: bool,
426 architecture: Arch,
427 machine: String,
428 systemd: SystemdState,
429}
430
431impl DistributionReadiness {
432 #[must_use]
434 pub fn name(&self) -> &str {
435 &self.name
436 }
437
438 #[must_use]
441 pub fn wsl_version(&self) -> u8 {
442 self.wsl_version
443 }
444
445 #[must_use]
447 pub fn is_default(&self) -> bool {
448 self.default
449 }
450
451 #[must_use]
453 pub fn architecture(&self) -> Arch {
454 self.architecture
455 }
456
457 #[must_use]
459 pub fn machine(&self) -> &str {
460 &self.machine
461 }
462
463 #[must_use]
465 pub fn systemd(&self) -> &SystemdState {
466 &self.systemd
467 }
468}
469
470pub fn probe_readiness(
480 invoker: &WslInvoker<'_>,
481 name: &str,
482) -> Result<DistributionReadiness, WslError> {
483 validate_distribution_name(name)?;
484 let table = invoker.list()?;
485 let installed = table.exactly(name)?;
486 installed.require_wsl2()?;
487 let wsl_version = installed.wsl_version();
488 let default = installed.is_default();
489
490 let identity = invoker.exec(LinuxCommand::new(name, "id").args(["-u"]))?;
494 let reported = identity.stdout_text();
495 if !identity.success() || reported != "0" {
496 return Err(WslError::NoRootAccess {
497 distribution: name.to_string(),
498 detail: if identity.success() {
499 format!("`id -u` answered {reported}, not 0")
500 } else {
501 identity.diagnostic()
502 },
503 });
504 }
505
506 let uname = invoker.exec_ok(
507 "read the distribution's architecture",
508 LinuxCommand::new(name, "uname").args(["-m"]),
509 )?;
510 let machine = uname.stdout_text();
511 let architecture = architecture_from_uname(name, &machine)?;
512
513 let systemd_report =
516 invoker.exec(LinuxCommand::new(name, "systemctl").args(["is-system-running"]))?;
517 let systemd = SystemdState::from_report(&systemd_report.stdout_text());
518 if !systemd.is_usable() {
519 return Err(WslError::SystemdUnavailable {
520 distribution: name.to_string(),
521 detail: match &systemd {
522 SystemdState::Unavailable(word) => {
523 let stderr = systemd_report.stderr_text();
524 if stderr.is_empty() {
525 format!("`systemctl is-system-running` answered `{word}`")
526 } else {
527 format!("`systemctl is-system-running` answered `{word}`: {stderr}")
528 }
529 }
530 other => other.to_string(),
531 },
532 });
533 }
534
535 Ok(DistributionReadiness {
536 name: name.to_string(),
537 wsl_version,
538 default,
539 architecture,
540 machine,
541 systemd,
542 })
543}
544
545#[cfg(test)]
546mod tests {
547 use super::*;
548 use crate::wsl::exec::{PipedInput, ScriptedRunner};
549 use secrecy::{ExposeSecret, SecretString};
550
551 fn executable() -> WslExecutable {
552 WslExecutable::at("wsl.exe")
553 }
554
555 fn table() -> CommandOutput {
556 CommandOutput::exited(
557 0,
558 concat!(
559 " NAME STATE VERSION\n",
560 "* Ubuntu Running 2\n",
561 " Legacy Stopped 1\n",
562 ),
563 "",
564 )
565 }
566
567 fn healthy() -> ScriptedRunner {
570 ScriptedRunner::new()
571 .always("--list --verbose", table())
572 .always("--exec id -u", CommandOutput::exited(0, "0\n", ""))
573 .always("--exec uname -m", CommandOutput::exited(0, "x86_64\n", ""))
574 .always(
575 "--exec systemctl is-system-running",
576 CommandOutput::exited(0, "running\n", ""),
577 )
578 }
579
580 #[test]
583 fn a_linux_command_is_an_argument_vector_with_no_shell_in_it() {
584 let command = LinuxCommand::new("Debian GNU/Linux 12", "/usr/local/bin/runner-manager")
585 .args(["service", "install", "--start-at", "boot"]);
586 assert_eq!(
587 command.wsl_arguments(),
588 vec![
589 "--distribution",
590 "Debian GNU/Linux 12",
591 "--user",
592 "root",
593 "--exec",
594 "/usr/local/bin/runner-manager",
595 "service",
596 "install",
597 "--start-at",
598 "boot",
599 ]
600 );
601 }
602
603 #[test]
604 fn a_hostile_distribution_name_stays_one_argument() {
605 let command = LinuxCommand::new("Ubuntu; rm -rf /", "id").args(["-u"]);
606 let argv = command.wsl_arguments();
607 assert_eq!(argv[1], "Ubuntu; rm -rf /");
608 assert!(
609 argv.iter().all(|argument| argument != "rm"),
610 "the name must never become its own argument: {argv:?}"
611 );
612 assert!(argv.contains(&"--exec".to_string()));
615 }
616
617 #[test]
618 fn the_invoker_passes_the_vector_through_untouched() {
619 let runner = healthy();
620 let executable = executable();
621 let invoker = WslInvoker::new(&runner, &executable);
622 invoker
623 .exec(LinuxCommand::new("Ubuntu", "id").args(["-u"]))
624 .expect("scripted");
625 let recorded = runner.recorded();
626 assert_eq!(recorded.len(), 1);
627 assert_eq!(
628 recorded[0].arguments,
629 vec![
630 "--distribution",
631 "Ubuntu",
632 "--user",
633 "root",
634 "--exec",
635 "id",
636 "-u"
637 ]
638 );
639 }
640
641 #[test]
642 fn a_name_that_reads_as_an_option_is_refused_before_anything_is_launched() {
643 let runner = ScriptedRunner::new();
644 let executable = executable();
645 let invoker = WslInvoker::new(&runner, &executable);
646 let error = invoker
647 .exec(LinuxCommand::new("--shutdown", "id"))
648 .expect_err("refused");
649 assert!(matches!(error, WslError::InvalidName { .. }), "{error:?}");
650 assert_eq!(runner.call_count(), 0, "nothing may be launched");
651 }
652
653 #[test]
654 fn a_piped_payload_reaches_the_child_and_no_argument() {
655 let secret = SecretString::from(format!("{}{}", "ghu_", "a1ProbeFixtureNotARealToken0000"));
656 let runner = ScriptedRunner::new();
657 let executable = executable();
658 let invoker = WslInvoker::new(&runner, &executable);
659 invoker
660 .exec(
661 LinuxCommand::new("Ubuntu", "/usr/local/bin/runner-manager")
662 .args(["auth", "receive", "--start-at", "boot"])
663 .with_input(ChildInput::Piped(PipedInput::from_secret_text(&secret))),
664 )
665 .expect("scripted");
666 assert_eq!(runner.piped_input(), secret.expose_secret().as_bytes());
667 assert!(
668 runner
669 .command_lines()
670 .iter()
671 .all(|line| !line.contains(secret.expose_secret())),
672 "the canary must not be in any command line"
673 );
674 }
675
676 #[test]
679 fn a_healthy_distribution_reports_every_fact_it_established() {
680 let runner = healthy();
681 let executable = executable();
682 let ready =
683 probe_readiness(&WslInvoker::new(&runner, &executable), "Ubuntu").expect("healthy");
684 assert_eq!(ready.name(), "Ubuntu");
685 assert_eq!(ready.wsl_version(), 2);
686 assert!(ready.is_default());
687 assert_eq!(ready.architecture(), Arch::X64);
688 assert_eq!(ready.machine(), "x86_64");
689 assert_eq!(ready.systemd(), &SystemdState::Running);
690 }
691
692 #[test]
693 fn a_wsl1_distribution_is_refused_before_any_linux_command_runs() {
694 let runner = healthy();
695 let executable = executable();
696 let error =
697 probe_readiness(&WslInvoker::new(&runner, &executable), "Legacy").expect_err("WSL1");
698 assert!(matches!(error, WslError::NotWsl2 { .. }), "{error:?}");
699 assert_eq!(
700 runner.call_count(),
701 1,
702 "only `--list` should have run: {:?}",
703 runner.command_lines()
704 );
705 }
706
707 #[test]
708 fn a_distribution_that_is_not_installed_lists_the_ones_that_are() {
709 let runner = healthy();
710 let executable = executable();
711 let error =
712 probe_readiness(&WslInvoker::new(&runner, &executable), "Fedora").expect_err("absent");
713 assert!(error.to_string().contains("Ubuntu"), "{error}");
714 }
715
716 #[test]
717 fn a_distribution_that_does_not_start_as_root_is_refused() {
718 let runner = ScriptedRunner::new()
719 .always("--list --verbose", table())
720 .always("--exec id -u", CommandOutput::exited(0, "1000\n", ""));
721 let executable = executable();
722 let error = probe_readiness(&WslInvoker::new(&runner, &executable), "Ubuntu")
723 .expect_err("not root");
724 let WslError::NoRootAccess { detail, .. } = &error else {
725 panic!("unexpected error: {error:?}");
726 };
727 assert!(detail.contains("1000"), "{detail}");
728 }
729
730 #[test]
731 fn an_unsupported_architecture_names_what_the_distribution_said() {
732 let runner = ScriptedRunner::new()
733 .always("--list --verbose", table())
734 .always("--exec id -u", CommandOutput::exited(0, "0\n", ""))
735 .always("--exec uname -m", CommandOutput::exited(0, "armv7l\n", ""));
736 let executable = executable();
737 let error = probe_readiness(&WslInvoker::new(&runner, &executable), "Ubuntu")
738 .expect_err("armv7l has no published artifact");
739 assert!(
740 matches!(&error, WslError::UnsupportedArchitecture { reported, .. } if reported == "armv7l"),
741 "{error:?}"
742 );
743 assert!(error.to_string().contains("armv7l"));
744 }
745
746 #[test]
747 fn architecture_mapping_covers_both_published_linux_targets_and_nothing_else() {
748 assert_eq!(
749 architecture_from_uname("d", "x86_64").expect("x64"),
750 Arch::X64
751 );
752 assert_eq!(
753 architecture_from_uname("d", "amd64").expect("x64"),
754 Arch::X64
755 );
756 assert_eq!(
757 architecture_from_uname("d", "aarch64").expect("arm64"),
758 Arch::Arm64
759 );
760 assert_eq!(
761 architecture_from_uname("d", "arm64\n").expect("arm64"),
762 Arch::Arm64
763 );
764 for machine in ["armv7l", "i686", "riscv64", "s390x", ""] {
765 assert!(
766 architecture_from_uname("d", machine).is_err(),
767 "{machine} has no published Linux artifact"
768 );
769 }
770 }
771
772 #[test]
773 fn a_distribution_without_systemd_is_refused_with_what_it_answered() {
774 let runner = ScriptedRunner::new()
775 .always("--list --verbose", table())
776 .always("--exec id -u", CommandOutput::exited(0, "0\n", ""))
777 .always("--exec uname -m", CommandOutput::exited(0, "x86_64\n", ""))
778 .always(
779 "--exec systemctl is-system-running",
780 CommandOutput::exited(1, "offline\n", ""),
781 );
782 let executable = executable();
783 let error = probe_readiness(&WslInvoker::new(&runner, &executable), "Ubuntu")
784 .expect_err("no systemd");
785 let WslError::SystemdUnavailable { detail, .. } = &error else {
786 panic!("unexpected error: {error:?}");
787 };
788 assert!(detail.contains("offline"), "{detail}");
789 }
790
791 #[test]
792 fn degraded_and_starting_systemd_are_usable_because_the_products_own_unit_is_what_matters() {
793 for word in ["degraded", "starting", "initializing"] {
794 let runner = ScriptedRunner::new()
795 .always("--list --verbose", table())
796 .always("--exec id -u", CommandOutput::exited(0, "0\n", ""))
797 .always("--exec uname -m", CommandOutput::exited(0, "x86_64\n", ""))
798 .always(
799 "--exec systemctl is-system-running",
800 CommandOutput::exited(1, format!("{word}\n"), ""),
801 );
802 let executable = executable();
803 let ready = probe_readiness(&WslInvoker::new(&runner, &executable), "Ubuntu")
804 .unwrap_or_else(|error| panic!("{word} should be usable: {error}"));
805 assert!(ready.systemd().is_usable());
806 }
807 }
808
809 #[test]
810 fn the_preflight_mutates_nothing() {
811 let runner = healthy();
815 let executable = executable();
816 probe_readiness(&WslInvoker::new(&runner, &executable), "Ubuntu").expect("healthy");
817 for line in runner.command_lines() {
818 assert!(
819 [
820 "--list --verbose",
821 "id -u",
822 "uname -m",
823 "systemctl is-system-running"
824 ]
825 .iter()
826 .any(|read| line.contains(read)),
827 "the preflight ran something that is not a read: {line}"
828 );
829 }
830 }
831
832 #[test]
835 fn a_missing_system_root_falls_back_to_the_path_lookup() {
836 assert_eq!(locate_from(None, "wsl.exe"), PathBuf::from("wsl.exe"));
837 assert_eq!(
838 locate_from(Some(PathBuf::from("Q:\\NoSuchWindows")), "wsl.exe"),
839 PathBuf::from("wsl.exe")
840 );
841 }
842
843 #[test]
844 fn a_system_root_that_really_holds_the_executable_is_used() {
845 let root = tempfile::tempdir().expect("a temporary directory");
846 let system32 = root.path().join("System32");
847 std::fs::create_dir_all(&system32).expect("create System32");
848 std::fs::write(system32.join("wsl.exe"), b"not really an executable")
849 .expect("write the stand-in");
850 assert_eq!(
851 locate_from(Some(root.path().to_path_buf()), "wsl.exe"),
852 system32.join("wsl.exe")
853 );
854 }
855}