1use std::ffi::OsString;
65use std::path::{Path, PathBuf};
66
67use super::WslError;
68use super::discovery::{
69 decode_console_output, escaped_name_with_digest, validate_distribution_name,
70};
71use super::exec::{CommandRequest, CommandRunner};
72use super::probe::{LINUX_USER, WslExecutable, locate_in_system32};
73use crate::service::{TaskPrincipal, quote_argument, xml_escape, xml_value};
74
75pub const LIFECYCLE_TASK_PREFIX: &str = "runner-manager-wsl";
77
78pub const PRODUCT_MARKER: &str = "runner-manager-wsl-lifecycle/v1";
84
85pub const TASK_DEFINITION_REVISION: u32 = 2;
97
98pub const HOLD_ARGUMENTS: [&str; 2] = ["wsl-host", "hold"];
104
105#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct LifecycleTaskIdentity {
112 distribution: String,
113 name: String,
114}
115
116impl LifecycleTaskIdentity {
117 pub fn for_distribution(distribution: &str) -> Result<Self, WslError> {
130 validate_distribution_name(distribution)?;
131 Ok(Self {
132 distribution: distribution.to_string(),
133 name: format!(
134 "{LIFECYCLE_TASK_PREFIX}-{}",
135 escaped_name_with_digest(distribution)
136 ),
137 })
138 }
139
140 #[must_use]
142 pub fn distribution(&self) -> &str {
143 &self.distribution
144 }
145
146 #[must_use]
148 pub fn name(&self) -> &str {
149 &self.name
150 }
151
152 fn description(&self, stamp: &str) -> String {
155 format!(
156 "Keeps the WSL distribution \"{}\" running so its runner-manager service can \
157 accept jobs after this account logs on. Created and owned by runner-manager \
158 ({PRODUCT_MARKER}, {stamp}); remove it with `runner-manager wsl detach \
159 --distribution {}`.",
160 self.distribution, self.distribution
161 )
162 }
163}
164
165#[derive(Debug, Clone)]
171pub struct LifecycleTask {
172 identity: LifecycleTaskIdentity,
173 principal: TaskPrincipal,
174 wsl_executable: PathBuf,
175 linux_binary: String,
176 recovery_root: Option<PathBuf>,
177 windows_supervisor: Option<PathBuf>,
178 windows_child: Option<PathBuf>,
179}
180
181impl LifecycleTask {
182 #[must_use]
184 pub fn new(
185 identity: LifecycleTaskIdentity,
186 principal: TaskPrincipal,
187 wsl_executable: &WslExecutable,
188 linux_binary: impl Into<String>,
189 ) -> Self {
190 Self {
191 identity,
192 principal,
193 wsl_executable: wsl_executable.path().to_path_buf(),
194 linux_binary: linux_binary.into(),
195 recovery_root: None,
196 windows_supervisor: None,
197 windows_child: None,
198 }
199 }
200
201 #[must_use]
203 pub fn with_recovery_root(mut self, path: PathBuf) -> Self {
204 self.recovery_root = Some(path);
205 self
206 }
207
208 #[must_use]
212 pub fn with_windows_supervisor(mut self, supervisor: PathBuf, child: PathBuf) -> Self {
213 self.windows_supervisor = Some(supervisor);
214 self.windows_child = Some(child);
215 self
216 }
217
218 #[must_use]
220 pub fn identity(&self) -> &LifecycleTaskIdentity {
221 &self.identity
222 }
223
224 #[must_use]
226 pub fn principal(&self) -> &TaskPrincipal {
227 &self.principal
228 }
229
230 #[must_use]
232 pub fn command(&self) -> &Path {
233 self.windows_supervisor
234 .as_deref()
235 .unwrap_or(&self.wsl_executable)
236 }
237
238 #[must_use]
245 pub fn action_arguments(&self) -> Vec<String> {
246 if self.windows_supervisor.is_some() {
247 let mut argv = vec![
248 self.windows_child
249 .as_ref()
250 .expect("a Windows supervisor always has a child")
251 .to_string_lossy()
252 .into_owned(),
253 "wsl-host".to_string(),
254 "supervise".to_string(),
255 "--distribution".to_string(),
256 self.identity.distribution.clone(),
257 "--linux-binary".to_string(),
258 self.linux_binary.clone(),
259 ];
260 if let Some(root) = &self.recovery_root {
261 argv.push("--shared-root".to_string());
262 argv.push(root.to_string_lossy().into_owned());
263 }
264 return argv;
265 }
266 let mut argv = vec![
267 "--distribution".to_string(),
268 self.identity.distribution.clone(),
269 "--user".to_string(),
270 LINUX_USER.to_string(),
271 "--exec".to_string(),
272 self.linux_binary.clone(),
273 ];
274 argv.extend(
275 HOLD_ARGUMENTS
276 .iter()
277 .map(|argument| (*argument).to_string()),
278 );
279 if let Some(root) = &self.recovery_root {
280 argv.push("--shared-root".to_string());
281 argv.push(root.to_string_lossy().into_owned());
282 }
283 argv
284 }
285
286 #[must_use]
288 pub fn rendered_arguments(&self) -> String {
289 self.action_arguments()
290 .iter()
291 .map(|argument| quote_argument(argument))
292 .collect::<Vec<_>>()
293 .join(" ")
294 }
295
296 #[must_use]
309 pub fn definition_stamp(&self) -> String {
310 use sha2::{Digest, Sha256};
311 let mut digest = Sha256::new();
312 for part in [
313 self.command().to_string_lossy().as_ref(),
314 self.rendered_arguments().as_str(),
315 self.principal.user_id(),
316 ] {
317 digest.update(part.as_bytes());
318 digest.update([0]);
319 }
320 let digest = hex::encode(digest.finalize());
321 format!(
322 "definition {TASK_DEFINITION_REVISION}, fingerprint {}",
323 &digest[..16]
324 )
325 }
326
327 #[must_use]
329 pub fn description(&self) -> String {
330 self.identity.description(&self.definition_stamp())
331 }
332
333 #[must_use]
348 pub fn is_registered_as(&self, registered: &RegisteredTask) -> bool {
349 let command = self.command().to_string_lossy();
350 let arguments = self.rendered_arguments();
351 let action_matches = !(command.is_ascii() && arguments.is_ascii())
352 || (registered.command().eq_ignore_ascii_case(&command)
353 && registered.arguments() == arguments);
354 registered.is_product_owned()
355 && registered.enabled()
356 && registered.description().contains(&self.definition_stamp())
357 && action_matches
358 }
359
360 #[must_use]
362 pub fn xml(&self) -> String {
363 let user = xml_escape(self.principal.user_id());
364 let mut out = String::new();
365 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
366 out.push_str(
367 "<Task version=\"1.4\" \
368 xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
369 );
370 out.push_str(" <RegistrationInfo>\n");
371 out.push_str(&format!(
372 " <Description>{}</Description>\n",
373 xml_escape(&self.description())
374 ));
375 out.push_str(&format!(
376 " <URI>\\{}</URI>\n",
377 xml_escape(self.identity.name())
378 ));
379 out.push_str(" </RegistrationInfo>\n");
380
381 out.push_str(" <Triggers>\n <LogonTrigger>\n");
382 out.push_str(" <Enabled>true</Enabled>\n");
383 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
384 out.push_str(" </LogonTrigger>\n </Triggers>\n");
385
386 out.push_str(" <Principals>\n <Principal id=\"Author\">\n");
391 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
392 out.push_str(" <LogonType>InteractiveToken</LogonType>\n");
393 out.push_str(" <RunLevel>LeastPrivilege</RunLevel>\n");
394 out.push_str(" </Principal>\n </Principals>\n");
395
396 out.push_str(" <Settings>\n");
397 out.push_str(" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
400 out.push_str(" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
401 out.push_str(" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
402 out.push_str(" <AllowHardTerminate>true</AllowHardTerminate>\n");
403 out.push_str(" <StartWhenAvailable>true</StartWhenAvailable>\n");
404 out.push_str(" <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
405 out.push_str(" <IdleSettings>\n");
406 out.push_str(" <StopOnIdleEnd>false</StopOnIdleEnd>\n");
407 out.push_str(" <RestartOnIdle>false</RestartOnIdle>\n");
408 out.push_str(" </IdleSettings>\n");
409 out.push_str(" <AllowStartOnDemand>true</AllowStartOnDemand>\n");
410 out.push_str(" <Enabled>true</Enabled>\n");
411 out.push_str(" <Hidden>false</Hidden>\n");
412 out.push_str(" <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
413 out.push_str(" <WakeToRun>false</WakeToRun>\n");
414 out.push_str(" <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
417 out.push_str(" <Priority>7</Priority>\n");
418 out.push_str(" <RestartOnFailure>\n");
419 out.push_str(" <Interval>PT1M</Interval>\n");
420 out.push_str(" <Count>5</Count>\n");
421 out.push_str(" </RestartOnFailure>\n");
422 out.push_str(" </Settings>\n");
423
424 out.push_str(" <Actions Context=\"Author\">\n <Exec>\n");
425 out.push_str(&format!(
426 " <Command>{}</Command>\n",
427 xml_escape(&self.command().to_string_lossy())
428 ));
429 out.push_str(&format!(
430 " <Arguments>{}</Arguments>\n",
431 xml_escape(&self.rendered_arguments())
432 ));
433 out.push_str(" </Exec>\n </Actions>\n");
434 out.push_str("</Task>\n");
435 out
436 }
437}
438
439#[derive(Debug, Clone, PartialEq, Eq)]
445pub struct RegisteredTask {
446 name: String,
447 command: String,
448 arguments: String,
449 account: Option<String>,
450 description: String,
451 enabled: bool,
452 running: bool,
453}
454
455impl RegisteredTask {
456 #[must_use]
459 pub fn from_document(name: &str, document: &str, running: bool) -> Self {
460 Self {
461 name: name.to_string(),
462 command: xml_value(document, "Command").unwrap_or_default(),
463 arguments: xml_value(document, "Arguments").unwrap_or_default(),
464 account: xml_value(document, "UserId"),
465 description: xml_value(document, "Description").unwrap_or_default(),
466 enabled: task_is_enabled(document),
467 running,
468 }
469 }
470
471 #[must_use]
473 pub fn name(&self) -> &str {
474 &self.name
475 }
476
477 #[must_use]
479 pub fn command(&self) -> &str {
480 &self.command
481 }
482
483 #[must_use]
485 pub fn arguments(&self) -> &str {
486 &self.arguments
487 }
488
489 #[must_use]
491 pub fn account(&self) -> Option<&str> {
492 self.account.as_deref()
493 }
494
495 #[must_use]
497 pub fn description(&self) -> &str {
498 &self.description
499 }
500
501 #[must_use]
503 pub fn enabled(&self) -> bool {
504 self.enabled
505 }
506
507 #[must_use]
521 pub fn running(&self) -> bool {
522 self.running
523 }
524
525 #[must_use]
531 pub fn is_product_owned(&self) -> bool {
532 self.description.contains(PRODUCT_MARKER)
533 }
534}
535
536fn task_is_enabled(document: &str) -> bool {
548 let settings = document
549 .find("<Settings>")
550 .map_or(document, |start| &document[start..]);
551 xml_value(settings, "Enabled").as_deref() != Some("false")
552}
553
554#[derive(Debug)]
565pub struct LifecycleTaskControl<'runner> {
566 runner: &'runner dyn CommandRunner,
567 schtasks: PathBuf,
568}
569
570#[derive(Debug, Clone, PartialEq, Eq)]
572pub struct Detached {
573 pub removed: bool,
575 pub name: String,
577}
578
579impl<'runner> LifecycleTaskControl<'runner> {
580 #[must_use]
582 pub fn new(runner: &'runner dyn CommandRunner) -> Self {
583 Self {
584 runner,
585 schtasks: locate_in_system32("schtasks.exe"),
586 }
587 }
588
589 #[must_use]
591 pub fn with_executable(
592 runner: &'runner dyn CommandRunner,
593 schtasks: impl Into<PathBuf>,
594 ) -> Self {
595 Self {
596 runner,
597 schtasks: schtasks.into(),
598 }
599 }
600
601 pub fn query(
607 &self,
608 identity: &LifecycleTaskIdentity,
609 ) -> Result<Option<RegisteredTask>, WslError> {
610 let output = self.schtasks(&["/Query", "/TN", identity.name(), "/XML", "ONE"])?;
611 if !output.success() {
612 return Ok(None);
620 }
621 let document = decode_console_output(output.stdout()).into_text();
622 Ok(Some(RegisteredTask::from_document(
623 identity.name(),
624 &document,
625 self.is_running(identity),
626 )))
627 }
628
629 pub fn register(&self, task: &LifecycleTask) -> Result<(), WslError> {
644 let identity = task.identity();
645 match self.query(identity)? {
646 Some(existing) if !existing.is_product_owned() => {
647 return Err(WslError::ForeignTask {
648 name: identity.name().to_string(),
649 detail: format!(
650 "a task of this name already exists, its description does not identify \
651 it as this product's ({PRODUCT_MARKER}), and it starts `{}`. Rename or \
652 remove it yourself if it is the hand-created keep-alive this feature \
653 replaces.",
654 existing.command()
655 ),
656 });
657 }
658 Some(_) => {}
659 None if self.exists(identity) => {
666 return Err(WslError::ForeignTask {
667 name: identity.name().to_string(),
668 detail: format!(
669 "a task of this name exists but Task Scheduler would not export its \
670 definition, so it cannot be shown to be this product's \
671 ({PRODUCT_MARKER}) and registering would replace it. Inspect it in \
672 `taskschd.msc`, and rename or remove it yourself if it is the \
673 hand-created keep-alive this feature replaces."
674 ),
675 });
676 }
677 None => {}
678 }
679
680 let directory = tempfile::tempdir().map_err(|error| WslError::Record {
681 operation: "write",
682 path: PathBuf::from("<the scheduled-task document>"),
683 detail: error.to_string(),
684 })?;
685 let document = directory.path().join("task.xml");
686 write_utf16(&document, &task.xml()).map_err(|error| WslError::Record {
687 operation: "write",
688 path: document.clone(),
689 detail: error.to_string(),
690 })?;
691
692 let output = self.schtasks(&[
693 "/Create",
694 "/TN",
695 identity.name(),
696 "/XML",
697 &document.to_string_lossy(),
698 "/F",
699 ])?;
700 if !output.success() {
701 return Err(self.task_error("register", identity.name(), &output.diagnostic()));
702 }
703 Ok(())
704 }
705
706 pub fn detach(&self, identity: &LifecycleTaskIdentity) -> Result<Detached, WslError> {
718 let Some(existing) = self.query(identity)? else {
719 return Ok(Detached {
720 removed: false,
721 name: identity.name().to_string(),
722 });
723 };
724 if !existing.is_product_owned() {
725 return Err(WslError::ForeignTask {
726 name: identity.name().to_string(),
727 detail: format!(
728 "a task of this name exists but its description does not identify it as \
729 this product's ({PRODUCT_MARKER}), so `detach` will not remove it."
730 ),
731 });
732 }
733 let output = self.schtasks(&["/Delete", "/TN", identity.name(), "/F"])?;
734 if !output.success() {
735 return Err(self.task_error("remove", identity.name(), &output.diagnostic()));
736 }
737 Ok(Detached {
738 removed: true,
739 name: identity.name().to_string(),
740 })
741 }
742
743 pub fn start(&self, identity: &LifecycleTaskIdentity) -> Result<(), WslError> {
751 self.require_ours("start", identity)?;
752 let output = self.schtasks(&["/Run", "/TN", identity.name()])?;
753 if !output.success() {
754 return Err(self.task_error("start", identity.name(), &output.diagnostic()));
755 }
756 Ok(())
757 }
758
759 pub fn stop(&self, identity: &LifecycleTaskIdentity) -> Result<bool, WslError> {
765 let existing = self.require_ours("stop", identity)?;
766 if !existing.running() {
767 return Ok(false);
768 }
769 let output = self.schtasks(&["/End", "/TN", identity.name()])?;
770 if !output.success() {
771 return Err(self.task_error("stop", identity.name(), &output.diagnostic()));
772 }
773 Ok(true)
774 }
775
776 fn require_ours(
777 &self,
778 operation: &'static str,
779 identity: &LifecycleTaskIdentity,
780 ) -> Result<RegisteredTask, WslError> {
781 let Some(existing) = self.query(identity)? else {
782 return Err(WslError::NoSuchTask {
783 name: identity.name().to_string(),
784 });
785 };
786 if !existing.is_product_owned() {
787 return Err(WslError::ForeignTask {
788 name: identity.name().to_string(),
789 detail: format!(
790 "a task of this name exists but is not this product's ({PRODUCT_MARKER}), \
791 so it will not be used to {operation} anything."
792 ),
793 });
794 }
795 Ok(existing)
796 }
797
798 fn schtasks(&self, arguments: &[&str]) -> Result<super::exec::CommandOutput, WslError> {
799 let request =
800 CommandRequest::new(&self.schtasks).args(arguments.iter().map(OsString::from));
801 self.runner.run(&request)
802 }
803
804 fn query_csv(&self, identity: &LifecycleTaskIdentity) -> Option<super::exec::CommandOutput> {
816 let output = self
817 .schtasks(&["/Query", "/TN", identity.name(), "/FO", "CSV", "/NH"])
818 .ok()?;
819 output.success().then_some(output)
820 }
821
822 fn exists(&self, identity: &LifecycleTaskIdentity) -> bool {
827 self.query_csv(identity).is_some()
828 }
829
830 fn is_running(&self, identity: &LifecycleTaskIdentity) -> bool {
833 let Some(output) = self.query_csv(identity) else {
834 return false;
835 };
836 decode_console_output(output.stdout())
837 .into_text()
838 .lines()
839 .filter_map(|line| line.rsplit(',').next())
840 .any(|status| {
841 status
842 .trim()
843 .trim_matches('"')
844 .eq_ignore_ascii_case("running")
845 })
846 }
847
848 fn task_error(&self, operation: &'static str, name: &str, detail: &str) -> WslError {
849 if detail.to_ascii_lowercase().contains("access is denied") {
850 return WslError::NeedsElevation {
851 operation,
852 name: name.to_string(),
853 detail: detail.to_string(),
854 };
855 }
856 WslError::TaskControl {
857 operation,
858 name: name.to_string(),
859 detail: detail.to_string(),
860 }
861 }
862}
863
864fn write_utf16(path: &Path, text: &str) -> std::io::Result<()> {
870 let mut bytes = vec![0xFF, 0xFE];
871 for unit in text.encode_utf16() {
872 bytes.extend_from_slice(&unit.to_le_bytes());
873 }
874 std::fs::write(path, bytes)
875}
876
877#[cfg(test)]
878mod tests {
879 use super::*;
880 use crate::wsl::discovery::{DIGEST_SUFFIX_LENGTH, ESCAPED_NAME_BUDGET};
881 use crate::wsl::exec::{CommandOutput, ScriptedRunner};
882
883 fn identity(distribution: &str) -> LifecycleTaskIdentity {
884 LifecycleTaskIdentity::for_distribution(distribution).expect("a usable name")
885 }
886
887 fn task(distribution: &str) -> LifecycleTask {
888 LifecycleTask::new(
889 identity(distribution),
890 TaskPrincipal::named("IVANPC\\IvanD"),
891 &WslExecutable::at("C:\\Windows\\System32\\wsl.exe"),
892 "/usr/local/bin/runner-manager",
893 )
894 }
895
896 fn registered_document(distribution: &str) -> CommandOutput {
897 CommandOutput::exited(0, task(distribution).xml(), "")
898 }
899
900 #[test]
903 fn the_task_name_is_stable_for_a_distribution() {
904 assert_eq!(identity("Ubuntu").name(), identity("Ubuntu").name());
905 assert!(
906 identity("Ubuntu")
907 .name()
908 .starts_with("runner-manager-wsl-Ubuntu-")
909 );
910 }
911
912 #[test]
913 fn a_name_task_scheduler_could_not_hold_is_escaped_into_one_that_it_can() {
914 let name = identity("Debian GNU/Linux 12").name().to_string();
915 for forbidden in ['\\', '/', ':', '*', '?', '"', '<', '>', '|'] {
916 assert!(
917 !name.contains(forbidden),
918 "{name} still contains {forbidden:?}"
919 );
920 }
921 assert!(name.contains("Debian_GNU_Linux_12"), "{name}");
922 }
923
924 #[test]
925 fn two_distributions_that_escape_alike_still_get_different_tasks() {
926 let first = identity("Debian GNU/Linux");
930 let second = identity("Debian GNU:Linux");
931 assert_ne!(first.name(), second.name());
932 assert!(first.name().contains("Debian_GNU_Linux"));
933 assert!(second.name().contains("Debian_GNU_Linux"));
934 }
935
936 #[test]
937 fn a_very_long_name_is_bounded_and_still_unique() {
938 let long = "u".repeat(200);
939 let other = format!("{long}x");
940 let first = identity(&long);
941 let second = identity(&other);
942 assert_ne!(first.name(), second.name());
943 assert!(
944 first.name().len()
945 <= LIFECYCLE_TASK_PREFIX.len() + 1 + ESCAPED_NAME_BUDGET + 1 + DIGEST_SUFFIX_LENGTH,
946 "{}",
947 first.name()
948 );
949 }
950
951 #[test]
952 fn a_distribution_name_that_is_not_usable_never_becomes_a_task_name() {
953 assert!(LifecycleTaskIdentity::for_distribution("--shutdown").is_err());
954 assert!(LifecycleTaskIdentity::for_distribution("").is_err());
955 }
956
957 #[test]
960 fn the_action_is_the_documented_argument_vector() {
961 assert_eq!(
962 task("Ubuntu").action_arguments(),
963 vec![
964 "--distribution",
965 "Ubuntu",
966 "--user",
967 "root",
968 "--exec",
969 "/usr/local/bin/runner-manager",
970 "wsl-host",
971 "hold",
972 ]
973 );
974 }
975
976 #[test]
977 fn a_name_with_spaces_is_quoted_so_windows_splits_it_back_into_one_argument() {
978 let rendered = task("My Ubuntu").rendered_arguments();
979 assert!(
980 rendered.contains("--distribution \"My Ubuntu\" --user root"),
981 "{rendered}"
982 );
983 }
984
985 #[test]
986 fn no_shell_text_reaches_the_task_document() {
987 let document = task("Ubuntu & echo pwned").xml();
990 let arguments = xml_value(&document, "Arguments").expect("the document has an action");
991 for shell in ["cmd", "powershell", "/c", "&&", "||", ";", "$(", "`"] {
992 assert!(
993 !arguments.contains(shell),
994 "the rendered arguments contain shell text {shell:?}: {arguments}"
995 );
996 }
997 assert_eq!(
998 xml_value(&document, "Command").as_deref(),
999 Some("C:\\Windows\\System32\\wsl.exe")
1000 );
1001 assert!(document.contains("&"), "{document}");
1004 assert!(arguments.contains("\"Ubuntu & echo pwned\""), "{arguments}");
1005 }
1006
1007 #[test]
1008 fn the_document_is_a_least_privilege_logon_task_for_the_named_principal() {
1009 let document = task("Ubuntu").xml();
1010 assert!(document.contains("<LogonTrigger>"), "{document}");
1011 assert!(
1012 document.contains("<RunLevel>LeastPrivilege</RunLevel>"),
1013 "{document}"
1014 );
1015 assert!(
1016 document.contains("<UserId>IVANPC\\IvanD</UserId>"),
1017 "{document}"
1018 );
1019 assert!(document.contains("<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>"));
1021 }
1022
1023 #[test]
1024 fn the_document_carries_the_ownership_marker_and_names_the_distribution() {
1025 let document = task("Ubuntu").xml();
1026 let description = xml_value(&document, "Description").expect("a description");
1027 assert!(description.contains(PRODUCT_MARKER), "{description}");
1028 assert!(description.contains("Ubuntu"), "{description}");
1029 assert!(description.contains("wsl detach"), "{description}");
1030 }
1031
1032 #[test]
1033 fn a_rendered_document_reads_back_as_this_products_task() {
1034 let document = task("Ubuntu").xml();
1035 let read = RegisteredTask::from_document("whatever", &document, false);
1036 assert!(read.is_product_owned());
1037 assert_eq!(read.account(), Some("IVANPC\\IvanD"));
1038 assert!(read.enabled());
1039 assert!(
1040 read.arguments().contains("wsl-host hold"),
1041 "{}",
1042 read.arguments()
1043 );
1044 }
1045
1046 #[test]
1047 fn a_supervised_task_runs_in_windows_and_carries_the_exact_guest_identity() {
1048 let supervised = task("Ubuntu")
1049 .with_recovery_root(PathBuf::from(r"C:\state\wsl-recovery\Ubuntu"))
1050 .with_windows_supervisor(
1051 PathBuf::from(r"C:\runner-manager-supervisor.exe"),
1052 PathBuf::from(r"C:\runner-manager.exe"),
1053 );
1054 assert_eq!(
1055 supervised.command(),
1056 Path::new(r"C:\runner-manager-supervisor.exe")
1057 );
1058 assert_eq!(
1059 supervised.action_arguments(),
1060 vec![
1061 r"C:\runner-manager.exe",
1062 "wsl-host",
1063 "supervise",
1064 "--distribution",
1065 "Ubuntu",
1066 "--linux-binary",
1067 "/usr/local/bin/runner-manager",
1068 "--shared-root",
1069 r"C:\state\wsl-recovery\Ubuntu",
1070 ]
1071 );
1072 let document = supervised.xml();
1073 assert!(
1074 document.contains(r"C:\runner-manager-supervisor.exe"),
1075 "{document}"
1076 );
1077 assert!(document.contains("wsl-host supervise"), "{document}");
1078 assert!(!document.contains("wsl.exe</Command>"), "{document}");
1079 }
1080
1081 fn supervised(directory: &str) -> LifecycleTask {
1082 task("Ubuntu")
1083 .with_recovery_root(PathBuf::from(r"C:\state\wsl-recovery\Ubuntu"))
1084 .with_windows_supervisor(
1085 PathBuf::from(format!(r"{directory}\runner-manager-wsl-supervisor.exe")),
1086 PathBuf::from(format!(r"{directory}\runner-manager-wsl.exe")),
1087 )
1088 }
1089
1090 #[test]
1091 fn a_registered_copy_of_the_same_document_needs_no_registration() {
1092 let desired = supervised(r"C:\state\bin");
1096 let registered = RegisteredTask::from_document("whatever", &desired.xml(), true);
1097 assert!(desired.is_registered_as(®istered));
1098
1099 let exported = desired
1102 .xml()
1103 .replace(r"IVANPC\IvanD", "S-1-5-21-1-2-3-1001")
1104 .replace(
1105 r"<Command>C:\state\bin\runner-manager-wsl-supervisor.exe</Command>",
1106 r"<Command>c:\STATE\bin\RUNNER-MANAGER-WSL-SUPERVISOR.EXE</Command>",
1107 );
1108 assert!(!exported.contains("IVANPC") && !exported.contains(r"<Command>C:\state"));
1109 let registered = RegisteredTask::from_document("whatever", &exported, true);
1110 assert!(desired.is_registered_as(®istered));
1111 }
1112
1113 #[test]
1114 fn a_profile_outside_the_console_code_page_still_reads_back_as_this_task() {
1115 let desired = task("Ubuntu")
1120 .with_recovery_root(PathBuf::from(r"C:\Users\Иван\state\wsl-recovery\Ubuntu"))
1121 .with_windows_supervisor(
1122 PathBuf::from(r"C:\Users\Иван\state\bin\runner-manager-wsl-supervisor.exe"),
1123 PathBuf::from(r"C:\Users\Иван\state\bin\runner-manager-wsl.exe"),
1124 );
1125 let lossy: String = desired
1126 .xml()
1127 .chars()
1128 .map(|c| if c.is_ascii() { c } else { '?' })
1129 .collect();
1130 let registered = RegisteredTask::from_document("whatever", &lossy, true);
1131 assert!(desired.is_registered_as(®istered));
1132
1133 let other_stamp = lossy.replace(
1134 &desired.definition_stamp(),
1135 "definition 2, fingerprint 0000000000000000",
1136 );
1137 assert_ne!(other_stamp, lossy);
1138 let registered = RegisteredTask::from_document("whatever", &other_stamp, true);
1139 assert!(!desired.is_registered_as(®istered));
1140 }
1141
1142 #[test]
1143 fn a_document_that_differs_in_anything_compared_is_registered_again() {
1144 let desired = supervised(r"C:\state\bin");
1145 let somebody_else = LifecycleTask::new(
1146 identity("Ubuntu"),
1147 TaskPrincipal::named(r"IVANPC\Somebody"),
1148 &WslExecutable::at(r"C:\Windows\System32\wsl.exe"),
1149 "/usr/local/bin/runner-manager",
1150 )
1151 .with_recovery_root(PathBuf::from(r"C:\state\wsl-recovery\Ubuntu"))
1152 .with_windows_supervisor(
1153 PathBuf::from(r"C:\state\bin\runner-manager-wsl-supervisor.exe"),
1154 PathBuf::from(r"C:\state\bin\runner-manager-wsl.exe"),
1155 );
1156 let differing = [
1157 (
1158 "the versioned 0.4.34 companion paths",
1159 supervised(r"C:\state\bin")
1160 .with_windows_supervisor(
1161 PathBuf::from(r"C:\state\bin\runner-manager-wsl-supervisor-0.4.34.exe"),
1162 PathBuf::from(r"C:\state\bin\runner-manager-wsl-0.4.34.exe"),
1163 )
1164 .xml(),
1165 ),
1166 (
1167 "an earlier definition revision",
1168 desired
1169 .xml()
1170 .replace(&desired.definition_stamp(), "definition 1"),
1171 ),
1172 (
1173 "a task an operator disabled",
1174 desired.xml().replace(
1175 "\n <Enabled>true</Enabled>\n",
1176 "\n <Enabled>false</Enabled>\n",
1177 ),
1178 ),
1179 ("another account", somebody_else.xml()),
1180 (
1181 "a different recovery root",
1182 supervised(r"C:\state\bin")
1183 .with_recovery_root(PathBuf::from(r"C:\elsewhere\Ubuntu"))
1184 .xml(),
1185 ),
1186 (
1187 "an action edited by hand under the same description",
1188 desired.xml().replace("wsl-recovery", "elsewhere"),
1189 ),
1190 ];
1191 for (what, document) in differing {
1192 assert_ne!(document, desired.xml(), "{what}: the case must differ");
1193 let registered = RegisteredTask::from_document("whatever", &document, true);
1194 assert!(!desired.is_registered_as(®istered), "{what}");
1195 }
1196
1197 let foreign = RegisteredTask::from_document(
1198 "whatever",
1199 "<Task><RegistrationInfo><Description>mine</Description></RegistrationInfo></Task>",
1200 false,
1201 );
1202 assert!(!desired.is_registered_as(&foreign));
1203 }
1204
1205 #[test]
1206 fn a_task_an_operator_disabled_is_reported_as_disabled() {
1207 let disabled = task("Ubuntu").xml().replace(
1215 "\n <Enabled>true</Enabled>\n",
1216 "\n <Enabled>false</Enabled>\n",
1217 );
1218 assert!(
1219 disabled.contains(" <Enabled>true</Enabled>"),
1220 "the trigger's own <Enabled> must still be true for this to prove anything"
1221 );
1222 assert!(!RegisteredTask::from_document("whatever", &disabled, false).enabled());
1223 assert!(RegisteredTask::from_document("whatever", &task("Ubuntu").xml(), false).enabled());
1224 }
1225
1226 #[test]
1227 fn a_task_this_product_did_not_write_is_not_product_owned() {
1228 let hand_made = concat!(
1229 "<Task><RegistrationInfo><Description>GitHub Actions Linux Runner - Ubuntu WSL",
1230 "</Description></RegistrationInfo><Actions><Exec><Command>wsl.exe</Command>",
1231 "<Arguments>-d Ubuntu -u root /bin/sleep infinity</Arguments></Exec></Actions></Task>",
1232 );
1233 let read = RegisteredTask::from_document("whatever", hand_made, false);
1234 assert!(!read.is_product_owned());
1235 }
1236
1237 fn control(runner: &ScriptedRunner) -> LifecycleTaskControl<'_> {
1240 LifecycleTaskControl::with_executable(runner, "schtasks.exe")
1241 }
1242
1243 #[test]
1244 fn registering_writes_a_utf16_document_and_replaces_in_place() {
1245 let runner = ScriptedRunner::new().always("/Query", CommandOutput::exited(1, "", ""));
1246 let task = task("Ubuntu");
1247 control(&runner).register(&task).expect("registered");
1248
1249 let create = runner
1250 .recorded()
1251 .into_iter()
1252 .find(|request| request.arguments.first().map(String::as_str) == Some("/Create"))
1253 .expect("a /Create call");
1254 assert_eq!(create.arguments[1], "/TN");
1255 assert_eq!(create.arguments[2], task.identity().name());
1256 assert_eq!(create.arguments[3], "/XML");
1257 assert_eq!(
1258 create.arguments[5], "/F",
1259 "without /F a second `wsl install` fails instead of updating the task"
1260 );
1261 }
1262
1263 #[test]
1264 fn registering_over_this_products_own_task_is_allowed_and_idempotent() {
1265 let runner = ScriptedRunner::new()
1266 .always("/Query", registered_document("Ubuntu"))
1267 .always("/Create", CommandOutput::exited(0, "SUCCESS", ""));
1268 control(&runner)
1269 .register(&task("Ubuntu"))
1270 .expect("replaced");
1271 control(&runner)
1272 .register(&task("Ubuntu"))
1273 .expect("replaced again");
1274 }
1275
1276 #[test]
1277 fn registering_over_a_task_that_cannot_be_exported_refuses_and_changes_nothing() {
1278 let runner = ScriptedRunner::new()
1283 .always(
1284 "/XML",
1285 CommandOutput::exited(1, "", "the task image is corrupt"),
1286 )
1287 .always(
1288 "/FO",
1289 CommandOutput::exited(0, "\"whatever\",\"N/A\",\"Ready\"", ""),
1290 );
1291 let error = control(&runner)
1292 .register(&task("Ubuntu"))
1293 .expect_err("an unexportable task is not a free name");
1294 assert!(matches!(error, WslError::ForeignTask { .. }), "{error:?}");
1295 assert!(
1296 runner
1297 .command_lines()
1298 .iter()
1299 .all(|line| !line.contains("/Create")),
1300 "nothing may be written: {:?}",
1301 runner.command_lines()
1302 );
1303 }
1304
1305 #[test]
1306 fn registering_over_a_foreign_task_refuses_and_changes_nothing() {
1307 let hand_made = CommandOutput::exited(
1308 0,
1309 concat!(
1310 "<Task><RegistrationInfo><Description>GitHub Actions Linux Runner - Ubuntu WSL",
1311 "</Description></RegistrationInfo><Actions><Exec><Command>wsl.exe</Command>",
1312 "</Exec></Actions></Task>",
1313 ),
1314 "",
1315 );
1316 let runner = ScriptedRunner::new().always("/Query", hand_made);
1317 let error = control(&runner)
1318 .register(&task("Ubuntu"))
1319 .expect_err("not ours");
1320 assert!(matches!(error, WslError::ForeignTask { .. }), "{error:?}");
1321 assert!(
1322 runner
1323 .command_lines()
1324 .iter()
1325 .all(|line| !line.contains("/Create")),
1326 "nothing may be written: {:?}",
1327 runner.command_lines()
1328 );
1329 }
1330
1331 #[test]
1332 fn detach_removes_only_the_product_task_and_runs_nothing_else() {
1333 let runner = ScriptedRunner::new()
1334 .always("/Query", registered_document("Ubuntu"))
1335 .always("/Delete", CommandOutput::exited(0, "SUCCESS", ""));
1336 let detached = control(&runner)
1337 .detach(&identity("Ubuntu"))
1338 .expect("detached");
1339 assert!(detached.removed);
1340
1341 for request in runner.recorded() {
1342 assert_eq!(
1343 request.program.to_string_lossy(),
1344 "schtasks.exe",
1345 "detach must not run anything but Task Scheduler: {request:?}"
1346 );
1347 }
1348 let lines = runner.command_lines();
1349 assert!(
1350 lines.iter().all(|line| !line.contains("wsl.exe")),
1351 "detach must not reach into the distribution: {lines:?}"
1352 );
1353 assert!(
1354 lines
1355 .iter()
1356 .all(|line| !line.contains("--unregister") && !line.contains("systemctl")),
1357 "detach must not unregister WSL or touch the Linux service: {lines:?}"
1358 );
1359 }
1360
1361 #[test]
1362 fn detach_without_a_task_is_not_an_error() {
1363 let runner = ScriptedRunner::new().always("/Query", CommandOutput::exited(1, "", ""));
1364 let detached = control(&runner)
1365 .detach(&identity("Ubuntu"))
1366 .expect("nothing to remove");
1367 assert!(!detached.removed);
1368 assert!(
1369 runner
1370 .command_lines()
1371 .iter()
1372 .all(|line| !line.contains("/Delete"))
1373 );
1374 }
1375
1376 #[test]
1377 fn detach_refuses_a_foreign_task_rather_than_deleting_it() {
1378 let runner = ScriptedRunner::new().always(
1379 "/Query",
1380 CommandOutput::exited(
1381 0,
1382 "<Task><RegistrationInfo><Description>Somebody else's task</Description>\
1383 </RegistrationInfo></Task>",
1384 "",
1385 ),
1386 );
1387 let error = control(&runner)
1388 .detach(&identity("Ubuntu"))
1389 .expect_err("not ours");
1390 assert!(matches!(error, WslError::ForeignTask { .. }), "{error:?}");
1391 assert!(
1392 runner
1393 .command_lines()
1394 .iter()
1395 .all(|line| !line.contains("/Delete")),
1396 "a task this product does not own must not be deleted"
1397 );
1398 }
1399
1400 #[test]
1401 fn access_denied_is_reported_as_needing_elevation_rather_than_as_a_generic_failure() {
1402 let runner = ScriptedRunner::new()
1403 .always("/Query", CommandOutput::exited(1, "", ""))
1404 .always(
1405 "/Create",
1406 CommandOutput::exited(1, "", "ERROR: Access is denied.\n"),
1407 );
1408 let error = control(&runner)
1409 .register(&task("Ubuntu"))
1410 .expect_err("denied");
1411 assert!(
1412 matches!(error, WslError::NeedsElevation { .. }),
1413 "{error:?}"
1414 );
1415 }
1416
1417 #[test]
1418 fn starting_a_task_that_is_not_registered_says_so() {
1419 let runner = ScriptedRunner::new().always("/Query", CommandOutput::exited(1, "", ""));
1420 let error = control(&runner)
1421 .start(&identity("Ubuntu"))
1422 .expect_err("not registered");
1423 assert!(matches!(error, WslError::NoSuchTask { .. }), "{error:?}");
1424 }
1425
1426 #[test]
1427 fn a_query_reads_a_utf16_document_as_schtasks_really_writes_it() {
1428 let mut bytes = vec![0xFF, 0xFE];
1429 for unit in task("Ubuntu").xml().encode_utf16() {
1430 bytes.extend_from_slice(&unit.to_le_bytes());
1431 }
1432 let runner = ScriptedRunner::new()
1433 .always("/XML ONE", CommandOutput::exited(0, bytes, ""))
1434 .always(
1435 "/FO CSV",
1436 CommandOutput::exited(0, "\"task\",\"N/A\",\"Ready\"\n", ""),
1437 );
1438 let found = control(&runner)
1439 .query(&identity("Ubuntu"))
1440 .expect("queried")
1441 .expect("registered");
1442 assert!(found.is_product_owned());
1443 assert!(!found.running());
1444 assert!(found.arguments().contains("wsl-host hold"));
1445 }
1446
1447 #[test]
1448 fn a_running_task_is_reported_from_the_csv_status_column() {
1449 let runner = ScriptedRunner::new()
1450 .always("/XML ONE", registered_document("Ubuntu"))
1451 .always(
1452 "/FO CSV",
1453 CommandOutput::exited(0, "\"\\task\",\"N/A\",\"Running\"\n", ""),
1454 );
1455 let found = control(&runner)
1456 .query(&identity("Ubuntu"))
1457 .expect("queried")
1458 .expect("registered");
1459 assert!(found.running());
1460 }
1461
1462 #[test]
1465 fn the_document_is_written_as_utf16_little_endian_with_a_byte_order_mark() {
1466 let directory = tempfile::tempdir().expect("a temporary directory");
1467 let path = directory.path().join("task.xml");
1468 write_utf16(&path, &task("Ubuntu").xml()).expect("written");
1469 let bytes = std::fs::read(&path).expect("readable");
1470 assert_eq!(&bytes[..2], &[0xFF, 0xFE]);
1471 let decoded = decode_console_output(&bytes);
1472 assert_eq!(decoded.text(), task("Ubuntu").xml());
1473 }
1474
1475 #[test]
1476 fn no_credential_shaped_value_can_reach_the_document() {
1477 let document = task("Ubuntu").xml().to_ascii_lowercase();
1482 for shape in [
1483 "ghu_",
1484 "ghs_",
1485 "gho_",
1486 "github_pat_",
1487 "access_token",
1488 "refresh_token",
1489 "jitconfig",
1490 "secret",
1491 "password",
1492 "credential",
1493 ] {
1494 assert!(
1495 !document.contains(shape),
1496 "the task document mentions {shape:?}: {document}"
1497 );
1498 }
1499 assert!(document.contains("interactivetoken"));
1505 }
1506}