1use std::ffi::OsString;
54use std::path::{Path, PathBuf};
55
56use super::WslError;
57use super::discovery::{
58 decode_console_output, escaped_name_with_digest, validate_distribution_name,
59};
60use super::exec::{CommandRequest, CommandRunner};
61use super::probe::{LINUX_USER, WslExecutable, locate_in_system32};
62use crate::service::{TaskPrincipal, quote_argument, xml_escape, xml_value};
63
64pub const LIFECYCLE_TASK_PREFIX: &str = "runner-manager-wsl";
66
67pub const PRODUCT_MARKER: &str = "runner-manager-wsl-lifecycle/v1";
73
74pub const HOLD_ARGUMENTS: [&str; 2] = ["wsl-host", "hold"];
80
81#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct LifecycleTaskIdentity {
88 distribution: String,
89 name: String,
90}
91
92impl LifecycleTaskIdentity {
93 pub fn for_distribution(distribution: &str) -> Result<Self, WslError> {
106 validate_distribution_name(distribution)?;
107 Ok(Self {
108 distribution: distribution.to_string(),
109 name: format!(
110 "{LIFECYCLE_TASK_PREFIX}-{}",
111 escaped_name_with_digest(distribution)
112 ),
113 })
114 }
115
116 #[must_use]
118 pub fn distribution(&self) -> &str {
119 &self.distribution
120 }
121
122 #[must_use]
124 pub fn name(&self) -> &str {
125 &self.name
126 }
127
128 #[must_use]
130 pub fn description(&self) -> String {
131 format!(
132 "Keeps the WSL distribution \"{}\" running so its runner-manager service can \
133 accept jobs after this account logs on. Created and owned by runner-manager \
134 ({PRODUCT_MARKER}); remove it with `runner-manager wsl detach --distribution \
135 {}`.",
136 self.distribution, self.distribution
137 )
138 }
139}
140
141#[derive(Debug, Clone)]
147pub struct LifecycleTask {
148 identity: LifecycleTaskIdentity,
149 principal: TaskPrincipal,
150 wsl_executable: PathBuf,
151 linux_binary: String,
152}
153
154impl LifecycleTask {
155 #[must_use]
157 pub fn new(
158 identity: LifecycleTaskIdentity,
159 principal: TaskPrincipal,
160 wsl_executable: &WslExecutable,
161 linux_binary: impl Into<String>,
162 ) -> Self {
163 Self {
164 identity,
165 principal,
166 wsl_executable: wsl_executable.path().to_path_buf(),
167 linux_binary: linux_binary.into(),
168 }
169 }
170
171 #[must_use]
173 pub fn identity(&self) -> &LifecycleTaskIdentity {
174 &self.identity
175 }
176
177 #[must_use]
179 pub fn principal(&self) -> &TaskPrincipal {
180 &self.principal
181 }
182
183 #[must_use]
185 pub fn command(&self) -> &Path {
186 &self.wsl_executable
187 }
188
189 #[must_use]
196 pub fn action_arguments(&self) -> Vec<String> {
197 let mut argv = vec![
198 "--distribution".to_string(),
199 self.identity.distribution.clone(),
200 "--user".to_string(),
201 LINUX_USER.to_string(),
202 "--exec".to_string(),
203 self.linux_binary.clone(),
204 ];
205 argv.extend(
206 HOLD_ARGUMENTS
207 .iter()
208 .map(|argument| (*argument).to_string()),
209 );
210 argv
211 }
212
213 #[must_use]
215 pub fn rendered_arguments(&self) -> String {
216 self.action_arguments()
217 .iter()
218 .map(|argument| quote_argument(argument))
219 .collect::<Vec<_>>()
220 .join(" ")
221 }
222
223 #[must_use]
225 pub fn xml(&self) -> String {
226 let user = xml_escape(self.principal.user_id());
227 let mut out = String::new();
228 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
229 out.push_str(
230 "<Task version=\"1.4\" \
231 xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
232 );
233 out.push_str(" <RegistrationInfo>\n");
234 out.push_str(&format!(
235 " <Description>{}</Description>\n",
236 xml_escape(&self.identity.description())
237 ));
238 out.push_str(&format!(
239 " <URI>\\{}</URI>\n",
240 xml_escape(self.identity.name())
241 ));
242 out.push_str(" </RegistrationInfo>\n");
243
244 out.push_str(" <Triggers>\n <LogonTrigger>\n");
245 out.push_str(" <Enabled>true</Enabled>\n");
246 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
247 out.push_str(" </LogonTrigger>\n </Triggers>\n");
248
249 out.push_str(" <Principals>\n <Principal id=\"Author\">\n");
254 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
255 out.push_str(" <LogonType>InteractiveToken</LogonType>\n");
256 out.push_str(" <RunLevel>LeastPrivilege</RunLevel>\n");
257 out.push_str(" </Principal>\n </Principals>\n");
258
259 out.push_str(" <Settings>\n");
260 out.push_str(" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
263 out.push_str(" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
264 out.push_str(" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
265 out.push_str(" <AllowHardTerminate>true</AllowHardTerminate>\n");
266 out.push_str(" <StartWhenAvailable>true</StartWhenAvailable>\n");
267 out.push_str(" <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
268 out.push_str(" <IdleSettings>\n");
269 out.push_str(" <StopOnIdleEnd>false</StopOnIdleEnd>\n");
270 out.push_str(" <RestartOnIdle>false</RestartOnIdle>\n");
271 out.push_str(" </IdleSettings>\n");
272 out.push_str(" <AllowStartOnDemand>true</AllowStartOnDemand>\n");
273 out.push_str(" <Enabled>true</Enabled>\n");
274 out.push_str(" <Hidden>false</Hidden>\n");
275 out.push_str(" <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
276 out.push_str(" <WakeToRun>false</WakeToRun>\n");
277 out.push_str(" <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
280 out.push_str(" <Priority>7</Priority>\n");
281 out.push_str(" <RestartOnFailure>\n");
282 out.push_str(" <Interval>PT1M</Interval>\n");
283 out.push_str(" <Count>5</Count>\n");
284 out.push_str(" </RestartOnFailure>\n");
285 out.push_str(" </Settings>\n");
286
287 out.push_str(" <Actions Context=\"Author\">\n <Exec>\n");
288 out.push_str(&format!(
289 " <Command>{}</Command>\n",
290 xml_escape(&self.wsl_executable.to_string_lossy())
291 ));
292 out.push_str(&format!(
293 " <Arguments>{}</Arguments>\n",
294 xml_escape(&self.rendered_arguments())
295 ));
296 out.push_str(" </Exec>\n </Actions>\n");
297 out.push_str("</Task>\n");
298 out
299 }
300}
301
302#[derive(Debug, Clone, PartialEq, Eq)]
308pub struct RegisteredTask {
309 name: String,
310 command: String,
311 arguments: String,
312 account: Option<String>,
313 description: String,
314 enabled: bool,
315 running: bool,
316}
317
318impl RegisteredTask {
319 #[must_use]
322 pub fn from_document(name: &str, document: &str, running: bool) -> Self {
323 Self {
324 name: name.to_string(),
325 command: xml_value(document, "Command").unwrap_or_default(),
326 arguments: xml_value(document, "Arguments").unwrap_or_default(),
327 account: xml_value(document, "UserId"),
328 description: xml_value(document, "Description").unwrap_or_default(),
329 enabled: task_is_enabled(document),
330 running,
331 }
332 }
333
334 #[must_use]
336 pub fn name(&self) -> &str {
337 &self.name
338 }
339
340 #[must_use]
342 pub fn command(&self) -> &str {
343 &self.command
344 }
345
346 #[must_use]
348 pub fn arguments(&self) -> &str {
349 &self.arguments
350 }
351
352 #[must_use]
354 pub fn account(&self) -> Option<&str> {
355 self.account.as_deref()
356 }
357
358 #[must_use]
360 pub fn description(&self) -> &str {
361 &self.description
362 }
363
364 #[must_use]
366 pub fn enabled(&self) -> bool {
367 self.enabled
368 }
369
370 #[must_use]
381 pub fn running(&self) -> bool {
382 self.running
383 }
384
385 #[must_use]
391 pub fn is_product_owned(&self) -> bool {
392 self.description.contains(PRODUCT_MARKER)
393 }
394}
395
396fn task_is_enabled(document: &str) -> bool {
408 let settings = document
409 .find("<Settings>")
410 .map_or(document, |start| &document[start..]);
411 xml_value(settings, "Enabled").as_deref() != Some("false")
412}
413
414#[derive(Debug)]
425pub struct LifecycleTaskControl<'runner> {
426 runner: &'runner dyn CommandRunner,
427 schtasks: PathBuf,
428}
429
430#[derive(Debug, Clone, PartialEq, Eq)]
432pub struct Detached {
433 pub removed: bool,
435 pub name: String,
437}
438
439impl<'runner> LifecycleTaskControl<'runner> {
440 #[must_use]
442 pub fn new(runner: &'runner dyn CommandRunner) -> Self {
443 Self {
444 runner,
445 schtasks: locate_in_system32("schtasks.exe"),
446 }
447 }
448
449 #[must_use]
451 pub fn with_executable(
452 runner: &'runner dyn CommandRunner,
453 schtasks: impl Into<PathBuf>,
454 ) -> Self {
455 Self {
456 runner,
457 schtasks: schtasks.into(),
458 }
459 }
460
461 pub fn query(
467 &self,
468 identity: &LifecycleTaskIdentity,
469 ) -> Result<Option<RegisteredTask>, WslError> {
470 let output = self.schtasks(&["/Query", "/TN", identity.name(), "/XML", "ONE"])?;
471 if !output.success() {
472 return Ok(None);
480 }
481 let document = decode_console_output(output.stdout()).into_text();
482 Ok(Some(RegisteredTask::from_document(
483 identity.name(),
484 &document,
485 self.is_running(identity),
486 )))
487 }
488
489 pub fn register(&self, task: &LifecycleTask) -> Result<(), WslError> {
504 let identity = task.identity();
505 match self.query(identity)? {
506 Some(existing) if !existing.is_product_owned() => {
507 return Err(WslError::ForeignTask {
508 name: identity.name().to_string(),
509 detail: format!(
510 "a task of this name already exists, its description does not identify \
511 it as this product's ({PRODUCT_MARKER}), and it starts `{}`. Rename or \
512 remove it yourself if it is the hand-created keep-alive this feature \
513 replaces.",
514 existing.command()
515 ),
516 });
517 }
518 Some(_) => {}
519 None if self.exists(identity) => {
526 return Err(WslError::ForeignTask {
527 name: identity.name().to_string(),
528 detail: format!(
529 "a task of this name exists but Task Scheduler would not export its \
530 definition, so it cannot be shown to be this product's \
531 ({PRODUCT_MARKER}) and registering would replace it. Inspect it in \
532 `taskschd.msc`, and rename or remove it yourself if it is the \
533 hand-created keep-alive this feature replaces."
534 ),
535 });
536 }
537 None => {}
538 }
539
540 let directory = tempfile::tempdir().map_err(|error| WslError::Record {
541 operation: "write",
542 path: PathBuf::from("<the scheduled-task document>"),
543 detail: error.to_string(),
544 })?;
545 let document = directory.path().join("task.xml");
546 write_utf16(&document, &task.xml()).map_err(|error| WslError::Record {
547 operation: "write",
548 path: document.clone(),
549 detail: error.to_string(),
550 })?;
551
552 let output = self.schtasks(&[
553 "/Create",
554 "/TN",
555 identity.name(),
556 "/XML",
557 &document.to_string_lossy(),
558 "/F",
559 ])?;
560 if !output.success() {
561 return Err(self.task_error("register", identity.name(), &output.diagnostic()));
562 }
563 Ok(())
564 }
565
566 pub fn detach(&self, identity: &LifecycleTaskIdentity) -> Result<Detached, WslError> {
578 let Some(existing) = self.query(identity)? else {
579 return Ok(Detached {
580 removed: false,
581 name: identity.name().to_string(),
582 });
583 };
584 if !existing.is_product_owned() {
585 return Err(WslError::ForeignTask {
586 name: identity.name().to_string(),
587 detail: format!(
588 "a task of this name exists but its description does not identify it as \
589 this product's ({PRODUCT_MARKER}), so `detach` will not remove it."
590 ),
591 });
592 }
593 let output = self.schtasks(&["/Delete", "/TN", identity.name(), "/F"])?;
594 if !output.success() {
595 return Err(self.task_error("remove", identity.name(), &output.diagnostic()));
596 }
597 Ok(Detached {
598 removed: true,
599 name: identity.name().to_string(),
600 })
601 }
602
603 pub fn start(&self, identity: &LifecycleTaskIdentity) -> Result<(), WslError> {
611 self.require_ours("start", identity)?;
612 let output = self.schtasks(&["/Run", "/TN", identity.name()])?;
613 if !output.success() {
614 return Err(self.task_error("start", identity.name(), &output.diagnostic()));
615 }
616 Ok(())
617 }
618
619 pub fn stop(&self, identity: &LifecycleTaskIdentity) -> Result<bool, WslError> {
625 let existing = self.require_ours("stop", identity)?;
626 if !existing.running() {
627 return Ok(false);
628 }
629 let output = self.schtasks(&["/End", "/TN", identity.name()])?;
630 if !output.success() {
631 return Err(self.task_error("stop", identity.name(), &output.diagnostic()));
632 }
633 Ok(true)
634 }
635
636 fn require_ours(
637 &self,
638 operation: &'static str,
639 identity: &LifecycleTaskIdentity,
640 ) -> Result<RegisteredTask, WslError> {
641 let Some(existing) = self.query(identity)? else {
642 return Err(WslError::NoSuchTask {
643 name: identity.name().to_string(),
644 });
645 };
646 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 exists but is not this product's ({PRODUCT_MARKER}), \
651 so it will not be used to {operation} anything."
652 ),
653 });
654 }
655 Ok(existing)
656 }
657
658 fn schtasks(&self, arguments: &[&str]) -> Result<super::exec::CommandOutput, WslError> {
659 let request =
660 CommandRequest::new(&self.schtasks).args(arguments.iter().map(OsString::from));
661 self.runner.run(&request)
662 }
663
664 fn query_csv(&self, identity: &LifecycleTaskIdentity) -> Option<super::exec::CommandOutput> {
676 let output = self
677 .schtasks(&["/Query", "/TN", identity.name(), "/FO", "CSV", "/NH"])
678 .ok()?;
679 output.success().then_some(output)
680 }
681
682 fn exists(&self, identity: &LifecycleTaskIdentity) -> bool {
687 self.query_csv(identity).is_some()
688 }
689
690 fn is_running(&self, identity: &LifecycleTaskIdentity) -> bool {
693 let Some(output) = self.query_csv(identity) else {
694 return false;
695 };
696 decode_console_output(output.stdout())
697 .into_text()
698 .lines()
699 .filter_map(|line| line.rsplit(',').next())
700 .any(|status| {
701 status
702 .trim()
703 .trim_matches('"')
704 .eq_ignore_ascii_case("running")
705 })
706 }
707
708 fn task_error(&self, operation: &'static str, name: &str, detail: &str) -> WslError {
709 if detail.to_ascii_lowercase().contains("access is denied") {
710 return WslError::NeedsElevation {
711 operation,
712 name: name.to_string(),
713 detail: detail.to_string(),
714 };
715 }
716 WslError::TaskControl {
717 operation,
718 name: name.to_string(),
719 detail: detail.to_string(),
720 }
721 }
722}
723
724fn write_utf16(path: &Path, text: &str) -> std::io::Result<()> {
730 let mut bytes = vec![0xFF, 0xFE];
731 for unit in text.encode_utf16() {
732 bytes.extend_from_slice(&unit.to_le_bytes());
733 }
734 std::fs::write(path, bytes)
735}
736
737#[cfg(test)]
738mod tests {
739 use super::*;
740 use crate::wsl::discovery::{DIGEST_SUFFIX_LENGTH, ESCAPED_NAME_BUDGET};
741 use crate::wsl::exec::{CommandOutput, ScriptedRunner};
742
743 fn identity(distribution: &str) -> LifecycleTaskIdentity {
744 LifecycleTaskIdentity::for_distribution(distribution).expect("a usable name")
745 }
746
747 fn task(distribution: &str) -> LifecycleTask {
748 LifecycleTask::new(
749 identity(distribution),
750 TaskPrincipal::named("IVANPC\\IvanD"),
751 &WslExecutable::at("C:\\Windows\\System32\\wsl.exe"),
752 "/usr/local/bin/runner-manager",
753 )
754 }
755
756 fn registered_document(distribution: &str) -> CommandOutput {
757 CommandOutput::exited(0, task(distribution).xml(), "")
758 }
759
760 #[test]
763 fn the_task_name_is_stable_for_a_distribution() {
764 assert_eq!(identity("Ubuntu").name(), identity("Ubuntu").name());
765 assert!(
766 identity("Ubuntu")
767 .name()
768 .starts_with("runner-manager-wsl-Ubuntu-")
769 );
770 }
771
772 #[test]
773 fn a_name_task_scheduler_could_not_hold_is_escaped_into_one_that_it_can() {
774 let name = identity("Debian GNU/Linux 12").name().to_string();
775 for forbidden in ['\\', '/', ':', '*', '?', '"', '<', '>', '|'] {
776 assert!(
777 !name.contains(forbidden),
778 "{name} still contains {forbidden:?}"
779 );
780 }
781 assert!(name.contains("Debian_GNU_Linux_12"), "{name}");
782 }
783
784 #[test]
785 fn two_distributions_that_escape_alike_still_get_different_tasks() {
786 let first = identity("Debian GNU/Linux");
790 let second = identity("Debian GNU:Linux");
791 assert_ne!(first.name(), second.name());
792 assert!(first.name().contains("Debian_GNU_Linux"));
793 assert!(second.name().contains("Debian_GNU_Linux"));
794 }
795
796 #[test]
797 fn a_very_long_name_is_bounded_and_still_unique() {
798 let long = "u".repeat(200);
799 let other = format!("{long}x");
800 let first = identity(&long);
801 let second = identity(&other);
802 assert_ne!(first.name(), second.name());
803 assert!(
804 first.name().len()
805 <= LIFECYCLE_TASK_PREFIX.len() + 1 + ESCAPED_NAME_BUDGET + 1 + DIGEST_SUFFIX_LENGTH,
806 "{}",
807 first.name()
808 );
809 }
810
811 #[test]
812 fn a_distribution_name_that_is_not_usable_never_becomes_a_task_name() {
813 assert!(LifecycleTaskIdentity::for_distribution("--shutdown").is_err());
814 assert!(LifecycleTaskIdentity::for_distribution("").is_err());
815 }
816
817 #[test]
820 fn the_action_is_the_documented_argument_vector() {
821 assert_eq!(
822 task("Ubuntu").action_arguments(),
823 vec![
824 "--distribution",
825 "Ubuntu",
826 "--user",
827 "root",
828 "--exec",
829 "/usr/local/bin/runner-manager",
830 "wsl-host",
831 "hold",
832 ]
833 );
834 }
835
836 #[test]
837 fn a_name_with_spaces_is_quoted_so_windows_splits_it_back_into_one_argument() {
838 let rendered = task("My Ubuntu").rendered_arguments();
839 assert!(
840 rendered.contains("--distribution \"My Ubuntu\" --user root"),
841 "{rendered}"
842 );
843 }
844
845 #[test]
846 fn no_shell_text_reaches_the_task_document() {
847 let document = task("Ubuntu & echo pwned").xml();
850 let arguments = xml_value(&document, "Arguments").expect("the document has an action");
851 for shell in ["cmd", "powershell", "/c", "&&", "||", ";", "$(", "`"] {
852 assert!(
853 !arguments.contains(shell),
854 "the rendered arguments contain shell text {shell:?}: {arguments}"
855 );
856 }
857 assert_eq!(
858 xml_value(&document, "Command").as_deref(),
859 Some("C:\\Windows\\System32\\wsl.exe")
860 );
861 assert!(document.contains("&"), "{document}");
864 assert!(arguments.contains("\"Ubuntu & echo pwned\""), "{arguments}");
865 }
866
867 #[test]
868 fn the_document_is_a_least_privilege_logon_task_for_the_named_principal() {
869 let document = task("Ubuntu").xml();
870 assert!(document.contains("<LogonTrigger>"), "{document}");
871 assert!(
872 document.contains("<RunLevel>LeastPrivilege</RunLevel>"),
873 "{document}"
874 );
875 assert!(
876 document.contains("<UserId>IVANPC\\IvanD</UserId>"),
877 "{document}"
878 );
879 assert!(document.contains("<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>"));
881 }
882
883 #[test]
884 fn the_document_carries_the_ownership_marker_and_names_the_distribution() {
885 let document = task("Ubuntu").xml();
886 let description = xml_value(&document, "Description").expect("a description");
887 assert!(description.contains(PRODUCT_MARKER), "{description}");
888 assert!(description.contains("Ubuntu"), "{description}");
889 assert!(description.contains("wsl detach"), "{description}");
890 }
891
892 #[test]
893 fn a_rendered_document_reads_back_as_this_products_task() {
894 let document = task("Ubuntu").xml();
895 let read = RegisteredTask::from_document("whatever", &document, false);
896 assert!(read.is_product_owned());
897 assert_eq!(read.account(), Some("IVANPC\\IvanD"));
898 assert!(read.enabled());
899 assert!(
900 read.arguments().contains("wsl-host hold"),
901 "{}",
902 read.arguments()
903 );
904 }
905
906 #[test]
907 fn a_task_an_operator_disabled_is_reported_as_disabled() {
908 let disabled = task("Ubuntu").xml().replace(
916 "\n <Enabled>true</Enabled>\n",
917 "\n <Enabled>false</Enabled>\n",
918 );
919 assert!(
920 disabled.contains(" <Enabled>true</Enabled>"),
921 "the trigger's own <Enabled> must still be true for this to prove anything"
922 );
923 assert!(!RegisteredTask::from_document("whatever", &disabled, false).enabled());
924 assert!(RegisteredTask::from_document("whatever", &task("Ubuntu").xml(), false).enabled());
925 }
926
927 #[test]
928 fn a_task_this_product_did_not_write_is_not_product_owned() {
929 let hand_made = concat!(
930 "<Task><RegistrationInfo><Description>GitHub Actions Linux Runner - Ubuntu WSL",
931 "</Description></RegistrationInfo><Actions><Exec><Command>wsl.exe</Command>",
932 "<Arguments>-d Ubuntu -u root /bin/sleep infinity</Arguments></Exec></Actions></Task>",
933 );
934 let read = RegisteredTask::from_document("whatever", hand_made, false);
935 assert!(!read.is_product_owned());
936 }
937
938 fn control(runner: &ScriptedRunner) -> LifecycleTaskControl<'_> {
941 LifecycleTaskControl::with_executable(runner, "schtasks.exe")
942 }
943
944 #[test]
945 fn registering_writes_a_utf16_document_and_replaces_in_place() {
946 let runner = ScriptedRunner::new().always("/Query", CommandOutput::exited(1, "", ""));
947 let task = task("Ubuntu");
948 control(&runner).register(&task).expect("registered");
949
950 let create = runner
951 .recorded()
952 .into_iter()
953 .find(|request| request.arguments.first().map(String::as_str) == Some("/Create"))
954 .expect("a /Create call");
955 assert_eq!(create.arguments[1], "/TN");
956 assert_eq!(create.arguments[2], task.identity().name());
957 assert_eq!(create.arguments[3], "/XML");
958 assert_eq!(
959 create.arguments[5], "/F",
960 "without /F a second `wsl install` fails instead of updating the task"
961 );
962 }
963
964 #[test]
965 fn registering_over_this_products_own_task_is_allowed_and_idempotent() {
966 let runner = ScriptedRunner::new()
967 .always("/Query", registered_document("Ubuntu"))
968 .always("/Create", CommandOutput::exited(0, "SUCCESS", ""));
969 control(&runner)
970 .register(&task("Ubuntu"))
971 .expect("replaced");
972 control(&runner)
973 .register(&task("Ubuntu"))
974 .expect("replaced again");
975 }
976
977 #[test]
978 fn registering_over_a_task_that_cannot_be_exported_refuses_and_changes_nothing() {
979 let runner = ScriptedRunner::new()
984 .always(
985 "/XML",
986 CommandOutput::exited(1, "", "the task image is corrupt"),
987 )
988 .always(
989 "/FO",
990 CommandOutput::exited(0, "\"whatever\",\"N/A\",\"Ready\"", ""),
991 );
992 let error = control(&runner)
993 .register(&task("Ubuntu"))
994 .expect_err("an unexportable task is not a free name");
995 assert!(matches!(error, WslError::ForeignTask { .. }), "{error:?}");
996 assert!(
997 runner
998 .command_lines()
999 .iter()
1000 .all(|line| !line.contains("/Create")),
1001 "nothing may be written: {:?}",
1002 runner.command_lines()
1003 );
1004 }
1005
1006 #[test]
1007 fn registering_over_a_foreign_task_refuses_and_changes_nothing() {
1008 let hand_made = CommandOutput::exited(
1009 0,
1010 concat!(
1011 "<Task><RegistrationInfo><Description>GitHub Actions Linux Runner - Ubuntu WSL",
1012 "</Description></RegistrationInfo><Actions><Exec><Command>wsl.exe</Command>",
1013 "</Exec></Actions></Task>",
1014 ),
1015 "",
1016 );
1017 let runner = ScriptedRunner::new().always("/Query", hand_made);
1018 let error = control(&runner)
1019 .register(&task("Ubuntu"))
1020 .expect_err("not ours");
1021 assert!(matches!(error, WslError::ForeignTask { .. }), "{error:?}");
1022 assert!(
1023 runner
1024 .command_lines()
1025 .iter()
1026 .all(|line| !line.contains("/Create")),
1027 "nothing may be written: {:?}",
1028 runner.command_lines()
1029 );
1030 }
1031
1032 #[test]
1033 fn detach_removes_only_the_product_task_and_runs_nothing_else() {
1034 let runner = ScriptedRunner::new()
1035 .always("/Query", registered_document("Ubuntu"))
1036 .always("/Delete", CommandOutput::exited(0, "SUCCESS", ""));
1037 let detached = control(&runner)
1038 .detach(&identity("Ubuntu"))
1039 .expect("detached");
1040 assert!(detached.removed);
1041
1042 for request in runner.recorded() {
1043 assert_eq!(
1044 request.program.to_string_lossy(),
1045 "schtasks.exe",
1046 "detach must not run anything but Task Scheduler: {request:?}"
1047 );
1048 }
1049 let lines = runner.command_lines();
1050 assert!(
1051 lines.iter().all(|line| !line.contains("wsl.exe")),
1052 "detach must not reach into the distribution: {lines:?}"
1053 );
1054 assert!(
1055 lines
1056 .iter()
1057 .all(|line| !line.contains("--unregister") && !line.contains("systemctl")),
1058 "detach must not unregister WSL or touch the Linux service: {lines:?}"
1059 );
1060 }
1061
1062 #[test]
1063 fn detach_without_a_task_is_not_an_error() {
1064 let runner = ScriptedRunner::new().always("/Query", CommandOutput::exited(1, "", ""));
1065 let detached = control(&runner)
1066 .detach(&identity("Ubuntu"))
1067 .expect("nothing to remove");
1068 assert!(!detached.removed);
1069 assert!(
1070 runner
1071 .command_lines()
1072 .iter()
1073 .all(|line| !line.contains("/Delete"))
1074 );
1075 }
1076
1077 #[test]
1078 fn detach_refuses_a_foreign_task_rather_than_deleting_it() {
1079 let runner = ScriptedRunner::new().always(
1080 "/Query",
1081 CommandOutput::exited(
1082 0,
1083 "<Task><RegistrationInfo><Description>Somebody else's task</Description>\
1084 </RegistrationInfo></Task>",
1085 "",
1086 ),
1087 );
1088 let error = control(&runner)
1089 .detach(&identity("Ubuntu"))
1090 .expect_err("not ours");
1091 assert!(matches!(error, WslError::ForeignTask { .. }), "{error:?}");
1092 assert!(
1093 runner
1094 .command_lines()
1095 .iter()
1096 .all(|line| !line.contains("/Delete")),
1097 "a task this product does not own must not be deleted"
1098 );
1099 }
1100
1101 #[test]
1102 fn access_denied_is_reported_as_needing_elevation_rather_than_as_a_generic_failure() {
1103 let runner = ScriptedRunner::new()
1104 .always("/Query", CommandOutput::exited(1, "", ""))
1105 .always(
1106 "/Create",
1107 CommandOutput::exited(1, "", "ERROR: Access is denied.\n"),
1108 );
1109 let error = control(&runner)
1110 .register(&task("Ubuntu"))
1111 .expect_err("denied");
1112 assert!(
1113 matches!(error, WslError::NeedsElevation { .. }),
1114 "{error:?}"
1115 );
1116 }
1117
1118 #[test]
1119 fn starting_a_task_that_is_not_registered_says_so() {
1120 let runner = ScriptedRunner::new().always("/Query", CommandOutput::exited(1, "", ""));
1121 let error = control(&runner)
1122 .start(&identity("Ubuntu"))
1123 .expect_err("not registered");
1124 assert!(matches!(error, WslError::NoSuchTask { .. }), "{error:?}");
1125 }
1126
1127 #[test]
1128 fn a_query_reads_a_utf16_document_as_schtasks_really_writes_it() {
1129 let mut bytes = vec![0xFF, 0xFE];
1130 for unit in task("Ubuntu").xml().encode_utf16() {
1131 bytes.extend_from_slice(&unit.to_le_bytes());
1132 }
1133 let runner = ScriptedRunner::new()
1134 .always("/XML ONE", CommandOutput::exited(0, bytes, ""))
1135 .always(
1136 "/FO CSV",
1137 CommandOutput::exited(0, "\"task\",\"N/A\",\"Ready\"\n", ""),
1138 );
1139 let found = control(&runner)
1140 .query(&identity("Ubuntu"))
1141 .expect("queried")
1142 .expect("registered");
1143 assert!(found.is_product_owned());
1144 assert!(!found.running());
1145 assert!(found.arguments().contains("wsl-host hold"));
1146 }
1147
1148 #[test]
1149 fn a_running_task_is_reported_from_the_csv_status_column() {
1150 let runner = ScriptedRunner::new()
1151 .always("/XML ONE", registered_document("Ubuntu"))
1152 .always(
1153 "/FO CSV",
1154 CommandOutput::exited(0, "\"\\task\",\"N/A\",\"Running\"\n", ""),
1155 );
1156 let found = control(&runner)
1157 .query(&identity("Ubuntu"))
1158 .expect("queried")
1159 .expect("registered");
1160 assert!(found.running());
1161 }
1162
1163 #[test]
1166 fn the_document_is_written_as_utf16_little_endian_with_a_byte_order_mark() {
1167 let directory = tempfile::tempdir().expect("a temporary directory");
1168 let path = directory.path().join("task.xml");
1169 write_utf16(&path, &task("Ubuntu").xml()).expect("written");
1170 let bytes = std::fs::read(&path).expect("readable");
1171 assert_eq!(&bytes[..2], &[0xFF, 0xFE]);
1172 let decoded = decode_console_output(&bytes);
1173 assert_eq!(decoded.text(), task("Ubuntu").xml());
1174 }
1175
1176 #[test]
1177 fn no_credential_shaped_value_can_reach_the_document() {
1178 let document = task("Ubuntu").xml().to_ascii_lowercase();
1183 for shape in [
1184 "ghu_",
1185 "ghs_",
1186 "gho_",
1187 "github_pat_",
1188 "access_token",
1189 "refresh_token",
1190 "jitconfig",
1191 "secret",
1192 "password",
1193 "credential",
1194 ] {
1195 assert!(
1196 !document.contains(shape),
1197 "the task document mentions {shape:?}: {document}"
1198 );
1199 }
1200 assert!(document.contains("interactivetoken"));
1206 }
1207}