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 if !identity.success() {
495 return Err(WslError::CommandFailed {
501 what: "verify root access in the distribution",
502 program: PathBuf::from("id"),
503 exit_code: identity.exit_code(),
504 detail: identity.diagnostic(),
505 });
506 }
507 let reported = identity.stdout_text();
508 if reported != "0" {
509 return Err(WslError::NoRootAccess {
510 distribution: name.to_string(),
511 detail: format!("`id -u` answered {reported}, not 0"),
512 });
513 }
514
515 let uname = invoker.exec_ok(
516 "read the distribution's architecture",
517 LinuxCommand::new(name, "uname").args(["-m"]),
518 )?;
519 let machine = uname.stdout_text();
520 let architecture = architecture_from_uname(name, &machine)?;
521
522 let systemd_report =
525 invoker.exec(LinuxCommand::new(name, "systemctl").args(["is-system-running"]))?;
526 let systemd = SystemdState::from_report(&systemd_report.stdout_text());
527 if !systemd.is_usable() {
528 return Err(WslError::SystemdUnavailable {
529 distribution: name.to_string(),
530 detail: match &systemd {
531 SystemdState::Unavailable(word) => {
532 let stderr = systemd_report.stderr_text();
533 if stderr.is_empty() {
534 format!("`systemctl is-system-running` answered `{word}`")
535 } else {
536 format!("`systemctl is-system-running` answered `{word}`: {stderr}")
537 }
538 }
539 other => other.to_string(),
540 },
541 });
542 }
543
544 Ok(DistributionReadiness {
545 name: name.to_string(),
546 wsl_version,
547 default,
548 architecture,
549 machine,
550 systemd,
551 })
552}
553
554#[cfg(test)]
555mod tests {
556 use super::*;
557 use crate::wsl::exec::{PipedInput, ScriptedRunner};
558 use secrecy::{ExposeSecret, SecretString};
559
560 fn executable() -> WslExecutable {
561 WslExecutable::at("wsl.exe")
562 }
563
564 fn table() -> CommandOutput {
565 CommandOutput::exited(
566 0,
567 concat!(
568 " NAME STATE VERSION\n",
569 "* Ubuntu Running 2\n",
570 " Legacy Stopped 1\n",
571 ),
572 "",
573 )
574 }
575
576 fn healthy() -> ScriptedRunner {
579 ScriptedRunner::new()
580 .always("--list --verbose", table())
581 .always("--exec id -u", CommandOutput::exited(0, "0\n", ""))
582 .always("--exec uname -m", CommandOutput::exited(0, "x86_64\n", ""))
583 .always(
584 "--exec systemctl is-system-running",
585 CommandOutput::exited(0, "running\n", ""),
586 )
587 }
588
589 #[test]
592 fn a_linux_command_is_an_argument_vector_with_no_shell_in_it() {
593 let command = LinuxCommand::new("Debian GNU/Linux 12", "/usr/local/bin/runner-manager")
594 .args(["service", "install", "--start-at", "boot"]);
595 assert_eq!(
596 command.wsl_arguments(),
597 vec![
598 "--distribution",
599 "Debian GNU/Linux 12",
600 "--user",
601 "root",
602 "--exec",
603 "/usr/local/bin/runner-manager",
604 "service",
605 "install",
606 "--start-at",
607 "boot",
608 ]
609 );
610 }
611
612 #[test]
613 fn a_hostile_distribution_name_stays_one_argument() {
614 let command = LinuxCommand::new("Ubuntu; rm -rf /", "id").args(["-u"]);
615 let argv = command.wsl_arguments();
616 assert_eq!(argv[1], "Ubuntu; rm -rf /");
617 assert!(
618 argv.iter().all(|argument| argument != "rm"),
619 "the name must never become its own argument: {argv:?}"
620 );
621 assert!(argv.contains(&"--exec".to_string()));
624 }
625
626 #[test]
627 fn the_invoker_passes_the_vector_through_untouched() {
628 let runner = healthy();
629 let executable = executable();
630 let invoker = WslInvoker::new(&runner, &executable);
631 invoker
632 .exec(LinuxCommand::new("Ubuntu", "id").args(["-u"]))
633 .expect("scripted");
634 let recorded = runner.recorded();
635 assert_eq!(recorded.len(), 1);
636 assert_eq!(
637 recorded[0].arguments,
638 vec![
639 "--distribution",
640 "Ubuntu",
641 "--user",
642 "root",
643 "--exec",
644 "id",
645 "-u"
646 ]
647 );
648 }
649
650 #[test]
651 fn a_name_that_reads_as_an_option_is_refused_before_anything_is_launched() {
652 let runner = ScriptedRunner::new();
653 let executable = executable();
654 let invoker = WslInvoker::new(&runner, &executable);
655 let error = invoker
656 .exec(LinuxCommand::new("--shutdown", "id"))
657 .expect_err("refused");
658 assert!(matches!(error, WslError::InvalidName { .. }), "{error:?}");
659 assert_eq!(runner.call_count(), 0, "nothing may be launched");
660 }
661
662 #[test]
663 fn a_piped_payload_reaches_the_child_and_no_argument() {
664 let secret = SecretString::from(format!("{}{}", "ghu_", "a1ProbeFixtureNotARealToken0000"));
665 let runner = ScriptedRunner::new();
666 let executable = executable();
667 let invoker = WslInvoker::new(&runner, &executable);
668 invoker
669 .exec(
670 LinuxCommand::new("Ubuntu", "/usr/local/bin/runner-manager")
671 .args(["auth", "receive", "--start-at", "boot"])
672 .with_input(ChildInput::Piped(PipedInput::from_secret_text(&secret))),
673 )
674 .expect("scripted");
675 assert_eq!(runner.piped_input(), secret.expose_secret().as_bytes());
676 assert!(
677 runner
678 .command_lines()
679 .iter()
680 .all(|line| !line.contains(secret.expose_secret())),
681 "the canary must not be in any command line"
682 );
683 }
684
685 #[test]
688 fn a_healthy_distribution_reports_every_fact_it_established() {
689 let runner = healthy();
690 let executable = executable();
691 let ready =
692 probe_readiness(&WslInvoker::new(&runner, &executable), "Ubuntu").expect("healthy");
693 assert_eq!(ready.name(), "Ubuntu");
694 assert_eq!(ready.wsl_version(), 2);
695 assert!(ready.is_default());
696 assert_eq!(ready.architecture(), Arch::X64);
697 assert_eq!(ready.machine(), "x86_64");
698 assert_eq!(ready.systemd(), &SystemdState::Running);
699 }
700
701 #[test]
702 fn a_wsl1_distribution_is_refused_before_any_linux_command_runs() {
703 let runner = healthy();
704 let executable = executable();
705 let error =
706 probe_readiness(&WslInvoker::new(&runner, &executable), "Legacy").expect_err("WSL1");
707 assert!(matches!(error, WslError::NotWsl2 { .. }), "{error:?}");
708 assert_eq!(
709 runner.call_count(),
710 1,
711 "only `--list` should have run: {:?}",
712 runner.command_lines()
713 );
714 }
715
716 #[test]
717 fn a_distribution_that_is_not_installed_lists_the_ones_that_are() {
718 let runner = healthy();
719 let executable = executable();
720 let error =
721 probe_readiness(&WslInvoker::new(&runner, &executable), "Fedora").expect_err("absent");
722 assert!(error.to_string().contains("Ubuntu"), "{error}");
723 }
724
725 #[test]
726 fn a_distribution_that_does_not_start_as_root_is_refused() {
727 let runner = ScriptedRunner::new()
728 .always("--list --verbose", table())
729 .always("--exec id -u", CommandOutput::exited(0, "1000\n", ""));
730 let executable = executable();
731 let error = probe_readiness(&WslInvoker::new(&runner, &executable), "Ubuntu")
732 .expect_err("not root");
733 let WslError::NoRootAccess { detail, .. } = &error else {
734 panic!("unexpected error: {error:?}");
735 };
736 assert!(detail.contains("1000"), "{detail}");
737 }
738
739 #[test]
740 fn a_wsl_startup_failure_is_not_mislabeled_as_missing_root_access() {
741 let diagnostic = concat!(
742 "A connection attempt failed because the connected party did not respond.\n",
743 "Error code: Wsl/Service/0x8007274c\n",
744 );
745 let runner = ScriptedRunner::new()
746 .always("--list --verbose", table())
747 .always("--exec id -u", CommandOutput::exited(-1, "", diagnostic));
748 let executable = executable();
749 let error = probe_readiness(&WslInvoker::new(&runner, &executable), "Ubuntu")
750 .expect_err("WSL startup failed");
751
752 let WslError::CommandFailed {
753 what,
754 exit_code,
755 detail,
756 ..
757 } = &error
758 else {
759 panic!("unexpected error: {error:?}");
760 };
761 assert_eq!(*what, "verify root access in the distribution");
762 assert_eq!(*exit_code, Some(-1));
763 assert!(detail.contains("Wsl/Service/0x8007274c"), "{detail}");
764 assert!(
765 !error.to_string().contains("does not start as root"),
766 "{error}"
767 );
768 }
769
770 #[test]
771 fn an_unsupported_architecture_names_what_the_distribution_said() {
772 let runner = ScriptedRunner::new()
773 .always("--list --verbose", table())
774 .always("--exec id -u", CommandOutput::exited(0, "0\n", ""))
775 .always("--exec uname -m", CommandOutput::exited(0, "armv7l\n", ""));
776 let executable = executable();
777 let error = probe_readiness(&WslInvoker::new(&runner, &executable), "Ubuntu")
778 .expect_err("armv7l has no published artifact");
779 assert!(
780 matches!(&error, WslError::UnsupportedArchitecture { reported, .. } if reported == "armv7l"),
781 "{error:?}"
782 );
783 assert!(error.to_string().contains("armv7l"));
784 }
785
786 #[test]
787 fn architecture_mapping_covers_both_published_linux_targets_and_nothing_else() {
788 assert_eq!(
789 architecture_from_uname("d", "x86_64").expect("x64"),
790 Arch::X64
791 );
792 assert_eq!(
793 architecture_from_uname("d", "amd64").expect("x64"),
794 Arch::X64
795 );
796 assert_eq!(
797 architecture_from_uname("d", "aarch64").expect("arm64"),
798 Arch::Arm64
799 );
800 assert_eq!(
801 architecture_from_uname("d", "arm64\n").expect("arm64"),
802 Arch::Arm64
803 );
804 for machine in ["armv7l", "i686", "riscv64", "s390x", ""] {
805 assert!(
806 architecture_from_uname("d", machine).is_err(),
807 "{machine} has no published Linux artifact"
808 );
809 }
810 }
811
812 #[test]
813 fn a_distribution_without_systemd_is_refused_with_what_it_answered() {
814 let runner = ScriptedRunner::new()
815 .always("--list --verbose", table())
816 .always("--exec id -u", CommandOutput::exited(0, "0\n", ""))
817 .always("--exec uname -m", CommandOutput::exited(0, "x86_64\n", ""))
818 .always(
819 "--exec systemctl is-system-running",
820 CommandOutput::exited(1, "offline\n", ""),
821 );
822 let executable = executable();
823 let error = probe_readiness(&WslInvoker::new(&runner, &executable), "Ubuntu")
824 .expect_err("no systemd");
825 let WslError::SystemdUnavailable { detail, .. } = &error else {
826 panic!("unexpected error: {error:?}");
827 };
828 assert!(detail.contains("offline"), "{detail}");
829 }
830
831 #[test]
832 fn degraded_and_starting_systemd_are_usable_because_the_products_own_unit_is_what_matters() {
833 for word in ["degraded", "starting", "initializing"] {
834 let runner = ScriptedRunner::new()
835 .always("--list --verbose", table())
836 .always("--exec id -u", CommandOutput::exited(0, "0\n", ""))
837 .always("--exec uname -m", CommandOutput::exited(0, "x86_64\n", ""))
838 .always(
839 "--exec systemctl is-system-running",
840 CommandOutput::exited(1, format!("{word}\n"), ""),
841 );
842 let executable = executable();
843 let ready = probe_readiness(&WslInvoker::new(&runner, &executable), "Ubuntu")
844 .unwrap_or_else(|error| panic!("{word} should be usable: {error}"));
845 assert!(ready.systemd().is_usable());
846 }
847 }
848
849 #[test]
850 fn the_preflight_mutates_nothing() {
851 let runner = healthy();
855 let executable = executable();
856 probe_readiness(&WslInvoker::new(&runner, &executable), "Ubuntu").expect("healthy");
857 for line in runner.command_lines() {
858 assert!(
859 [
860 "--list --verbose",
861 "id -u",
862 "uname -m",
863 "systemctl is-system-running"
864 ]
865 .iter()
866 .any(|read| line.contains(read)),
867 "the preflight ran something that is not a read: {line}"
868 );
869 }
870 }
871
872 #[test]
875 fn a_missing_system_root_falls_back_to_the_path_lookup() {
876 assert_eq!(locate_from(None, "wsl.exe"), PathBuf::from("wsl.exe"));
877 assert_eq!(
878 locate_from(Some(PathBuf::from("Q:\\NoSuchWindows")), "wsl.exe"),
879 PathBuf::from("wsl.exe")
880 );
881 }
882
883 #[test]
884 fn a_system_root_that_really_holds_the_executable_is_used() {
885 let root = tempfile::tempdir().expect("a temporary directory");
886 let system32 = root.path().join("System32");
887 std::fs::create_dir_all(&system32).expect("create System32");
888 std::fs::write(system32.join("wsl.exe"), b"not really an executable")
889 .expect("write the stand-in");
890 assert_eq!(
891 locate_from(Some(root.path().to_path_buf()), "wsl.exe"),
892 system32.join("wsl.exe")
893 );
894 }
895}