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 recovery_root: Option<PathBuf>,
153}
154
155impl LifecycleTask {
156 #[must_use]
158 pub fn new(
159 identity: LifecycleTaskIdentity,
160 principal: TaskPrincipal,
161 wsl_executable: &WslExecutable,
162 linux_binary: impl Into<String>,
163 ) -> Self {
164 Self {
165 identity,
166 principal,
167 wsl_executable: wsl_executable.path().to_path_buf(),
168 linux_binary: linux_binary.into(),
169 recovery_root: None,
170 }
171 }
172
173 #[must_use]
175 pub fn with_recovery_root(mut self, path: PathBuf) -> Self {
176 self.recovery_root = Some(path);
177 self
178 }
179
180 #[must_use]
182 pub fn identity(&self) -> &LifecycleTaskIdentity {
183 &self.identity
184 }
185
186 #[must_use]
188 pub fn principal(&self) -> &TaskPrincipal {
189 &self.principal
190 }
191
192 #[must_use]
194 pub fn command(&self) -> &Path {
195 &self.wsl_executable
196 }
197
198 #[must_use]
205 pub fn action_arguments(&self) -> Vec<String> {
206 let mut argv = vec![
207 "--distribution".to_string(),
208 self.identity.distribution.clone(),
209 "--user".to_string(),
210 LINUX_USER.to_string(),
211 "--exec".to_string(),
212 self.linux_binary.clone(),
213 ];
214 argv.extend(
215 HOLD_ARGUMENTS
216 .iter()
217 .map(|argument| (*argument).to_string()),
218 );
219 if let Some(root) = &self.recovery_root {
220 argv.push("--shared-root".to_string());
221 argv.push(root.to_string_lossy().into_owned());
222 }
223 argv
224 }
225
226 #[must_use]
228 pub fn rendered_arguments(&self) -> String {
229 self.action_arguments()
230 .iter()
231 .map(|argument| quote_argument(argument))
232 .collect::<Vec<_>>()
233 .join(" ")
234 }
235
236 #[must_use]
238 pub fn xml(&self) -> String {
239 let user = xml_escape(self.principal.user_id());
240 let mut out = String::new();
241 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
242 out.push_str(
243 "<Task version=\"1.4\" \
244 xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
245 );
246 out.push_str(" <RegistrationInfo>\n");
247 out.push_str(&format!(
248 " <Description>{}</Description>\n",
249 xml_escape(&self.identity.description())
250 ));
251 out.push_str(&format!(
252 " <URI>\\{}</URI>\n",
253 xml_escape(self.identity.name())
254 ));
255 out.push_str(" </RegistrationInfo>\n");
256
257 out.push_str(" <Triggers>\n <LogonTrigger>\n");
258 out.push_str(" <Enabled>true</Enabled>\n");
259 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
260 out.push_str(" </LogonTrigger>\n </Triggers>\n");
261
262 out.push_str(" <Principals>\n <Principal id=\"Author\">\n");
267 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
268 out.push_str(" <LogonType>InteractiveToken</LogonType>\n");
269 out.push_str(" <RunLevel>LeastPrivilege</RunLevel>\n");
270 out.push_str(" </Principal>\n </Principals>\n");
271
272 out.push_str(" <Settings>\n");
273 out.push_str(" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
276 out.push_str(" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
277 out.push_str(" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
278 out.push_str(" <AllowHardTerminate>true</AllowHardTerminate>\n");
279 out.push_str(" <StartWhenAvailable>true</StartWhenAvailable>\n");
280 out.push_str(" <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
281 out.push_str(" <IdleSettings>\n");
282 out.push_str(" <StopOnIdleEnd>false</StopOnIdleEnd>\n");
283 out.push_str(" <RestartOnIdle>false</RestartOnIdle>\n");
284 out.push_str(" </IdleSettings>\n");
285 out.push_str(" <AllowStartOnDemand>true</AllowStartOnDemand>\n");
286 out.push_str(" <Enabled>true</Enabled>\n");
287 out.push_str(" <Hidden>false</Hidden>\n");
288 out.push_str(" <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
289 out.push_str(" <WakeToRun>false</WakeToRun>\n");
290 out.push_str(" <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
293 out.push_str(" <Priority>7</Priority>\n");
294 out.push_str(" <RestartOnFailure>\n");
295 out.push_str(" <Interval>PT1M</Interval>\n");
296 out.push_str(" <Count>5</Count>\n");
297 out.push_str(" </RestartOnFailure>\n");
298 out.push_str(" </Settings>\n");
299
300 out.push_str(" <Actions Context=\"Author\">\n <Exec>\n");
301 out.push_str(&format!(
302 " <Command>{}</Command>\n",
303 xml_escape(&self.wsl_executable.to_string_lossy())
304 ));
305 out.push_str(&format!(
306 " <Arguments>{}</Arguments>\n",
307 xml_escape(&self.rendered_arguments())
308 ));
309 out.push_str(" </Exec>\n </Actions>\n");
310 out.push_str("</Task>\n");
311 out
312 }
313}
314
315#[derive(Debug, Clone, PartialEq, Eq)]
321pub struct RegisteredTask {
322 name: String,
323 command: String,
324 arguments: String,
325 account: Option<String>,
326 description: String,
327 enabled: bool,
328 running: bool,
329}
330
331impl RegisteredTask {
332 #[must_use]
335 pub fn from_document(name: &str, document: &str, running: bool) -> Self {
336 Self {
337 name: name.to_string(),
338 command: xml_value(document, "Command").unwrap_or_default(),
339 arguments: xml_value(document, "Arguments").unwrap_or_default(),
340 account: xml_value(document, "UserId"),
341 description: xml_value(document, "Description").unwrap_or_default(),
342 enabled: task_is_enabled(document),
343 running,
344 }
345 }
346
347 #[must_use]
349 pub fn name(&self) -> &str {
350 &self.name
351 }
352
353 #[must_use]
355 pub fn command(&self) -> &str {
356 &self.command
357 }
358
359 #[must_use]
361 pub fn arguments(&self) -> &str {
362 &self.arguments
363 }
364
365 #[must_use]
367 pub fn account(&self) -> Option<&str> {
368 self.account.as_deref()
369 }
370
371 #[must_use]
373 pub fn description(&self) -> &str {
374 &self.description
375 }
376
377 #[must_use]
379 pub fn enabled(&self) -> bool {
380 self.enabled
381 }
382
383 #[must_use]
394 pub fn running(&self) -> bool {
395 self.running
396 }
397
398 #[must_use]
404 pub fn is_product_owned(&self) -> bool {
405 self.description.contains(PRODUCT_MARKER)
406 }
407}
408
409fn task_is_enabled(document: &str) -> bool {
421 let settings = document
422 .find("<Settings>")
423 .map_or(document, |start| &document[start..]);
424 xml_value(settings, "Enabled").as_deref() != Some("false")
425}
426
427#[derive(Debug)]
438pub struct LifecycleTaskControl<'runner> {
439 runner: &'runner dyn CommandRunner,
440 schtasks: PathBuf,
441}
442
443#[derive(Debug, Clone, PartialEq, Eq)]
445pub struct Detached {
446 pub removed: bool,
448 pub name: String,
450}
451
452impl<'runner> LifecycleTaskControl<'runner> {
453 #[must_use]
455 pub fn new(runner: &'runner dyn CommandRunner) -> Self {
456 Self {
457 runner,
458 schtasks: locate_in_system32("schtasks.exe"),
459 }
460 }
461
462 #[must_use]
464 pub fn with_executable(
465 runner: &'runner dyn CommandRunner,
466 schtasks: impl Into<PathBuf>,
467 ) -> Self {
468 Self {
469 runner,
470 schtasks: schtasks.into(),
471 }
472 }
473
474 pub fn query(
480 &self,
481 identity: &LifecycleTaskIdentity,
482 ) -> Result<Option<RegisteredTask>, WslError> {
483 let output = self.schtasks(&["/Query", "/TN", identity.name(), "/XML", "ONE"])?;
484 if !output.success() {
485 return Ok(None);
493 }
494 let document = decode_console_output(output.stdout()).into_text();
495 Ok(Some(RegisteredTask::from_document(
496 identity.name(),
497 &document,
498 self.is_running(identity),
499 )))
500 }
501
502 pub fn register(&self, task: &LifecycleTask) -> Result<(), WslError> {
517 let identity = task.identity();
518 match self.query(identity)? {
519 Some(existing) if !existing.is_product_owned() => {
520 return Err(WslError::ForeignTask {
521 name: identity.name().to_string(),
522 detail: format!(
523 "a task of this name already exists, its description does not identify \
524 it as this product's ({PRODUCT_MARKER}), and it starts `{}`. Rename or \
525 remove it yourself if it is the hand-created keep-alive this feature \
526 replaces.",
527 existing.command()
528 ),
529 });
530 }
531 Some(_) => {}
532 None if self.exists(identity) => {
539 return Err(WslError::ForeignTask {
540 name: identity.name().to_string(),
541 detail: format!(
542 "a task of this name exists but Task Scheduler would not export its \
543 definition, so it cannot be shown to be this product's \
544 ({PRODUCT_MARKER}) and registering would replace it. Inspect it in \
545 `taskschd.msc`, and rename or remove it yourself if it is the \
546 hand-created keep-alive this feature replaces."
547 ),
548 });
549 }
550 None => {}
551 }
552
553 let directory = tempfile::tempdir().map_err(|error| WslError::Record {
554 operation: "write",
555 path: PathBuf::from("<the scheduled-task document>"),
556 detail: error.to_string(),
557 })?;
558 let document = directory.path().join("task.xml");
559 write_utf16(&document, &task.xml()).map_err(|error| WslError::Record {
560 operation: "write",
561 path: document.clone(),
562 detail: error.to_string(),
563 })?;
564
565 let output = self.schtasks(&[
566 "/Create",
567 "/TN",
568 identity.name(),
569 "/XML",
570 &document.to_string_lossy(),
571 "/F",
572 ])?;
573 if !output.success() {
574 return Err(self.task_error("register", identity.name(), &output.diagnostic()));
575 }
576 Ok(())
577 }
578
579 pub fn detach(&self, identity: &LifecycleTaskIdentity) -> Result<Detached, WslError> {
591 let Some(existing) = self.query(identity)? else {
592 return Ok(Detached {
593 removed: false,
594 name: identity.name().to_string(),
595 });
596 };
597 if !existing.is_product_owned() {
598 return Err(WslError::ForeignTask {
599 name: identity.name().to_string(),
600 detail: format!(
601 "a task of this name exists but its description does not identify it as \
602 this product's ({PRODUCT_MARKER}), so `detach` will not remove it."
603 ),
604 });
605 }
606 let output = self.schtasks(&["/Delete", "/TN", identity.name(), "/F"])?;
607 if !output.success() {
608 return Err(self.task_error("remove", identity.name(), &output.diagnostic()));
609 }
610 Ok(Detached {
611 removed: true,
612 name: identity.name().to_string(),
613 })
614 }
615
616 pub fn start(&self, identity: &LifecycleTaskIdentity) -> Result<(), WslError> {
624 self.require_ours("start", identity)?;
625 let output = self.schtasks(&["/Run", "/TN", identity.name()])?;
626 if !output.success() {
627 return Err(self.task_error("start", identity.name(), &output.diagnostic()));
628 }
629 Ok(())
630 }
631
632 pub fn stop(&self, identity: &LifecycleTaskIdentity) -> Result<bool, WslError> {
638 let existing = self.require_ours("stop", identity)?;
639 if !existing.running() {
640 return Ok(false);
641 }
642 let output = self.schtasks(&["/End", "/TN", identity.name()])?;
643 if !output.success() {
644 return Err(self.task_error("stop", identity.name(), &output.diagnostic()));
645 }
646 Ok(true)
647 }
648
649 fn require_ours(
650 &self,
651 operation: &'static str,
652 identity: &LifecycleTaskIdentity,
653 ) -> Result<RegisteredTask, WslError> {
654 let Some(existing) = self.query(identity)? else {
655 return Err(WslError::NoSuchTask {
656 name: identity.name().to_string(),
657 });
658 };
659 if !existing.is_product_owned() {
660 return Err(WslError::ForeignTask {
661 name: identity.name().to_string(),
662 detail: format!(
663 "a task of this name exists but is not this product's ({PRODUCT_MARKER}), \
664 so it will not be used to {operation} anything."
665 ),
666 });
667 }
668 Ok(existing)
669 }
670
671 fn schtasks(&self, arguments: &[&str]) -> Result<super::exec::CommandOutput, WslError> {
672 let request =
673 CommandRequest::new(&self.schtasks).args(arguments.iter().map(OsString::from));
674 self.runner.run(&request)
675 }
676
677 fn query_csv(&self, identity: &LifecycleTaskIdentity) -> Option<super::exec::CommandOutput> {
689 let output = self
690 .schtasks(&["/Query", "/TN", identity.name(), "/FO", "CSV", "/NH"])
691 .ok()?;
692 output.success().then_some(output)
693 }
694
695 fn exists(&self, identity: &LifecycleTaskIdentity) -> bool {
700 self.query_csv(identity).is_some()
701 }
702
703 fn is_running(&self, identity: &LifecycleTaskIdentity) -> bool {
706 let Some(output) = self.query_csv(identity) else {
707 return false;
708 };
709 decode_console_output(output.stdout())
710 .into_text()
711 .lines()
712 .filter_map(|line| line.rsplit(',').next())
713 .any(|status| {
714 status
715 .trim()
716 .trim_matches('"')
717 .eq_ignore_ascii_case("running")
718 })
719 }
720
721 fn task_error(&self, operation: &'static str, name: &str, detail: &str) -> WslError {
722 if detail.to_ascii_lowercase().contains("access is denied") {
723 return WslError::NeedsElevation {
724 operation,
725 name: name.to_string(),
726 detail: detail.to_string(),
727 };
728 }
729 WslError::TaskControl {
730 operation,
731 name: name.to_string(),
732 detail: detail.to_string(),
733 }
734 }
735}
736
737fn write_utf16(path: &Path, text: &str) -> std::io::Result<()> {
743 let mut bytes = vec![0xFF, 0xFE];
744 for unit in text.encode_utf16() {
745 bytes.extend_from_slice(&unit.to_le_bytes());
746 }
747 std::fs::write(path, bytes)
748}
749
750#[cfg(test)]
751mod tests {
752 use super::*;
753 use crate::wsl::discovery::{DIGEST_SUFFIX_LENGTH, ESCAPED_NAME_BUDGET};
754 use crate::wsl::exec::{CommandOutput, ScriptedRunner};
755
756 fn identity(distribution: &str) -> LifecycleTaskIdentity {
757 LifecycleTaskIdentity::for_distribution(distribution).expect("a usable name")
758 }
759
760 fn task(distribution: &str) -> LifecycleTask {
761 LifecycleTask::new(
762 identity(distribution),
763 TaskPrincipal::named("IVANPC\\IvanD"),
764 &WslExecutable::at("C:\\Windows\\System32\\wsl.exe"),
765 "/usr/local/bin/runner-manager",
766 )
767 }
768
769 fn registered_document(distribution: &str) -> CommandOutput {
770 CommandOutput::exited(0, task(distribution).xml(), "")
771 }
772
773 #[test]
776 fn the_task_name_is_stable_for_a_distribution() {
777 assert_eq!(identity("Ubuntu").name(), identity("Ubuntu").name());
778 assert!(
779 identity("Ubuntu")
780 .name()
781 .starts_with("runner-manager-wsl-Ubuntu-")
782 );
783 }
784
785 #[test]
786 fn a_name_task_scheduler_could_not_hold_is_escaped_into_one_that_it_can() {
787 let name = identity("Debian GNU/Linux 12").name().to_string();
788 for forbidden in ['\\', '/', ':', '*', '?', '"', '<', '>', '|'] {
789 assert!(
790 !name.contains(forbidden),
791 "{name} still contains {forbidden:?}"
792 );
793 }
794 assert!(name.contains("Debian_GNU_Linux_12"), "{name}");
795 }
796
797 #[test]
798 fn two_distributions_that_escape_alike_still_get_different_tasks() {
799 let first = identity("Debian GNU/Linux");
803 let second = identity("Debian GNU:Linux");
804 assert_ne!(first.name(), second.name());
805 assert!(first.name().contains("Debian_GNU_Linux"));
806 assert!(second.name().contains("Debian_GNU_Linux"));
807 }
808
809 #[test]
810 fn a_very_long_name_is_bounded_and_still_unique() {
811 let long = "u".repeat(200);
812 let other = format!("{long}x");
813 let first = identity(&long);
814 let second = identity(&other);
815 assert_ne!(first.name(), second.name());
816 assert!(
817 first.name().len()
818 <= LIFECYCLE_TASK_PREFIX.len() + 1 + ESCAPED_NAME_BUDGET + 1 + DIGEST_SUFFIX_LENGTH,
819 "{}",
820 first.name()
821 );
822 }
823
824 #[test]
825 fn a_distribution_name_that_is_not_usable_never_becomes_a_task_name() {
826 assert!(LifecycleTaskIdentity::for_distribution("--shutdown").is_err());
827 assert!(LifecycleTaskIdentity::for_distribution("").is_err());
828 }
829
830 #[test]
833 fn the_action_is_the_documented_argument_vector() {
834 assert_eq!(
835 task("Ubuntu").action_arguments(),
836 vec![
837 "--distribution",
838 "Ubuntu",
839 "--user",
840 "root",
841 "--exec",
842 "/usr/local/bin/runner-manager",
843 "wsl-host",
844 "hold",
845 ]
846 );
847 }
848
849 #[test]
850 fn a_name_with_spaces_is_quoted_so_windows_splits_it_back_into_one_argument() {
851 let rendered = task("My Ubuntu").rendered_arguments();
852 assert!(
853 rendered.contains("--distribution \"My Ubuntu\" --user root"),
854 "{rendered}"
855 );
856 }
857
858 #[test]
859 fn no_shell_text_reaches_the_task_document() {
860 let document = task("Ubuntu & echo pwned").xml();
863 let arguments = xml_value(&document, "Arguments").expect("the document has an action");
864 for shell in ["cmd", "powershell", "/c", "&&", "||", ";", "$(", "`"] {
865 assert!(
866 !arguments.contains(shell),
867 "the rendered arguments contain shell text {shell:?}: {arguments}"
868 );
869 }
870 assert_eq!(
871 xml_value(&document, "Command").as_deref(),
872 Some("C:\\Windows\\System32\\wsl.exe")
873 );
874 assert!(document.contains("&"), "{document}");
877 assert!(arguments.contains("\"Ubuntu & echo pwned\""), "{arguments}");
878 }
879
880 #[test]
881 fn the_document_is_a_least_privilege_logon_task_for_the_named_principal() {
882 let document = task("Ubuntu").xml();
883 assert!(document.contains("<LogonTrigger>"), "{document}");
884 assert!(
885 document.contains("<RunLevel>LeastPrivilege</RunLevel>"),
886 "{document}"
887 );
888 assert!(
889 document.contains("<UserId>IVANPC\\IvanD</UserId>"),
890 "{document}"
891 );
892 assert!(document.contains("<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>"));
894 }
895
896 #[test]
897 fn the_document_carries_the_ownership_marker_and_names_the_distribution() {
898 let document = task("Ubuntu").xml();
899 let description = xml_value(&document, "Description").expect("a description");
900 assert!(description.contains(PRODUCT_MARKER), "{description}");
901 assert!(description.contains("Ubuntu"), "{description}");
902 assert!(description.contains("wsl detach"), "{description}");
903 }
904
905 #[test]
906 fn a_rendered_document_reads_back_as_this_products_task() {
907 let document = task("Ubuntu").xml();
908 let read = RegisteredTask::from_document("whatever", &document, false);
909 assert!(read.is_product_owned());
910 assert_eq!(read.account(), Some("IVANPC\\IvanD"));
911 assert!(read.enabled());
912 assert!(
913 read.arguments().contains("wsl-host hold"),
914 "{}",
915 read.arguments()
916 );
917 }
918
919 #[test]
920 fn a_task_an_operator_disabled_is_reported_as_disabled() {
921 let disabled = task("Ubuntu").xml().replace(
929 "\n <Enabled>true</Enabled>\n",
930 "\n <Enabled>false</Enabled>\n",
931 );
932 assert!(
933 disabled.contains(" <Enabled>true</Enabled>"),
934 "the trigger's own <Enabled> must still be true for this to prove anything"
935 );
936 assert!(!RegisteredTask::from_document("whatever", &disabled, false).enabled());
937 assert!(RegisteredTask::from_document("whatever", &task("Ubuntu").xml(), false).enabled());
938 }
939
940 #[test]
941 fn a_task_this_product_did_not_write_is_not_product_owned() {
942 let hand_made = concat!(
943 "<Task><RegistrationInfo><Description>GitHub Actions Linux Runner - Ubuntu WSL",
944 "</Description></RegistrationInfo><Actions><Exec><Command>wsl.exe</Command>",
945 "<Arguments>-d Ubuntu -u root /bin/sleep infinity</Arguments></Exec></Actions></Task>",
946 );
947 let read = RegisteredTask::from_document("whatever", hand_made, false);
948 assert!(!read.is_product_owned());
949 }
950
951 fn control(runner: &ScriptedRunner) -> LifecycleTaskControl<'_> {
954 LifecycleTaskControl::with_executable(runner, "schtasks.exe")
955 }
956
957 #[test]
958 fn registering_writes_a_utf16_document_and_replaces_in_place() {
959 let runner = ScriptedRunner::new().always("/Query", CommandOutput::exited(1, "", ""));
960 let task = task("Ubuntu");
961 control(&runner).register(&task).expect("registered");
962
963 let create = runner
964 .recorded()
965 .into_iter()
966 .find(|request| request.arguments.first().map(String::as_str) == Some("/Create"))
967 .expect("a /Create call");
968 assert_eq!(create.arguments[1], "/TN");
969 assert_eq!(create.arguments[2], task.identity().name());
970 assert_eq!(create.arguments[3], "/XML");
971 assert_eq!(
972 create.arguments[5], "/F",
973 "without /F a second `wsl install` fails instead of updating the task"
974 );
975 }
976
977 #[test]
978 fn registering_over_this_products_own_task_is_allowed_and_idempotent() {
979 let runner = ScriptedRunner::new()
980 .always("/Query", registered_document("Ubuntu"))
981 .always("/Create", CommandOutput::exited(0, "SUCCESS", ""));
982 control(&runner)
983 .register(&task("Ubuntu"))
984 .expect("replaced");
985 control(&runner)
986 .register(&task("Ubuntu"))
987 .expect("replaced again");
988 }
989
990 #[test]
991 fn registering_over_a_task_that_cannot_be_exported_refuses_and_changes_nothing() {
992 let runner = ScriptedRunner::new()
997 .always(
998 "/XML",
999 CommandOutput::exited(1, "", "the task image is corrupt"),
1000 )
1001 .always(
1002 "/FO",
1003 CommandOutput::exited(0, "\"whatever\",\"N/A\",\"Ready\"", ""),
1004 );
1005 let error = control(&runner)
1006 .register(&task("Ubuntu"))
1007 .expect_err("an unexportable task is not a free name");
1008 assert!(matches!(error, WslError::ForeignTask { .. }), "{error:?}");
1009 assert!(
1010 runner
1011 .command_lines()
1012 .iter()
1013 .all(|line| !line.contains("/Create")),
1014 "nothing may be written: {:?}",
1015 runner.command_lines()
1016 );
1017 }
1018
1019 #[test]
1020 fn registering_over_a_foreign_task_refuses_and_changes_nothing() {
1021 let hand_made = CommandOutput::exited(
1022 0,
1023 concat!(
1024 "<Task><RegistrationInfo><Description>GitHub Actions Linux Runner - Ubuntu WSL",
1025 "</Description></RegistrationInfo><Actions><Exec><Command>wsl.exe</Command>",
1026 "</Exec></Actions></Task>",
1027 ),
1028 "",
1029 );
1030 let runner = ScriptedRunner::new().always("/Query", hand_made);
1031 let error = control(&runner)
1032 .register(&task("Ubuntu"))
1033 .expect_err("not ours");
1034 assert!(matches!(error, WslError::ForeignTask { .. }), "{error:?}");
1035 assert!(
1036 runner
1037 .command_lines()
1038 .iter()
1039 .all(|line| !line.contains("/Create")),
1040 "nothing may be written: {:?}",
1041 runner.command_lines()
1042 );
1043 }
1044
1045 #[test]
1046 fn detach_removes_only_the_product_task_and_runs_nothing_else() {
1047 let runner = ScriptedRunner::new()
1048 .always("/Query", registered_document("Ubuntu"))
1049 .always("/Delete", CommandOutput::exited(0, "SUCCESS", ""));
1050 let detached = control(&runner)
1051 .detach(&identity("Ubuntu"))
1052 .expect("detached");
1053 assert!(detached.removed);
1054
1055 for request in runner.recorded() {
1056 assert_eq!(
1057 request.program.to_string_lossy(),
1058 "schtasks.exe",
1059 "detach must not run anything but Task Scheduler: {request:?}"
1060 );
1061 }
1062 let lines = runner.command_lines();
1063 assert!(
1064 lines.iter().all(|line| !line.contains("wsl.exe")),
1065 "detach must not reach into the distribution: {lines:?}"
1066 );
1067 assert!(
1068 lines
1069 .iter()
1070 .all(|line| !line.contains("--unregister") && !line.contains("systemctl")),
1071 "detach must not unregister WSL or touch the Linux service: {lines:?}"
1072 );
1073 }
1074
1075 #[test]
1076 fn detach_without_a_task_is_not_an_error() {
1077 let runner = ScriptedRunner::new().always("/Query", CommandOutput::exited(1, "", ""));
1078 let detached = control(&runner)
1079 .detach(&identity("Ubuntu"))
1080 .expect("nothing to remove");
1081 assert!(!detached.removed);
1082 assert!(
1083 runner
1084 .command_lines()
1085 .iter()
1086 .all(|line| !line.contains("/Delete"))
1087 );
1088 }
1089
1090 #[test]
1091 fn detach_refuses_a_foreign_task_rather_than_deleting_it() {
1092 let runner = ScriptedRunner::new().always(
1093 "/Query",
1094 CommandOutput::exited(
1095 0,
1096 "<Task><RegistrationInfo><Description>Somebody else's task</Description>\
1097 </RegistrationInfo></Task>",
1098 "",
1099 ),
1100 );
1101 let error = control(&runner)
1102 .detach(&identity("Ubuntu"))
1103 .expect_err("not ours");
1104 assert!(matches!(error, WslError::ForeignTask { .. }), "{error:?}");
1105 assert!(
1106 runner
1107 .command_lines()
1108 .iter()
1109 .all(|line| !line.contains("/Delete")),
1110 "a task this product does not own must not be deleted"
1111 );
1112 }
1113
1114 #[test]
1115 fn access_denied_is_reported_as_needing_elevation_rather_than_as_a_generic_failure() {
1116 let runner = ScriptedRunner::new()
1117 .always("/Query", CommandOutput::exited(1, "", ""))
1118 .always(
1119 "/Create",
1120 CommandOutput::exited(1, "", "ERROR: Access is denied.\n"),
1121 );
1122 let error = control(&runner)
1123 .register(&task("Ubuntu"))
1124 .expect_err("denied");
1125 assert!(
1126 matches!(error, WslError::NeedsElevation { .. }),
1127 "{error:?}"
1128 );
1129 }
1130
1131 #[test]
1132 fn starting_a_task_that_is_not_registered_says_so() {
1133 let runner = ScriptedRunner::new().always("/Query", CommandOutput::exited(1, "", ""));
1134 let error = control(&runner)
1135 .start(&identity("Ubuntu"))
1136 .expect_err("not registered");
1137 assert!(matches!(error, WslError::NoSuchTask { .. }), "{error:?}");
1138 }
1139
1140 #[test]
1141 fn a_query_reads_a_utf16_document_as_schtasks_really_writes_it() {
1142 let mut bytes = vec![0xFF, 0xFE];
1143 for unit in task("Ubuntu").xml().encode_utf16() {
1144 bytes.extend_from_slice(&unit.to_le_bytes());
1145 }
1146 let runner = ScriptedRunner::new()
1147 .always("/XML ONE", CommandOutput::exited(0, bytes, ""))
1148 .always(
1149 "/FO CSV",
1150 CommandOutput::exited(0, "\"task\",\"N/A\",\"Ready\"\n", ""),
1151 );
1152 let found = control(&runner)
1153 .query(&identity("Ubuntu"))
1154 .expect("queried")
1155 .expect("registered");
1156 assert!(found.is_product_owned());
1157 assert!(!found.running());
1158 assert!(found.arguments().contains("wsl-host hold"));
1159 }
1160
1161 #[test]
1162 fn a_running_task_is_reported_from_the_csv_status_column() {
1163 let runner = ScriptedRunner::new()
1164 .always("/XML ONE", registered_document("Ubuntu"))
1165 .always(
1166 "/FO CSV",
1167 CommandOutput::exited(0, "\"\\task\",\"N/A\",\"Running\"\n", ""),
1168 );
1169 let found = control(&runner)
1170 .query(&identity("Ubuntu"))
1171 .expect("queried")
1172 .expect("registered");
1173 assert!(found.running());
1174 }
1175
1176 #[test]
1179 fn the_document_is_written_as_utf16_little_endian_with_a_byte_order_mark() {
1180 let directory = tempfile::tempdir().expect("a temporary directory");
1181 let path = directory.path().join("task.xml");
1182 write_utf16(&path, &task("Ubuntu").xml()).expect("written");
1183 let bytes = std::fs::read(&path).expect("readable");
1184 assert_eq!(&bytes[..2], &[0xFF, 0xFE]);
1185 let decoded = decode_console_output(&bytes);
1186 assert_eq!(decoded.text(), task("Ubuntu").xml());
1187 }
1188
1189 #[test]
1190 fn no_credential_shaped_value_can_reach_the_document() {
1191 let document = task("Ubuntu").xml().to_ascii_lowercase();
1196 for shape in [
1197 "ghu_",
1198 "ghs_",
1199 "gho_",
1200 "github_pat_",
1201 "access_token",
1202 "refresh_token",
1203 "jitconfig",
1204 "secret",
1205 "password",
1206 "credential",
1207 ] {
1208 assert!(
1209 !document.contains(shape),
1210 "the task document mentions {shape:?}: {document}"
1211 );
1212 }
1213 assert!(document.contains("interactivetoken"));
1219 }
1220}