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 platform_status(&self) -> Result<(), WslError> {
298 let request = CommandRequest::new(self.executable.path()).arg("--status");
299 let output = self.runner.run(&request)?;
300 if output.success() {
301 return Ok(());
302 }
303 Err(WslError::CommandFailed {
304 what: "read WSL platform status",
305 program: self.executable.path().to_path_buf(),
306 exit_code: output.exit_code(),
307 detail: output.diagnostic(),
308 })
309 }
310
311 pub fn terminate_named(&self, distribution: &str) -> Result<(), WslError> {
315 validate_distribution_name(distribution)?;
316 let request = CommandRequest::new(self.executable.path())
317 .arg("--terminate")
318 .arg(distribution);
319 let output = self.runner.run(&request)?;
320 if output.success() {
321 return Ok(());
322 }
323 Err(WslError::CommandFailed {
324 what: "terminate the named WSL distribution",
325 program: self.executable.path().to_path_buf(),
326 exit_code: output.exit_code(),
327 detail: output.diagnostic(),
328 })
329 }
330
331 pub fn exec(&self, command: LinuxCommand) -> Result<CommandOutput, WslError> {
350 validate_distribution_name(&command.distribution)?;
351 let request = CommandRequest::new(self.executable.path())
352 .args(command.wsl_arguments())
353 .with_timeout(command.timeout)
354 .with_limits(command.limits)
355 .with_input(command.input);
356 self.runner.run(&request)
357 }
358
359 pub fn exec_ok(
366 &self,
367 what: &'static str,
368 command: LinuxCommand,
369 ) -> Result<CommandOutput, WslError> {
370 let program = PathBuf::from(command.program());
371 let output = self.exec(command)?;
372 if output.success() {
373 return Ok(output);
374 }
375 Err(WslError::CommandFailed {
376 what,
377 program,
378 exit_code: output.exit_code(),
379 detail: output.diagnostic(),
380 })
381 }
382}
383
384#[derive(Debug, Clone, PartialEq, Eq)]
390pub enum SystemdState {
391 Running,
393 Degraded,
396 Starting,
400 Unavailable(String),
402}
403
404impl SystemdState {
405 #[must_use]
407 pub fn from_report(word: &str) -> Self {
408 match word.trim() {
409 "running" => Self::Running,
410 "degraded" => Self::Degraded,
411 "initializing" | "starting" => Self::Starting,
412 "" => Self::Unavailable("it said nothing".to_string()),
413 other => Self::Unavailable(other.to_string()),
414 }
415 }
416
417 #[must_use]
419 pub fn is_usable(&self) -> bool {
420 matches!(self, Self::Running | Self::Degraded | Self::Starting)
421 }
422}
423
424impl fmt::Display for SystemdState {
425 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
426 match self {
427 Self::Running => f.write_str("running"),
428 Self::Degraded => f.write_str("degraded"),
429 Self::Starting => f.write_str("starting"),
430 Self::Unavailable(word) => write!(f, "unavailable ({word})"),
431 }
432 }
433}
434
435pub fn architecture_from_uname(distribution: &str, machine: &str) -> Result<Arch, WslError> {
446 match machine.trim() {
447 "x86_64" | "amd64" => Ok(Arch::X64),
448 "aarch64" | "arm64" => Ok(Arch::Arm64),
449 other => Err(WslError::UnsupportedArchitecture {
450 distribution: distribution.to_string(),
451 reported: other.to_string(),
452 }),
453 }
454}
455
456#[derive(Debug, Clone, PartialEq, Eq)]
458pub struct DistributionReadiness {
459 name: String,
460 wsl_version: u8,
461 default: bool,
462 architecture: Arch,
463 machine: String,
464 systemd: SystemdState,
465}
466
467impl DistributionReadiness {
468 #[must_use]
470 pub fn name(&self) -> &str {
471 &self.name
472 }
473
474 #[must_use]
477 pub fn wsl_version(&self) -> u8 {
478 self.wsl_version
479 }
480
481 #[must_use]
483 pub fn is_default(&self) -> bool {
484 self.default
485 }
486
487 #[must_use]
489 pub fn architecture(&self) -> Arch {
490 self.architecture
491 }
492
493 #[must_use]
495 pub fn machine(&self) -> &str {
496 &self.machine
497 }
498
499 #[must_use]
501 pub fn systemd(&self) -> &SystemdState {
502 &self.systemd
503 }
504}
505
506pub fn probe_readiness(
516 invoker: &WslInvoker<'_>,
517 name: &str,
518) -> Result<DistributionReadiness, WslError> {
519 validate_distribution_name(name)?;
520 let table = invoker.list()?;
521 let installed = table.exactly(name)?;
522 installed.require_wsl2()?;
523 let wsl_version = installed.wsl_version();
524 let default = installed.is_default();
525
526 let identity = invoker.exec(LinuxCommand::new(name, "id").args(["-u"]))?;
530 if !identity.success() {
531 return Err(WslError::CommandFailed {
537 what: "verify root access in the distribution",
538 program: PathBuf::from("id"),
539 exit_code: identity.exit_code(),
540 detail: identity.diagnostic(),
541 });
542 }
543 let reported = identity.stdout_text();
544 if reported != "0" {
545 return Err(WslError::NoRootAccess {
546 distribution: name.to_string(),
547 detail: format!("`id -u` answered {reported}, not 0"),
548 });
549 }
550
551 let uname = invoker.exec_ok(
552 "read the distribution's architecture",
553 LinuxCommand::new(name, "uname").args(["-m"]),
554 )?;
555 let machine = uname.stdout_text();
556 let architecture = architecture_from_uname(name, &machine)?;
557
558 let systemd_report =
561 invoker.exec(LinuxCommand::new(name, "systemctl").args(["is-system-running"]))?;
562 let systemd = SystemdState::from_report(&systemd_report.stdout_text());
563 if !systemd.is_usable() {
564 return Err(WslError::SystemdUnavailable {
565 distribution: name.to_string(),
566 detail: match &systemd {
567 SystemdState::Unavailable(word) => {
568 let stderr = systemd_report.stderr_text();
569 if stderr.is_empty() {
570 format!("`systemctl is-system-running` answered `{word}`")
571 } else {
572 format!("`systemctl is-system-running` answered `{word}`: {stderr}")
573 }
574 }
575 other => other.to_string(),
576 },
577 });
578 }
579
580 Ok(DistributionReadiness {
581 name: name.to_string(),
582 wsl_version,
583 default,
584 architecture,
585 machine,
586 systemd,
587 })
588}
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593 use crate::wsl::exec::{PipedInput, ScriptedRunner};
594 use secrecy::{ExposeSecret, SecretString};
595
596 fn executable() -> WslExecutable {
597 WslExecutable::at("wsl.exe")
598 }
599
600 fn table() -> CommandOutput {
601 CommandOutput::exited(
602 0,
603 concat!(
604 " NAME STATE VERSION\n",
605 "* Ubuntu Running 2\n",
606 " Legacy Stopped 1\n",
607 ),
608 "",
609 )
610 }
611
612 fn healthy() -> ScriptedRunner {
615 ScriptedRunner::new()
616 .always("--list --verbose", table())
617 .always("--exec id -u", CommandOutput::exited(0, "0\n", ""))
618 .always("--exec uname -m", CommandOutput::exited(0, "x86_64\n", ""))
619 .always(
620 "--exec systemctl is-system-running",
621 CommandOutput::exited(0, "running\n", ""),
622 )
623 }
624
625 #[test]
628 fn a_linux_command_is_an_argument_vector_with_no_shell_in_it() {
629 let command = LinuxCommand::new("Debian GNU/Linux 12", "/usr/local/bin/runner-manager")
630 .args(["service", "install", "--start-at", "boot"]);
631 assert_eq!(
632 command.wsl_arguments(),
633 vec![
634 "--distribution",
635 "Debian GNU/Linux 12",
636 "--user",
637 "root",
638 "--exec",
639 "/usr/local/bin/runner-manager",
640 "service",
641 "install",
642 "--start-at",
643 "boot",
644 ]
645 );
646 }
647
648 #[test]
649 fn a_hostile_distribution_name_stays_one_argument() {
650 let command = LinuxCommand::new("Ubuntu; rm -rf /", "id").args(["-u"]);
651 let argv = command.wsl_arguments();
652 assert_eq!(argv[1], "Ubuntu; rm -rf /");
653 assert!(
654 argv.iter().all(|argument| argument != "rm"),
655 "the name must never become its own argument: {argv:?}"
656 );
657 assert!(argv.contains(&"--exec".to_string()));
660 }
661
662 #[test]
663 fn the_invoker_passes_the_vector_through_untouched() {
664 let runner = healthy();
665 let executable = executable();
666 let invoker = WslInvoker::new(&runner, &executable);
667 invoker
668 .exec(LinuxCommand::new("Ubuntu", "id").args(["-u"]))
669 .expect("scripted");
670 let recorded = runner.recorded();
671 assert_eq!(recorded.len(), 1);
672 assert_eq!(
673 recorded[0].arguments,
674 vec![
675 "--distribution",
676 "Ubuntu",
677 "--user",
678 "root",
679 "--exec",
680 "id",
681 "-u"
682 ]
683 );
684 }
685
686 #[test]
687 fn a_name_that_reads_as_an_option_is_refused_before_anything_is_launched() {
688 let runner = ScriptedRunner::new();
689 let executable = executable();
690 let invoker = WslInvoker::new(&runner, &executable);
691 let error = invoker
692 .exec(LinuxCommand::new("--shutdown", "id"))
693 .expect_err("refused");
694 assert!(matches!(error, WslError::InvalidName { .. }), "{error:?}");
695 assert_eq!(runner.call_count(), 0, "nothing may be launched");
696 }
697
698 #[test]
699 fn recovery_can_terminate_only_one_validated_named_distribution() {
700 let runner =
701 ScriptedRunner::new().always("--terminate Ubuntu", CommandOutput::exited(0, "", ""));
702 let executable = executable();
703 let invoker = WslInvoker::new(&runner, &executable);
704 invoker
705 .terminate_named("Ubuntu")
706 .expect("named termination");
707 assert_eq!(
708 runner.recorded()[0].arguments,
709 vec!["--terminate", "Ubuntu"]
710 );
711 assert!(
712 runner
713 .recorded()
714 .iter()
715 .all(|request| !request.arguments.iter().any(|arg| arg == "--shutdown"))
716 );
717
718 let error = invoker
719 .terminate_named("--shutdown")
720 .expect_err("option-like names are refused");
721 assert!(matches!(error, WslError::InvalidName { .. }));
722 assert_eq!(runner.call_count(), 1);
723 }
724
725 #[test]
726 fn a_piped_payload_reaches_the_child_and_no_argument() {
727 let secret = SecretString::from(format!("{}{}", "ghu_", "a1ProbeFixtureNotARealToken0000"));
728 let runner = ScriptedRunner::new();
729 let executable = executable();
730 let invoker = WslInvoker::new(&runner, &executable);
731 invoker
732 .exec(
733 LinuxCommand::new("Ubuntu", "/usr/local/bin/runner-manager")
734 .args(["auth", "receive", "--start-at", "boot"])
735 .with_input(ChildInput::Piped(PipedInput::from_secret_text(&secret))),
736 )
737 .expect("scripted");
738 assert_eq!(runner.piped_input(), secret.expose_secret().as_bytes());
739 assert!(
740 runner
741 .command_lines()
742 .iter()
743 .all(|line| !line.contains(secret.expose_secret())),
744 "the canary must not be in any command line"
745 );
746 }
747
748 #[test]
751 fn a_healthy_distribution_reports_every_fact_it_established() {
752 let runner = healthy();
753 let executable = executable();
754 let ready =
755 probe_readiness(&WslInvoker::new(&runner, &executable), "Ubuntu").expect("healthy");
756 assert_eq!(ready.name(), "Ubuntu");
757 assert_eq!(ready.wsl_version(), 2);
758 assert!(ready.is_default());
759 assert_eq!(ready.architecture(), Arch::X64);
760 assert_eq!(ready.machine(), "x86_64");
761 assert_eq!(ready.systemd(), &SystemdState::Running);
762 }
763
764 #[test]
765 fn a_wsl1_distribution_is_refused_before_any_linux_command_runs() {
766 let runner = healthy();
767 let executable = executable();
768 let error =
769 probe_readiness(&WslInvoker::new(&runner, &executable), "Legacy").expect_err("WSL1");
770 assert!(matches!(error, WslError::NotWsl2 { .. }), "{error:?}");
771 assert_eq!(
772 runner.call_count(),
773 1,
774 "only `--list` should have run: {:?}",
775 runner.command_lines()
776 );
777 }
778
779 #[test]
780 fn a_distribution_that_is_not_installed_lists_the_ones_that_are() {
781 let runner = healthy();
782 let executable = executable();
783 let error =
784 probe_readiness(&WslInvoker::new(&runner, &executable), "Fedora").expect_err("absent");
785 assert!(error.to_string().contains("Ubuntu"), "{error}");
786 }
787
788 #[test]
789 fn a_distribution_that_does_not_start_as_root_is_refused() {
790 let runner = ScriptedRunner::new()
791 .always("--list --verbose", table())
792 .always("--exec id -u", CommandOutput::exited(0, "1000\n", ""));
793 let executable = executable();
794 let error = probe_readiness(&WslInvoker::new(&runner, &executable), "Ubuntu")
795 .expect_err("not root");
796 let WslError::NoRootAccess { detail, .. } = &error else {
797 panic!("unexpected error: {error:?}");
798 };
799 assert!(detail.contains("1000"), "{detail}");
800 }
801
802 #[test]
803 fn a_wsl_startup_failure_is_not_mislabeled_as_missing_root_access() {
804 let diagnostic = concat!(
805 "A connection attempt failed because the connected party did not respond.\n",
806 "Error code: Wsl/Service/0x8007274c\n",
807 );
808 let runner = ScriptedRunner::new()
809 .always("--list --verbose", table())
810 .always("--exec id -u", CommandOutput::exited(-1, "", diagnostic));
811 let executable = executable();
812 let error = probe_readiness(&WslInvoker::new(&runner, &executable), "Ubuntu")
813 .expect_err("WSL startup failed");
814
815 let WslError::CommandFailed {
816 what,
817 exit_code,
818 detail,
819 ..
820 } = &error
821 else {
822 panic!("unexpected error: {error:?}");
823 };
824 assert_eq!(*what, "verify root access in the distribution");
825 assert_eq!(*exit_code, Some(-1));
826 assert!(detail.contains("Wsl/Service/0x8007274c"), "{detail}");
827 assert!(
828 !error.to_string().contains("does not start as root"),
829 "{error}"
830 );
831 }
832
833 #[test]
834 fn an_unsupported_architecture_names_what_the_distribution_said() {
835 let runner = ScriptedRunner::new()
836 .always("--list --verbose", table())
837 .always("--exec id -u", CommandOutput::exited(0, "0\n", ""))
838 .always("--exec uname -m", CommandOutput::exited(0, "armv7l\n", ""));
839 let executable = executable();
840 let error = probe_readiness(&WslInvoker::new(&runner, &executable), "Ubuntu")
841 .expect_err("armv7l has no published artifact");
842 assert!(
843 matches!(&error, WslError::UnsupportedArchitecture { reported, .. } if reported == "armv7l"),
844 "{error:?}"
845 );
846 assert!(error.to_string().contains("armv7l"));
847 }
848
849 #[test]
850 fn architecture_mapping_covers_both_published_linux_targets_and_nothing_else() {
851 assert_eq!(
852 architecture_from_uname("d", "x86_64").expect("x64"),
853 Arch::X64
854 );
855 assert_eq!(
856 architecture_from_uname("d", "amd64").expect("x64"),
857 Arch::X64
858 );
859 assert_eq!(
860 architecture_from_uname("d", "aarch64").expect("arm64"),
861 Arch::Arm64
862 );
863 assert_eq!(
864 architecture_from_uname("d", "arm64\n").expect("arm64"),
865 Arch::Arm64
866 );
867 for machine in ["armv7l", "i686", "riscv64", "s390x", ""] {
868 assert!(
869 architecture_from_uname("d", machine).is_err(),
870 "{machine} has no published Linux artifact"
871 );
872 }
873 }
874
875 #[test]
876 fn a_distribution_without_systemd_is_refused_with_what_it_answered() {
877 let runner = ScriptedRunner::new()
878 .always("--list --verbose", table())
879 .always("--exec id -u", CommandOutput::exited(0, "0\n", ""))
880 .always("--exec uname -m", CommandOutput::exited(0, "x86_64\n", ""))
881 .always(
882 "--exec systemctl is-system-running",
883 CommandOutput::exited(1, "offline\n", ""),
884 );
885 let executable = executable();
886 let error = probe_readiness(&WslInvoker::new(&runner, &executable), "Ubuntu")
887 .expect_err("no systemd");
888 let WslError::SystemdUnavailable { detail, .. } = &error else {
889 panic!("unexpected error: {error:?}");
890 };
891 assert!(detail.contains("offline"), "{detail}");
892 }
893
894 #[test]
895 fn degraded_and_starting_systemd_are_usable_because_the_products_own_unit_is_what_matters() {
896 for word in ["degraded", "starting", "initializing"] {
897 let runner = ScriptedRunner::new()
898 .always("--list --verbose", table())
899 .always("--exec id -u", CommandOutput::exited(0, "0\n", ""))
900 .always("--exec uname -m", CommandOutput::exited(0, "x86_64\n", ""))
901 .always(
902 "--exec systemctl is-system-running",
903 CommandOutput::exited(1, format!("{word}\n"), ""),
904 );
905 let executable = executable();
906 let ready = probe_readiness(&WslInvoker::new(&runner, &executable), "Ubuntu")
907 .unwrap_or_else(|error| panic!("{word} should be usable: {error}"));
908 assert!(ready.systemd().is_usable());
909 }
910 }
911
912 #[test]
913 fn the_preflight_mutates_nothing() {
914 let runner = healthy();
918 let executable = executable();
919 probe_readiness(&WslInvoker::new(&runner, &executable), "Ubuntu").expect("healthy");
920 for line in runner.command_lines() {
921 assert!(
922 [
923 "--list --verbose",
924 "id -u",
925 "uname -m",
926 "systemctl is-system-running"
927 ]
928 .iter()
929 .any(|read| line.contains(read)),
930 "the preflight ran something that is not a read: {line}"
931 );
932 }
933 }
934
935 #[test]
938 fn a_missing_system_root_falls_back_to_the_path_lookup() {
939 assert_eq!(locate_from(None, "wsl.exe"), PathBuf::from("wsl.exe"));
940 assert_eq!(
941 locate_from(Some(PathBuf::from("Q:\\NoSuchWindows")), "wsl.exe"),
942 PathBuf::from("wsl.exe")
943 );
944 }
945
946 #[test]
947 fn a_system_root_that_really_holds_the_executable_is_used() {
948 let root = tempfile::tempdir().expect("a temporary directory");
949 let system32 = root.path().join("System32");
950 std::fs::create_dir_all(&system32).expect("create System32");
951 std::fs::write(system32.join("wsl.exe"), b"not really an executable")
952 .expect("write the stand-in");
953 assert_eq!(
954 locate_from(Some(root.path().to_path_buf()), "wsl.exe"),
955 system32.join("wsl.exe")
956 );
957 }
958}