Skip to main content

runner_manager_platform/wsl/
task.rs

1// owner: a1-wsl-platform-adapter
2
3//! The one Windows login task that keeps a managed WSL distribution alive,
4//! and the four operations on it: render, register, query, remove.
5//!
6//! # What the task promises, and what it does not
7//!
8//! WSL distributions are registered **per user**, so nothing that runs before
9//! a user logs on can start one. The 2026-09-06 review closed exactly this
10//! defect: an earlier design promised boot availability that Windows cannot
11//! deliver. So this is a `LogonTrigger` task for one named principal, and
12//! `02-target-architecture.md` states the consequence in the product's own
13//! words — *"unattended Linux availability after that user's logon, not before
14//! any interactive logon after a Windows reboot"*.
15//!
16//! # There is no shell text in the action, at any layer
17//!
18//! The other half of that review closed a design that composed `systemctl` and
19//! a keep-alive through a shell string. The action here is
20//!
21//! ```text
22//! <Command>...\bin\runner-manager-wsl-supervisor.exe</Command>
23//! <Arguments>...\bin\runner-manager-wsl.exe wsl-host supervise --distribution Ubuntu ...</Arguments>
24//! ```
25//!
26//! Both paths are **version-independent**. An update swaps the two files in
27//! place and leaves the document as it was, so the task never has to be
28//! registered again — and registering is the one step that can need
29//! administrator rights, because a task created from an elevated prompt keeps
30//! an ACL that refuses replacement from an ordinary one. Up to 0.4.34 the
31//! paths carried the version, so every update was a new document, a new
32//! `/Create`, and an `Access is denied` on every host first set up elevated.
33//! [`LifecycleTask::is_registered_as`] is the comparison that lets `wsl
34//! install` skip the registration when nothing in the document changed.
35//!
36//! Task Scheduler has no `Arguments` *vector* — the element is a single string
37//! that Windows splits with `CommandLineToArgvW` — so
38//! [`LifecycleTask::action_arguments`] builds the vector and
39//! [`LifecycleTask::rendered_arguments`] quotes each element with the same
40//! function the service installer uses. A distribution called `My Ubuntu` is
41//! therefore `"My Ubuntu"` in the document and one argument again on the way
42//! out. What is *not* there is a `cmd /c`, a `&&`, a `;`, or anything else a
43//! shell would interpret, and `no_shell_text_reaches_the_task_document` is the
44//! test that keeps it that way.
45//!
46//! `wsl-host supervise` is the hidden Windows companion. It starts the
47//! Linux-only `wsl-host hold`, restarts that child when the named distribution
48//! is recovered, and owns the bounded recovery watchdog. Running in the
49//! distribution owner's interactive token is essential: an SCM service under
50//! LocalSystem cannot see another account's WSL registrations.
51//!
52//! # A task this did not create is never touched
53//!
54//! The name is derived from the distribution, so two workstations agree on it
55//! and a re-run updates the task rather than accumulating copies. That same
56//! determinism means the name could collide with something an operator made by
57//! hand — and on the target workstation there *is* a hand-created task doing
58//! this job today (`01-current-architecture.md`). So every mutating operation
59//! reads the task back first and refuses unless its description carries
60//! [`PRODUCT_MARKER`]. `wsl detach` removing somebody else's keep-alive task
61//! would be precisely the destructive behaviour the review renamed the command
62//! to avoid.
63
64use 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
75/// The prefix every product-owned lifecycle task name starts with.
76pub const LIFECYCLE_TASK_PREFIX: &str = "runner-manager-wsl";
77
78/// The string that says a task is this product's.
79///
80/// It is in the task's `Description`, which Task Scheduler round-trips
81/// verbatim through `/Query /XML`, so ownership survives an export and import
82/// and does not depend on parsing the action.
83pub const PRODUCT_MARKER: &str = "runner-manager-wsl-lifecycle/v1";
84
85/// The revision of the task document, carried in its description.
86///
87/// `wsl install` re-registers a task only when the registered document differs
88/// from the one it would write (see [`LifecycleTask::is_registered_as`]), and
89/// that comparison reads the program, its arguments and the account (through
90/// [`LifecycleTask::definition_stamp`]), not every setting. So a release that
91/// changes anything else in the document (a
92/// trigger, a setting, the run level) must raise this number, or hosts that
93/// already have the task keep the old document.
94///
95/// Revision 2 is the version-independent companion paths.
96pub const TASK_DEFINITION_REVISION: u32 = 2;
97
98/// The hidden Linux command the task runs.
99///
100/// `02-target-architecture.md`: it "verifies systemd, starts the existing unit
101/// by argument-vector process execution, and then remains alive with
102/// signal-aware shutdown so WSL does not retire the distribution".
103pub const HOLD_ARGUMENTS: [&str; 2] = ["wsl-host", "hold"];
104
105// ---------------------------------------------------------------------------
106// Identity
107// ---------------------------------------------------------------------------
108
109/// The stable, per-distribution name of the product's lifecycle task.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct LifecycleTaskIdentity {
112    distribution: String,
113    name: String,
114}
115
116impl LifecycleTaskIdentity {
117    /// Derives the task identity for a distribution.
118    ///
119    /// # The name is escaped *and* hashed, and both halves are load-bearing
120    ///
121    /// See [`escaped_name_with_digest`], which is also what
122    /// [`super::record`] names its files with: escaping alone would map
123    /// `Debian GNU/Linux` and `Debian GNU:Linux` onto one task, which is two
124    /// distributions quietly sharing one keep-alive.
125    ///
126    /// # Errors
127    ///
128    /// [`WslError::InvalidName`] for a name that cannot be used at all.
129    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    /// The distribution this task keeps alive.
141    #[must_use]
142    pub fn distribution(&self) -> &str {
143        &self.distribution
144    }
145
146    /// The Task Scheduler name.
147    #[must_use]
148    pub fn name(&self) -> &str {
149        &self.name
150    }
151
152    /// The description a document carries: the marker, and the task's
153    /// [`LifecycleTask::definition_stamp`].
154    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// ---------------------------------------------------------------------------
166// The document
167// ---------------------------------------------------------------------------
168
169/// Everything needed to render the task.
170#[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    /// Builds the task for one distribution and one Windows account.
183    #[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    /// Give the hold process the Windows directory used for recovery state.
202    #[must_use]
203    pub fn with_recovery_root(mut self, path: PathBuf) -> Self {
204        self.recovery_root = Some(path);
205        self
206    }
207
208    /// Run the keep-alive through a Windows process in the owning user's
209    /// session. That process can both hold the guest open and recover a WSL
210    /// transport which LocalSystem cannot see.
211    #[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    /// Which task this is.
219    #[must_use]
220    pub fn identity(&self) -> &LifecycleTaskIdentity {
221        &self.identity
222    }
223
224    /// The account it runs as.
225    #[must_use]
226    pub fn principal(&self) -> &TaskPrincipal {
227        &self.principal
228    }
229
230    /// The program the task starts.
231    #[must_use]
232    pub fn command(&self) -> &Path {
233        self.windows_supervisor
234            .as_deref()
235            .unwrap_or(&self.wsl_executable)
236    }
237
238    /// The action's argument **vector**.
239    ///
240    /// The same shape [`super::probe::LinuxCommand`] builds for every other
241    /// invocation, which is deliberate: the task starts the distribution the
242    /// same way the provisioning transaction does, so there is one thing to
243    /// get right rather than two.
244    #[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    /// The vector, quoted into the single string Task Scheduler stores.
287    #[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    /// `definition N, fingerprint XXXXXXXXXXXXXXXX`: the document revision and
297    /// a digest of the program, its arguments and the account, carried in the
298    /// description.
299    ///
300    /// # Why a digest rather than the values themselves
301    ///
302    /// `schtasks /Query /XML` writes to a pipe in the console's code page, not
303    /// in the UTF-16 its own header declares (measured on Windows 11), so a
304    /// profile path, an account or a distribution name outside that code page
305    /// comes back altered. A comparison of the values would then fail on every
306    /// update and send every update back to the `/Create` that needs
307    /// administrator rights. The stamp is ASCII and survives the trip.
308    #[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    /// The description the document carries.
328    #[must_use]
329    pub fn description(&self) -> String {
330        self.identity.description(&self.definition_stamp())
331    }
332
333    /// Whether `registered` already is this task, so that registering it again
334    /// would change nothing.
335    ///
336    /// This is what keeps an update from needing administrator rights: when
337    /// the answer is yes, `wsl install` does not call `schtasks /Create` at
338    /// all, and `/Create` is the call Windows refuses an ordinary token for a
339    /// task that an elevated prompt created.
340    ///
341    /// Compared: ownership, whether it is enabled, the
342    /// [`definition_stamp`](Self::definition_stamp) in its description, and --
343    /// when they are ASCII and so survive the export unaltered -- the program
344    /// and its argument string as well, which also catches an action edited by
345    /// hand. A task an operator disabled is not "already this task":
346    /// re-registering it is how `wsl install` has always repaired that.
347    #[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    /// The Task Scheduler document.
361    #[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        // `LeastPrivilege` is the whole of Windows' answer to "this task does
387        // not need administrator": `wsl.exe` needs no elevation to start a
388        // distribution the logged-on user owns, and the systemd unit inside it
389        // is root's business, not Windows'.
390        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        // One hold process per distribution. A second would keep the same
398        // distribution alive twice and tell an operator nothing new.
399        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        // The hold has no natural end, so a limit here would be a scheduled
415        // kill of the thing that keeps the distribution up.
416        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// ---------------------------------------------------------------------------
440// Reading a task back
441// ---------------------------------------------------------------------------
442
443/// What Task Scheduler says about a task that is registered.
444#[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    /// Reads the fields this module cares about out of a `/Query /XML`
457    /// document.
458    #[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    /// The Task Scheduler name.
472    #[must_use]
473    pub fn name(&self) -> &str {
474        &self.name
475    }
476
477    /// The program it starts.
478    #[must_use]
479    pub fn command(&self) -> &str {
480        &self.command
481    }
482
483    /// The single argument string it stores.
484    #[must_use]
485    pub fn arguments(&self) -> &str {
486        &self.arguments
487    }
488
489    /// The account, when the document names one.
490    #[must_use]
491    pub fn account(&self) -> Option<&str> {
492        self.account.as_deref()
493    }
494
495    /// Its description.
496    #[must_use]
497    pub fn description(&self) -> &str {
498        &self.description
499    }
500
501    /// Whether it is enabled.
502    #[must_use]
503    pub fn enabled(&self) -> bool {
504        self.enabled
505    }
506
507    /// Whether Task Scheduler reports it as running.
508    ///
509    /// **Read from localised output**, exactly as
510    /// [`crate::service`]'s Windows backend reads it, and for the same reason:
511    /// `schtasks /Query /FO CSV` prints its `Status` column in the machine's
512    /// display language and there is no locale-independent equivalent short of
513    /// COM. On a non-English Windows this is `false` for a task that is in fact
514    /// running. The authority for "is the Linux host healthy" is the Linux
515    /// service's own status, never this. `wsl install` does branch on it, and
516    /// only where a wrong `false` is harmless: it runs `/Run` on a current
517    /// task that already runs, which `IgnoreNew` makes a no-op, and it skips
518    /// restarting a task it re-registered, which leaves the old companion
519    /// running until the next logon rather than doing anything to it.
520    #[must_use]
521    pub fn running(&self) -> bool {
522        self.running
523    }
524
525    /// Whether this product created it.
526    ///
527    /// The gate on every mutation. See the module documentation: the name is
528    /// derived, so it can collide with a hand-made task, and the marker is
529    /// what tells the two apart.
530    #[must_use]
531    pub fn is_product_owned(&self) -> bool {
532        self.description.contains(PRODUCT_MARKER)
533    }
534}
535
536/// Whether the *task* is enabled, which is not the first `<Enabled>` in the
537/// document.
538///
539/// A task has an `<Enabled>` inside its trigger and another inside its
540/// `<Settings>`, in that order, and it is the second one that Task Scheduler
541/// turns to `false` when an operator disables the task. Reading the first
542/// would report a task somebody switched off in `taskschd.msc` as enabled,
543/// which is the opposite of what a status line is for.
544///
545/// A document with no `<Settings>` at all — a hand-made task, or a fragment —
546/// is read as enabled, which is what an absent setting means to Windows.
547fn 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// ---------------------------------------------------------------------------
555// The control
556// ---------------------------------------------------------------------------
557
558/// Registering, reading and removing the product's lifecycle task.
559///
560/// Everything goes through a [`CommandRunner`], so the whole of this — the
561/// argument vectors, the idempotent replacement, the foreign-task refusal and
562/// the non-destructive removal — is testable on a CI leg that has no Task
563/// Scheduler at all.
564#[derive(Debug)]
565pub struct LifecycleTaskControl<'runner> {
566    runner: &'runner dyn CommandRunner,
567    schtasks: PathBuf,
568}
569
570/// What [`LifecycleTaskControl::detach`] did, and what it deliberately did not.
571#[derive(Debug, Clone, PartialEq, Eq)]
572pub struct Detached {
573    /// Whether there was a task to remove.
574    pub removed: bool,
575    /// The task's name, whether or not it was there.
576    pub name: String,
577}
578
579impl<'runner> LifecycleTaskControl<'runner> {
580    /// Uses the host's `schtasks.exe`.
581    #[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    /// Uses a named `schtasks.exe`, for a test.
590    #[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    /// What Task Scheduler holds under this name, if anything.
602    ///
603    /// # Errors
604    ///
605    /// [`WslError::Spawn`] when `schtasks.exe` cannot be started at all.
606    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            // `schtasks` reports "no such task" and "Task Scheduler is broken"
613            // with the same non-zero exit and no distinct code, and the
614            // sentence that would tell them apart is localised. Reading it as
615            // absence is what an operator with no task should see -- but it is
616            // only safe because nothing destructive trusts it on its own:
617            // [`Self::register`] asks [`Self::exists`] for a second, export-free
618            // opinion before it replaces anything.
619            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    /// Registers the task, replacing a previous registration of the same task.
630    ///
631    /// Idempotent: running it twice leaves one task whose definition is the
632    /// current one. `schtasks /Create … /F` is what makes the replacement
633    /// atomic from Task Scheduler's point of view — there is no window in
634    /// which the task is absent.
635    ///
636    /// # Errors
637    ///
638    /// [`WslError::ForeignTask`] when a task of this name exists and is not
639    /// this product's, or exists but cannot be exported and so cannot be shown
640    /// to be this product's; [`WslError::TaskControl`] when `schtasks` refused;
641    /// [`WslError::Record`] when the document could not be written to a
642    /// temporary file for `schtasks /XML` to read.
643    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            // `query` reads *any* `/Query /XML` failure as absence, and
660            // `/Create ... /F` replaces rather than refuses -- so a task that
661            // exists but cannot be exported would be overwritten by the very
662            // call the marker guard above exists to prevent. Ask again in the
663            // one form that answers "is there one" without an export, and
664            // refuse when the two answers disagree.
665            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    /// Removes the product's task, and nothing else.
707    ///
708    /// This is the whole of `wsl detach`'s Windows half. It does not
709    /// unregister the WSL distribution, stop or uninstall the Linux service,
710    /// remove a credential, or delete any Linux data — it cannot, because the
711    /// only program it runs is `schtasks.exe`.
712    ///
713    /// # Errors
714    ///
715    /// [`WslError::ForeignTask`] when the task is not this product's, and
716    /// [`WslError::TaskControl`] when `schtasks` refused to delete it.
717    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    /// Starts the task now, rather than at the next logon.
744    ///
745    /// # Errors
746    ///
747    /// [`WslError::NoSuchTask`] when nothing is registered,
748    /// [`WslError::ForeignTask`] when the registration is not this product's,
749    /// and [`WslError::TaskControl`] when `schtasks` refused.
750    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    /// Ends a running instance. Returns whether one was running.
760    ///
761    /// # Errors
762    ///
763    /// As [`LifecycleTaskControl::start`].
764    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    /// The plain, headerless CSV listing of one task, when Task Scheduler holds
805    /// one and answered.
806    ///
807    /// It answers from the task store rather than from an XML export, so it
808    /// still says yes for a task [`Self::query`] cannot read back. `schtasks`
809    /// failing to run at all is read as "nothing", which leaves a caller
810    /// exactly where it stood before this second opinion existed.
811    ///
812    /// One function for both callers so that the two questions asked of this
813    /// listing — is there a task, and is it running — cannot drift onto
814    /// different `schtasks` invocations.
815    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    /// Whether Task Scheduler holds anything at all under this name.
823    ///
824    /// Reads an exit status rather than a message, so it is unaffected by the
825    /// console's language.
826    fn exists(&self, identity: &LifecycleTaskIdentity) -> bool {
827        self.query_csv(identity).is_some()
828    }
829
830    /// Whether Task Scheduler reports the task as running. See
831    /// [`RegisteredTask::running`] for why this is best-effort.
832    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
864/// Writes a task document as UTF-16LE with a byte-order mark.
865///
866/// `schtasks /XML` reads its input as UTF-16 and the `<?xml … encoding
867/// ="UTF-16"?>` declaration this module writes says so; handing it UTF-8 is
868/// the one mistake that makes a perfectly good document unreadable.
869fn 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    // -- Identity ------------------------------------------------------------
901
902    #[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        // The reason the digest suffix exists. Without it these two would be
927        // one task, and the second `wsl install` would silently retarget the
928        // first distribution's keep-alive.
929        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    // -- The document --------------------------------------------------------
958
959    #[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        // The P1 the 2026-09-06 review closed: an action that composed
988        // `systemctl` and a keep-alive through shell text.
989        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        // The `&` in the distribution name survived as data, escaped in the
1002        // document and quoted in the argument string.
1003        assert!(document.contains("&amp;"), "{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        // No end to the hold, so no scheduled kill of it.
1020        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        // The whole of the "updates need no administrator" fix rests on this
1093        // being true: the document an update would write reads back as the one
1094        // that is already there.
1095        let desired = supervised(r"C:\state\bin");
1096        let registered = RegisteredTask::from_document("whatever", &desired.xml(), true);
1097        assert!(desired.is_registered_as(&registered));
1098
1099        // Task Scheduler may export the principal as a SID, and the program
1100        // in whatever case it stored.
1101        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(&registered));
1111    }
1112
1113    #[test]
1114    fn a_profile_outside_the_console_code_page_still_reads_back_as_this_task() {
1115        // `schtasks /Query /XML` writes to a pipe in the console code page, so
1116        // a character outside it comes back as something else. Every update on
1117        // such a host would otherwise look like a new document and need
1118        // administrator rights again.
1119        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(&registered));
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(&registered));
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(&registered), "{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        // Task Scheduler leaves the *trigger's* `<Enabled>` alone and turns
1208        // `<Settings><Enabled>` to `false`, and the trigger's is the first one
1209        // in the document — so reading the first would report this task as
1210        // enabled and a status line would say the keep-alive is fine.
1211        // Anchored on the newline and the settings block's indentation, so
1212        // that the trigger's own -- more deeply indented -- element is left
1213        // exactly as Task Scheduler leaves it.
1214        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    // -- The control ---------------------------------------------------------
1238
1239    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        // `/Create ... /F` replaces, so "the XML query failed" must not be
1279        // read as "the name is free": a task Task Scheduler will not export --
1280        // the hand-created keep-alive among them -- would be destroyed by the
1281        // install that the ownership marker exists to make impossible.
1282        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    // -- The document round-trips through a file -----------------------------
1463
1464    #[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        // `03-security-and-lifecycle.md` item 3 lists scheduled-task XML among
1478        // the places the credential document must be absent from. The control
1479        // is structural -- `LifecycleTask` has no field that could hold one --
1480        // and this is the test that says so about the rendered result.
1481        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        // `token` on its own is deliberately *not* in that list: Task
1500        // Scheduler's own `<LogonType>InteractiveToken</LogonType>` contains
1501        // it, so a substring test for it would fail on a document that is
1502        // exactly right. The shapes above are credential-shaped; that one is a
1503        // Windows API word.
1504        assert!(document.contains("interactivetoken"));
1505    }
1506}