1use std::ffi::OsString;
55use std::path::{Path, PathBuf};
56
57use super::WslError;
58use super::discovery::{
59 decode_console_output, escaped_name_with_digest, validate_distribution_name,
60};
61use super::exec::{CommandRequest, CommandRunner};
62use super::probe::{LINUX_USER, WslExecutable, locate_in_system32};
63use crate::service::{TaskPrincipal, quote_argument, xml_escape, xml_value};
64
65pub const LIFECYCLE_TASK_PREFIX: &str = "runner-manager-wsl";
67
68pub const PRODUCT_MARKER: &str = "runner-manager-wsl-lifecycle/v1";
74
75pub const HOLD_ARGUMENTS: [&str; 2] = ["wsl-host", "hold"];
81
82#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct LifecycleTaskIdentity {
89 distribution: String,
90 name: String,
91}
92
93impl LifecycleTaskIdentity {
94 pub fn for_distribution(distribution: &str) -> Result<Self, WslError> {
107 validate_distribution_name(distribution)?;
108 Ok(Self {
109 distribution: distribution.to_string(),
110 name: format!(
111 "{LIFECYCLE_TASK_PREFIX}-{}",
112 escaped_name_with_digest(distribution)
113 ),
114 })
115 }
116
117 #[must_use]
119 pub fn distribution(&self) -> &str {
120 &self.distribution
121 }
122
123 #[must_use]
125 pub fn name(&self) -> &str {
126 &self.name
127 }
128
129 #[must_use]
131 pub fn description(&self) -> String {
132 format!(
133 "Keeps the WSL distribution \"{}\" running so its runner-manager service can \
134 accept jobs after this account logs on. Created and owned by runner-manager \
135 ({PRODUCT_MARKER}); remove it with `runner-manager wsl detach --distribution \
136 {}`.",
137 self.distribution, self.distribution
138 )
139 }
140}
141
142#[derive(Debug, Clone)]
148pub struct LifecycleTask {
149 identity: LifecycleTaskIdentity,
150 principal: TaskPrincipal,
151 wsl_executable: PathBuf,
152 linux_binary: String,
153 recovery_root: Option<PathBuf>,
154 windows_supervisor: Option<PathBuf>,
155 windows_child: Option<PathBuf>,
156}
157
158impl LifecycleTask {
159 #[must_use]
161 pub fn new(
162 identity: LifecycleTaskIdentity,
163 principal: TaskPrincipal,
164 wsl_executable: &WslExecutable,
165 linux_binary: impl Into<String>,
166 ) -> Self {
167 Self {
168 identity,
169 principal,
170 wsl_executable: wsl_executable.path().to_path_buf(),
171 linux_binary: linux_binary.into(),
172 recovery_root: None,
173 windows_supervisor: None,
174 windows_child: None,
175 }
176 }
177
178 #[must_use]
180 pub fn with_recovery_root(mut self, path: PathBuf) -> Self {
181 self.recovery_root = Some(path);
182 self
183 }
184
185 #[must_use]
189 pub fn with_windows_supervisor(mut self, supervisor: PathBuf, child: PathBuf) -> Self {
190 self.windows_supervisor = Some(supervisor);
191 self.windows_child = Some(child);
192 self
193 }
194
195 #[must_use]
197 pub fn identity(&self) -> &LifecycleTaskIdentity {
198 &self.identity
199 }
200
201 #[must_use]
203 pub fn principal(&self) -> &TaskPrincipal {
204 &self.principal
205 }
206
207 #[must_use]
209 pub fn command(&self) -> &Path {
210 self.windows_supervisor
211 .as_deref()
212 .unwrap_or(&self.wsl_executable)
213 }
214
215 #[must_use]
222 pub fn action_arguments(&self) -> Vec<String> {
223 if self.windows_supervisor.is_some() {
224 let mut argv = vec![
225 self.windows_child
226 .as_ref()
227 .expect("a Windows supervisor always has a child")
228 .to_string_lossy()
229 .into_owned(),
230 "wsl-host".to_string(),
231 "supervise".to_string(),
232 "--distribution".to_string(),
233 self.identity.distribution.clone(),
234 "--linux-binary".to_string(),
235 self.linux_binary.clone(),
236 ];
237 if let Some(root) = &self.recovery_root {
238 argv.push("--shared-root".to_string());
239 argv.push(root.to_string_lossy().into_owned());
240 }
241 return argv;
242 }
243 let mut argv = vec![
244 "--distribution".to_string(),
245 self.identity.distribution.clone(),
246 "--user".to_string(),
247 LINUX_USER.to_string(),
248 "--exec".to_string(),
249 self.linux_binary.clone(),
250 ];
251 argv.extend(
252 HOLD_ARGUMENTS
253 .iter()
254 .map(|argument| (*argument).to_string()),
255 );
256 if let Some(root) = &self.recovery_root {
257 argv.push("--shared-root".to_string());
258 argv.push(root.to_string_lossy().into_owned());
259 }
260 argv
261 }
262
263 #[must_use]
265 pub fn rendered_arguments(&self) -> String {
266 self.action_arguments()
267 .iter()
268 .map(|argument| quote_argument(argument))
269 .collect::<Vec<_>>()
270 .join(" ")
271 }
272
273 #[must_use]
275 pub fn xml(&self) -> String {
276 let user = xml_escape(self.principal.user_id());
277 let mut out = String::new();
278 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
279 out.push_str(
280 "<Task version=\"1.4\" \
281 xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
282 );
283 out.push_str(" <RegistrationInfo>\n");
284 out.push_str(&format!(
285 " <Description>{}</Description>\n",
286 xml_escape(&self.identity.description())
287 ));
288 out.push_str(&format!(
289 " <URI>\\{}</URI>\n",
290 xml_escape(self.identity.name())
291 ));
292 out.push_str(" </RegistrationInfo>\n");
293
294 out.push_str(" <Triggers>\n <LogonTrigger>\n");
295 out.push_str(" <Enabled>true</Enabled>\n");
296 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
297 out.push_str(" </LogonTrigger>\n </Triggers>\n");
298
299 out.push_str(" <Principals>\n <Principal id=\"Author\">\n");
304 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
305 out.push_str(" <LogonType>InteractiveToken</LogonType>\n");
306 out.push_str(" <RunLevel>LeastPrivilege</RunLevel>\n");
307 out.push_str(" </Principal>\n </Principals>\n");
308
309 out.push_str(" <Settings>\n");
310 out.push_str(" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
313 out.push_str(" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
314 out.push_str(" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
315 out.push_str(" <AllowHardTerminate>true</AllowHardTerminate>\n");
316 out.push_str(" <StartWhenAvailable>true</StartWhenAvailable>\n");
317 out.push_str(" <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
318 out.push_str(" <IdleSettings>\n");
319 out.push_str(" <StopOnIdleEnd>false</StopOnIdleEnd>\n");
320 out.push_str(" <RestartOnIdle>false</RestartOnIdle>\n");
321 out.push_str(" </IdleSettings>\n");
322 out.push_str(" <AllowStartOnDemand>true</AllowStartOnDemand>\n");
323 out.push_str(" <Enabled>true</Enabled>\n");
324 out.push_str(" <Hidden>false</Hidden>\n");
325 out.push_str(" <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
326 out.push_str(" <WakeToRun>false</WakeToRun>\n");
327 out.push_str(" <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
330 out.push_str(" <Priority>7</Priority>\n");
331 out.push_str(" <RestartOnFailure>\n");
332 out.push_str(" <Interval>PT1M</Interval>\n");
333 out.push_str(" <Count>5</Count>\n");
334 out.push_str(" </RestartOnFailure>\n");
335 out.push_str(" </Settings>\n");
336
337 out.push_str(" <Actions Context=\"Author\">\n <Exec>\n");
338 out.push_str(&format!(
339 " <Command>{}</Command>\n",
340 xml_escape(&self.command().to_string_lossy())
341 ));
342 out.push_str(&format!(
343 " <Arguments>{}</Arguments>\n",
344 xml_escape(&self.rendered_arguments())
345 ));
346 out.push_str(" </Exec>\n </Actions>\n");
347 out.push_str("</Task>\n");
348 out
349 }
350}
351
352#[derive(Debug, Clone, PartialEq, Eq)]
358pub struct RegisteredTask {
359 name: String,
360 command: String,
361 arguments: String,
362 account: Option<String>,
363 description: String,
364 enabled: bool,
365 running: bool,
366}
367
368impl RegisteredTask {
369 #[must_use]
372 pub fn from_document(name: &str, document: &str, running: bool) -> Self {
373 Self {
374 name: name.to_string(),
375 command: xml_value(document, "Command").unwrap_or_default(),
376 arguments: xml_value(document, "Arguments").unwrap_or_default(),
377 account: xml_value(document, "UserId"),
378 description: xml_value(document, "Description").unwrap_or_default(),
379 enabled: task_is_enabled(document),
380 running,
381 }
382 }
383
384 #[must_use]
386 pub fn name(&self) -> &str {
387 &self.name
388 }
389
390 #[must_use]
392 pub fn command(&self) -> &str {
393 &self.command
394 }
395
396 #[must_use]
398 pub fn arguments(&self) -> &str {
399 &self.arguments
400 }
401
402 #[must_use]
404 pub fn account(&self) -> Option<&str> {
405 self.account.as_deref()
406 }
407
408 #[must_use]
410 pub fn description(&self) -> &str {
411 &self.description
412 }
413
414 #[must_use]
416 pub fn enabled(&self) -> bool {
417 self.enabled
418 }
419
420 #[must_use]
431 pub fn running(&self) -> bool {
432 self.running
433 }
434
435 #[must_use]
441 pub fn is_product_owned(&self) -> bool {
442 self.description.contains(PRODUCT_MARKER)
443 }
444}
445
446fn task_is_enabled(document: &str) -> bool {
458 let settings = document
459 .find("<Settings>")
460 .map_or(document, |start| &document[start..]);
461 xml_value(settings, "Enabled").as_deref() != Some("false")
462}
463
464#[derive(Debug)]
475pub struct LifecycleTaskControl<'runner> {
476 runner: &'runner dyn CommandRunner,
477 schtasks: PathBuf,
478}
479
480#[derive(Debug, Clone, PartialEq, Eq)]
482pub struct Detached {
483 pub removed: bool,
485 pub name: String,
487}
488
489impl<'runner> LifecycleTaskControl<'runner> {
490 #[must_use]
492 pub fn new(runner: &'runner dyn CommandRunner) -> Self {
493 Self {
494 runner,
495 schtasks: locate_in_system32("schtasks.exe"),
496 }
497 }
498
499 #[must_use]
501 pub fn with_executable(
502 runner: &'runner dyn CommandRunner,
503 schtasks: impl Into<PathBuf>,
504 ) -> Self {
505 Self {
506 runner,
507 schtasks: schtasks.into(),
508 }
509 }
510
511 pub fn query(
517 &self,
518 identity: &LifecycleTaskIdentity,
519 ) -> Result<Option<RegisteredTask>, WslError> {
520 let output = self.schtasks(&["/Query", "/TN", identity.name(), "/XML", "ONE"])?;
521 if !output.success() {
522 return Ok(None);
530 }
531 let document = decode_console_output(output.stdout()).into_text();
532 Ok(Some(RegisteredTask::from_document(
533 identity.name(),
534 &document,
535 self.is_running(identity),
536 )))
537 }
538
539 pub fn register(&self, task: &LifecycleTask) -> Result<(), WslError> {
554 let identity = task.identity();
555 match self.query(identity)? {
556 Some(existing) if !existing.is_product_owned() => {
557 return Err(WslError::ForeignTask {
558 name: identity.name().to_string(),
559 detail: format!(
560 "a task of this name already exists, its description does not identify \
561 it as this product's ({PRODUCT_MARKER}), and it starts `{}`. Rename or \
562 remove it yourself if it is the hand-created keep-alive this feature \
563 replaces.",
564 existing.command()
565 ),
566 });
567 }
568 Some(_) => {}
569 None if self.exists(identity) => {
576 return Err(WslError::ForeignTask {
577 name: identity.name().to_string(),
578 detail: format!(
579 "a task of this name exists but Task Scheduler would not export its \
580 definition, so it cannot be shown to be this product's \
581 ({PRODUCT_MARKER}) and registering would replace it. Inspect it in \
582 `taskschd.msc`, and rename or remove it yourself if it is the \
583 hand-created keep-alive this feature replaces."
584 ),
585 });
586 }
587 None => {}
588 }
589
590 let directory = tempfile::tempdir().map_err(|error| WslError::Record {
591 operation: "write",
592 path: PathBuf::from("<the scheduled-task document>"),
593 detail: error.to_string(),
594 })?;
595 let document = directory.path().join("task.xml");
596 write_utf16(&document, &task.xml()).map_err(|error| WslError::Record {
597 operation: "write",
598 path: document.clone(),
599 detail: error.to_string(),
600 })?;
601
602 let output = self.schtasks(&[
603 "/Create",
604 "/TN",
605 identity.name(),
606 "/XML",
607 &document.to_string_lossy(),
608 "/F",
609 ])?;
610 if !output.success() {
611 return Err(self.task_error("register", identity.name(), &output.diagnostic()));
612 }
613 Ok(())
614 }
615
616 pub fn detach(&self, identity: &LifecycleTaskIdentity) -> Result<Detached, WslError> {
628 let Some(existing) = self.query(identity)? else {
629 return Ok(Detached {
630 removed: false,
631 name: identity.name().to_string(),
632 });
633 };
634 if !existing.is_product_owned() {
635 return Err(WslError::ForeignTask {
636 name: identity.name().to_string(),
637 detail: format!(
638 "a task of this name exists but its description does not identify it as \
639 this product's ({PRODUCT_MARKER}), so `detach` will not remove it."
640 ),
641 });
642 }
643 let output = self.schtasks(&["/Delete", "/TN", identity.name(), "/F"])?;
644 if !output.success() {
645 return Err(self.task_error("remove", identity.name(), &output.diagnostic()));
646 }
647 Ok(Detached {
648 removed: true,
649 name: identity.name().to_string(),
650 })
651 }
652
653 pub fn start(&self, identity: &LifecycleTaskIdentity) -> Result<(), WslError> {
661 self.require_ours("start", identity)?;
662 let output = self.schtasks(&["/Run", "/TN", identity.name()])?;
663 if !output.success() {
664 return Err(self.task_error("start", identity.name(), &output.diagnostic()));
665 }
666 Ok(())
667 }
668
669 pub fn stop(&self, identity: &LifecycleTaskIdentity) -> Result<bool, WslError> {
675 let existing = self.require_ours("stop", identity)?;
676 if !existing.running() {
677 return Ok(false);
678 }
679 let output = self.schtasks(&["/End", "/TN", identity.name()])?;
680 if !output.success() {
681 return Err(self.task_error("stop", identity.name(), &output.diagnostic()));
682 }
683 Ok(true)
684 }
685
686 fn require_ours(
687 &self,
688 operation: &'static str,
689 identity: &LifecycleTaskIdentity,
690 ) -> Result<RegisteredTask, WslError> {
691 let Some(existing) = self.query(identity)? else {
692 return Err(WslError::NoSuchTask {
693 name: identity.name().to_string(),
694 });
695 };
696 if !existing.is_product_owned() {
697 return Err(WslError::ForeignTask {
698 name: identity.name().to_string(),
699 detail: format!(
700 "a task of this name exists but is not this product's ({PRODUCT_MARKER}), \
701 so it will not be used to {operation} anything."
702 ),
703 });
704 }
705 Ok(existing)
706 }
707
708 fn schtasks(&self, arguments: &[&str]) -> Result<super::exec::CommandOutput, WslError> {
709 let request =
710 CommandRequest::new(&self.schtasks).args(arguments.iter().map(OsString::from));
711 self.runner.run(&request)
712 }
713
714 fn query_csv(&self, identity: &LifecycleTaskIdentity) -> Option<super::exec::CommandOutput> {
726 let output = self
727 .schtasks(&["/Query", "/TN", identity.name(), "/FO", "CSV", "/NH"])
728 .ok()?;
729 output.success().then_some(output)
730 }
731
732 fn exists(&self, identity: &LifecycleTaskIdentity) -> bool {
737 self.query_csv(identity).is_some()
738 }
739
740 fn is_running(&self, identity: &LifecycleTaskIdentity) -> bool {
743 let Some(output) = self.query_csv(identity) else {
744 return false;
745 };
746 decode_console_output(output.stdout())
747 .into_text()
748 .lines()
749 .filter_map(|line| line.rsplit(',').next())
750 .any(|status| {
751 status
752 .trim()
753 .trim_matches('"')
754 .eq_ignore_ascii_case("running")
755 })
756 }
757
758 fn task_error(&self, operation: &'static str, name: &str, detail: &str) -> WslError {
759 if detail.to_ascii_lowercase().contains("access is denied") {
760 return WslError::NeedsElevation {
761 operation,
762 name: name.to_string(),
763 detail: detail.to_string(),
764 };
765 }
766 WslError::TaskControl {
767 operation,
768 name: name.to_string(),
769 detail: detail.to_string(),
770 }
771 }
772}
773
774fn write_utf16(path: &Path, text: &str) -> std::io::Result<()> {
780 let mut bytes = vec![0xFF, 0xFE];
781 for unit in text.encode_utf16() {
782 bytes.extend_from_slice(&unit.to_le_bytes());
783 }
784 std::fs::write(path, bytes)
785}
786
787#[cfg(test)]
788mod tests {
789 use super::*;
790 use crate::wsl::discovery::{DIGEST_SUFFIX_LENGTH, ESCAPED_NAME_BUDGET};
791 use crate::wsl::exec::{CommandOutput, ScriptedRunner};
792
793 fn identity(distribution: &str) -> LifecycleTaskIdentity {
794 LifecycleTaskIdentity::for_distribution(distribution).expect("a usable name")
795 }
796
797 fn task(distribution: &str) -> LifecycleTask {
798 LifecycleTask::new(
799 identity(distribution),
800 TaskPrincipal::named("IVANPC\\IvanD"),
801 &WslExecutable::at("C:\\Windows\\System32\\wsl.exe"),
802 "/usr/local/bin/runner-manager",
803 )
804 }
805
806 fn registered_document(distribution: &str) -> CommandOutput {
807 CommandOutput::exited(0, task(distribution).xml(), "")
808 }
809
810 #[test]
813 fn the_task_name_is_stable_for_a_distribution() {
814 assert_eq!(identity("Ubuntu").name(), identity("Ubuntu").name());
815 assert!(
816 identity("Ubuntu")
817 .name()
818 .starts_with("runner-manager-wsl-Ubuntu-")
819 );
820 }
821
822 #[test]
823 fn a_name_task_scheduler_could_not_hold_is_escaped_into_one_that_it_can() {
824 let name = identity("Debian GNU/Linux 12").name().to_string();
825 for forbidden in ['\\', '/', ':', '*', '?', '"', '<', '>', '|'] {
826 assert!(
827 !name.contains(forbidden),
828 "{name} still contains {forbidden:?}"
829 );
830 }
831 assert!(name.contains("Debian_GNU_Linux_12"), "{name}");
832 }
833
834 #[test]
835 fn two_distributions_that_escape_alike_still_get_different_tasks() {
836 let first = identity("Debian GNU/Linux");
840 let second = identity("Debian GNU:Linux");
841 assert_ne!(first.name(), second.name());
842 assert!(first.name().contains("Debian_GNU_Linux"));
843 assert!(second.name().contains("Debian_GNU_Linux"));
844 }
845
846 #[test]
847 fn a_very_long_name_is_bounded_and_still_unique() {
848 let long = "u".repeat(200);
849 let other = format!("{long}x");
850 let first = identity(&long);
851 let second = identity(&other);
852 assert_ne!(first.name(), second.name());
853 assert!(
854 first.name().len()
855 <= LIFECYCLE_TASK_PREFIX.len() + 1 + ESCAPED_NAME_BUDGET + 1 + DIGEST_SUFFIX_LENGTH,
856 "{}",
857 first.name()
858 );
859 }
860
861 #[test]
862 fn a_distribution_name_that_is_not_usable_never_becomes_a_task_name() {
863 assert!(LifecycleTaskIdentity::for_distribution("--shutdown").is_err());
864 assert!(LifecycleTaskIdentity::for_distribution("").is_err());
865 }
866
867 #[test]
870 fn the_action_is_the_documented_argument_vector() {
871 assert_eq!(
872 task("Ubuntu").action_arguments(),
873 vec![
874 "--distribution",
875 "Ubuntu",
876 "--user",
877 "root",
878 "--exec",
879 "/usr/local/bin/runner-manager",
880 "wsl-host",
881 "hold",
882 ]
883 );
884 }
885
886 #[test]
887 fn a_name_with_spaces_is_quoted_so_windows_splits_it_back_into_one_argument() {
888 let rendered = task("My Ubuntu").rendered_arguments();
889 assert!(
890 rendered.contains("--distribution \"My Ubuntu\" --user root"),
891 "{rendered}"
892 );
893 }
894
895 #[test]
896 fn no_shell_text_reaches_the_task_document() {
897 let document = task("Ubuntu & echo pwned").xml();
900 let arguments = xml_value(&document, "Arguments").expect("the document has an action");
901 for shell in ["cmd", "powershell", "/c", "&&", "||", ";", "$(", "`"] {
902 assert!(
903 !arguments.contains(shell),
904 "the rendered arguments contain shell text {shell:?}: {arguments}"
905 );
906 }
907 assert_eq!(
908 xml_value(&document, "Command").as_deref(),
909 Some("C:\\Windows\\System32\\wsl.exe")
910 );
911 assert!(document.contains("&"), "{document}");
914 assert!(arguments.contains("\"Ubuntu & echo pwned\""), "{arguments}");
915 }
916
917 #[test]
918 fn the_document_is_a_least_privilege_logon_task_for_the_named_principal() {
919 let document = task("Ubuntu").xml();
920 assert!(document.contains("<LogonTrigger>"), "{document}");
921 assert!(
922 document.contains("<RunLevel>LeastPrivilege</RunLevel>"),
923 "{document}"
924 );
925 assert!(
926 document.contains("<UserId>IVANPC\\IvanD</UserId>"),
927 "{document}"
928 );
929 assert!(document.contains("<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>"));
931 }
932
933 #[test]
934 fn the_document_carries_the_ownership_marker_and_names_the_distribution() {
935 let document = task("Ubuntu").xml();
936 let description = xml_value(&document, "Description").expect("a description");
937 assert!(description.contains(PRODUCT_MARKER), "{description}");
938 assert!(description.contains("Ubuntu"), "{description}");
939 assert!(description.contains("wsl detach"), "{description}");
940 }
941
942 #[test]
943 fn a_rendered_document_reads_back_as_this_products_task() {
944 let document = task("Ubuntu").xml();
945 let read = RegisteredTask::from_document("whatever", &document, false);
946 assert!(read.is_product_owned());
947 assert_eq!(read.account(), Some("IVANPC\\IvanD"));
948 assert!(read.enabled());
949 assert!(
950 read.arguments().contains("wsl-host hold"),
951 "{}",
952 read.arguments()
953 );
954 }
955
956 #[test]
957 fn a_supervised_task_runs_in_windows_and_carries_the_exact_guest_identity() {
958 let supervised = task("Ubuntu")
959 .with_recovery_root(PathBuf::from(r"C:\state\wsl-recovery\Ubuntu"))
960 .with_windows_supervisor(
961 PathBuf::from(r"C:\runner-manager-supervisor.exe"),
962 PathBuf::from(r"C:\runner-manager.exe"),
963 );
964 assert_eq!(
965 supervised.command(),
966 Path::new(r"C:\runner-manager-supervisor.exe")
967 );
968 assert_eq!(
969 supervised.action_arguments(),
970 vec![
971 r"C:\runner-manager.exe",
972 "wsl-host",
973 "supervise",
974 "--distribution",
975 "Ubuntu",
976 "--linux-binary",
977 "/usr/local/bin/runner-manager",
978 "--shared-root",
979 r"C:\state\wsl-recovery\Ubuntu",
980 ]
981 );
982 let document = supervised.xml();
983 assert!(
984 document.contains(r"C:\runner-manager-supervisor.exe"),
985 "{document}"
986 );
987 assert!(document.contains("wsl-host supervise"), "{document}");
988 assert!(!document.contains("wsl.exe</Command>"), "{document}");
989 }
990
991 #[test]
992 fn a_task_an_operator_disabled_is_reported_as_disabled() {
993 let disabled = task("Ubuntu").xml().replace(
1001 "\n <Enabled>true</Enabled>\n",
1002 "\n <Enabled>false</Enabled>\n",
1003 );
1004 assert!(
1005 disabled.contains(" <Enabled>true</Enabled>"),
1006 "the trigger's own <Enabled> must still be true for this to prove anything"
1007 );
1008 assert!(!RegisteredTask::from_document("whatever", &disabled, false).enabled());
1009 assert!(RegisteredTask::from_document("whatever", &task("Ubuntu").xml(), false).enabled());
1010 }
1011
1012 #[test]
1013 fn a_task_this_product_did_not_write_is_not_product_owned() {
1014 let hand_made = concat!(
1015 "<Task><RegistrationInfo><Description>GitHub Actions Linux Runner - Ubuntu WSL",
1016 "</Description></RegistrationInfo><Actions><Exec><Command>wsl.exe</Command>",
1017 "<Arguments>-d Ubuntu -u root /bin/sleep infinity</Arguments></Exec></Actions></Task>",
1018 );
1019 let read = RegisteredTask::from_document("whatever", hand_made, false);
1020 assert!(!read.is_product_owned());
1021 }
1022
1023 fn control(runner: &ScriptedRunner) -> LifecycleTaskControl<'_> {
1026 LifecycleTaskControl::with_executable(runner, "schtasks.exe")
1027 }
1028
1029 #[test]
1030 fn registering_writes_a_utf16_document_and_replaces_in_place() {
1031 let runner = ScriptedRunner::new().always("/Query", CommandOutput::exited(1, "", ""));
1032 let task = task("Ubuntu");
1033 control(&runner).register(&task).expect("registered");
1034
1035 let create = runner
1036 .recorded()
1037 .into_iter()
1038 .find(|request| request.arguments.first().map(String::as_str) == Some("/Create"))
1039 .expect("a /Create call");
1040 assert_eq!(create.arguments[1], "/TN");
1041 assert_eq!(create.arguments[2], task.identity().name());
1042 assert_eq!(create.arguments[3], "/XML");
1043 assert_eq!(
1044 create.arguments[5], "/F",
1045 "without /F a second `wsl install` fails instead of updating the task"
1046 );
1047 }
1048
1049 #[test]
1050 fn registering_over_this_products_own_task_is_allowed_and_idempotent() {
1051 let runner = ScriptedRunner::new()
1052 .always("/Query", registered_document("Ubuntu"))
1053 .always("/Create", CommandOutput::exited(0, "SUCCESS", ""));
1054 control(&runner)
1055 .register(&task("Ubuntu"))
1056 .expect("replaced");
1057 control(&runner)
1058 .register(&task("Ubuntu"))
1059 .expect("replaced again");
1060 }
1061
1062 #[test]
1063 fn registering_over_a_task_that_cannot_be_exported_refuses_and_changes_nothing() {
1064 let runner = ScriptedRunner::new()
1069 .always(
1070 "/XML",
1071 CommandOutput::exited(1, "", "the task image is corrupt"),
1072 )
1073 .always(
1074 "/FO",
1075 CommandOutput::exited(0, "\"whatever\",\"N/A\",\"Ready\"", ""),
1076 );
1077 let error = control(&runner)
1078 .register(&task("Ubuntu"))
1079 .expect_err("an unexportable task is not a free name");
1080 assert!(matches!(error, WslError::ForeignTask { .. }), "{error:?}");
1081 assert!(
1082 runner
1083 .command_lines()
1084 .iter()
1085 .all(|line| !line.contains("/Create")),
1086 "nothing may be written: {:?}",
1087 runner.command_lines()
1088 );
1089 }
1090
1091 #[test]
1092 fn registering_over_a_foreign_task_refuses_and_changes_nothing() {
1093 let hand_made = CommandOutput::exited(
1094 0,
1095 concat!(
1096 "<Task><RegistrationInfo><Description>GitHub Actions Linux Runner - Ubuntu WSL",
1097 "</Description></RegistrationInfo><Actions><Exec><Command>wsl.exe</Command>",
1098 "</Exec></Actions></Task>",
1099 ),
1100 "",
1101 );
1102 let runner = ScriptedRunner::new().always("/Query", hand_made);
1103 let error = control(&runner)
1104 .register(&task("Ubuntu"))
1105 .expect_err("not ours");
1106 assert!(matches!(error, WslError::ForeignTask { .. }), "{error:?}");
1107 assert!(
1108 runner
1109 .command_lines()
1110 .iter()
1111 .all(|line| !line.contains("/Create")),
1112 "nothing may be written: {:?}",
1113 runner.command_lines()
1114 );
1115 }
1116
1117 #[test]
1118 fn detach_removes_only_the_product_task_and_runs_nothing_else() {
1119 let runner = ScriptedRunner::new()
1120 .always("/Query", registered_document("Ubuntu"))
1121 .always("/Delete", CommandOutput::exited(0, "SUCCESS", ""));
1122 let detached = control(&runner)
1123 .detach(&identity("Ubuntu"))
1124 .expect("detached");
1125 assert!(detached.removed);
1126
1127 for request in runner.recorded() {
1128 assert_eq!(
1129 request.program.to_string_lossy(),
1130 "schtasks.exe",
1131 "detach must not run anything but Task Scheduler: {request:?}"
1132 );
1133 }
1134 let lines = runner.command_lines();
1135 assert!(
1136 lines.iter().all(|line| !line.contains("wsl.exe")),
1137 "detach must not reach into the distribution: {lines:?}"
1138 );
1139 assert!(
1140 lines
1141 .iter()
1142 .all(|line| !line.contains("--unregister") && !line.contains("systemctl")),
1143 "detach must not unregister WSL or touch the Linux service: {lines:?}"
1144 );
1145 }
1146
1147 #[test]
1148 fn detach_without_a_task_is_not_an_error() {
1149 let runner = ScriptedRunner::new().always("/Query", CommandOutput::exited(1, "", ""));
1150 let detached = control(&runner)
1151 .detach(&identity("Ubuntu"))
1152 .expect("nothing to remove");
1153 assert!(!detached.removed);
1154 assert!(
1155 runner
1156 .command_lines()
1157 .iter()
1158 .all(|line| !line.contains("/Delete"))
1159 );
1160 }
1161
1162 #[test]
1163 fn detach_refuses_a_foreign_task_rather_than_deleting_it() {
1164 let runner = ScriptedRunner::new().always(
1165 "/Query",
1166 CommandOutput::exited(
1167 0,
1168 "<Task><RegistrationInfo><Description>Somebody else's task</Description>\
1169 </RegistrationInfo></Task>",
1170 "",
1171 ),
1172 );
1173 let error = control(&runner)
1174 .detach(&identity("Ubuntu"))
1175 .expect_err("not ours");
1176 assert!(matches!(error, WslError::ForeignTask { .. }), "{error:?}");
1177 assert!(
1178 runner
1179 .command_lines()
1180 .iter()
1181 .all(|line| !line.contains("/Delete")),
1182 "a task this product does not own must not be deleted"
1183 );
1184 }
1185
1186 #[test]
1187 fn access_denied_is_reported_as_needing_elevation_rather_than_as_a_generic_failure() {
1188 let runner = ScriptedRunner::new()
1189 .always("/Query", CommandOutput::exited(1, "", ""))
1190 .always(
1191 "/Create",
1192 CommandOutput::exited(1, "", "ERROR: Access is denied.\n"),
1193 );
1194 let error = control(&runner)
1195 .register(&task("Ubuntu"))
1196 .expect_err("denied");
1197 assert!(
1198 matches!(error, WslError::NeedsElevation { .. }),
1199 "{error:?}"
1200 );
1201 }
1202
1203 #[test]
1204 fn starting_a_task_that_is_not_registered_says_so() {
1205 let runner = ScriptedRunner::new().always("/Query", CommandOutput::exited(1, "", ""));
1206 let error = control(&runner)
1207 .start(&identity("Ubuntu"))
1208 .expect_err("not registered");
1209 assert!(matches!(error, WslError::NoSuchTask { .. }), "{error:?}");
1210 }
1211
1212 #[test]
1213 fn a_query_reads_a_utf16_document_as_schtasks_really_writes_it() {
1214 let mut bytes = vec![0xFF, 0xFE];
1215 for unit in task("Ubuntu").xml().encode_utf16() {
1216 bytes.extend_from_slice(&unit.to_le_bytes());
1217 }
1218 let runner = ScriptedRunner::new()
1219 .always("/XML ONE", CommandOutput::exited(0, bytes, ""))
1220 .always(
1221 "/FO CSV",
1222 CommandOutput::exited(0, "\"task\",\"N/A\",\"Ready\"\n", ""),
1223 );
1224 let found = control(&runner)
1225 .query(&identity("Ubuntu"))
1226 .expect("queried")
1227 .expect("registered");
1228 assert!(found.is_product_owned());
1229 assert!(!found.running());
1230 assert!(found.arguments().contains("wsl-host hold"));
1231 }
1232
1233 #[test]
1234 fn a_running_task_is_reported_from_the_csv_status_column() {
1235 let runner = ScriptedRunner::new()
1236 .always("/XML ONE", registered_document("Ubuntu"))
1237 .always(
1238 "/FO CSV",
1239 CommandOutput::exited(0, "\"\\task\",\"N/A\",\"Running\"\n", ""),
1240 );
1241 let found = control(&runner)
1242 .query(&identity("Ubuntu"))
1243 .expect("queried")
1244 .expect("registered");
1245 assert!(found.running());
1246 }
1247
1248 #[test]
1251 fn the_document_is_written_as_utf16_little_endian_with_a_byte_order_mark() {
1252 let directory = tempfile::tempdir().expect("a temporary directory");
1253 let path = directory.path().join("task.xml");
1254 write_utf16(&path, &task("Ubuntu").xml()).expect("written");
1255 let bytes = std::fs::read(&path).expect("readable");
1256 assert_eq!(&bytes[..2], &[0xFF, 0xFE]);
1257 let decoded = decode_console_output(&bytes);
1258 assert_eq!(decoded.text(), task("Ubuntu").xml());
1259 }
1260
1261 #[test]
1262 fn no_credential_shaped_value_can_reach_the_document() {
1263 let document = task("Ubuntu").xml().to_ascii_lowercase();
1268 for shape in [
1269 "ghu_",
1270 "ghs_",
1271 "gho_",
1272 "github_pat_",
1273 "access_token",
1274 "refresh_token",
1275 "jitconfig",
1276 "secret",
1277 "password",
1278 "credential",
1279 ] {
1280 assert!(
1281 !document.contains(shape),
1282 "the task document mentions {shape:?}: {document}"
1283 );
1284 }
1285 assert!(document.contains("interactivetoken"));
1291 }
1292}