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>...\runner-manager-wsl-supervisor-VERSION.exe</Command>
23//! <Arguments>...\runner-manager-wsl-VERSION.exe wsl-host supervise --distribution Ubuntu ...</Arguments>
24//! ```
25//!
26//! Task Scheduler has no `Arguments` *vector* — the element is a single string
27//! that Windows splits with `CommandLineToArgvW` — so
28//! [`LifecycleTask::action_arguments`] builds the vector and
29//! [`LifecycleTask::rendered_arguments`] quotes each element with the same
30//! function the service installer uses. A distribution called `My Ubuntu` is
31//! therefore `"My Ubuntu"` in the document and one argument again on the way
32//! out. What is *not* there is a `cmd /c`, a `&&`, a `;`, or anything else a
33//! shell would interpret, and `no_shell_text_reaches_the_task_document` is the
34//! test that keeps it that way.
35//!
36//! `wsl-host supervise` is the hidden Windows companion. It starts the
37//! Linux-only `wsl-host hold`, restarts that child when the named distribution
38//! is recovered, and owns the bounded recovery watchdog. Running in the
39//! distribution owner's interactive token is essential: an SCM service under
40//! LocalSystem cannot see another account's WSL registrations.
41//!
42//! # A task this did not create is never touched
43//!
44//! The name is derived from the distribution, so two workstations agree on it
45//! and a re-run updates the task rather than accumulating copies. That same
46//! determinism means the name could collide with something an operator made by
47//! hand — and on the target workstation there *is* a hand-created task doing
48//! this job today (`01-current-architecture.md`). So every mutating operation
49//! reads the task back first and refuses unless its description carries
50//! [`PRODUCT_MARKER`]. `wsl detach` removing somebody else's keep-alive task
51//! would be precisely the destructive behaviour the review renamed the command
52//! to avoid.
53
54use std::ffi::OsString;
55use std::path::{Path, PathBuf};
56
57use super::WslError;
58use super::discovery::{
59    decode_console_output, escaped_name_with_digest, validate_distribution_name,
60};
61use super::exec::{CommandRequest, CommandRunner};
62use super::probe::{LINUX_USER, WslExecutable, locate_in_system32};
63use crate::service::{TaskPrincipal, quote_argument, xml_escape, xml_value};
64
65/// The prefix every product-owned lifecycle task name starts with.
66pub const LIFECYCLE_TASK_PREFIX: &str = "runner-manager-wsl";
67
68/// The string that says a task is this product's.
69///
70/// It is in the task's `Description`, which Task Scheduler round-trips
71/// verbatim through `/Query /XML`, so ownership survives an export and import
72/// and does not depend on parsing the action.
73pub const PRODUCT_MARKER: &str = "runner-manager-wsl-lifecycle/v1";
74
75/// The hidden Linux command the task runs.
76///
77/// `02-target-architecture.md`: it "verifies systemd, starts the existing unit
78/// by argument-vector process execution, and then remains alive with
79/// signal-aware shutdown so WSL does not retire the distribution".
80pub const HOLD_ARGUMENTS: [&str; 2] = ["wsl-host", "hold"];
81
82// ---------------------------------------------------------------------------
83// Identity
84// ---------------------------------------------------------------------------
85
86/// The stable, per-distribution name of the product's lifecycle task.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct LifecycleTaskIdentity {
89    distribution: String,
90    name: String,
91}
92
93impl LifecycleTaskIdentity {
94    /// Derives the task identity for a distribution.
95    ///
96    /// # The name is escaped *and* hashed, and both halves are load-bearing
97    ///
98    /// See [`escaped_name_with_digest`], which is also what
99    /// [`super::record`] names its files with: escaping alone would map
100    /// `Debian GNU/Linux` and `Debian GNU:Linux` onto one task, which is two
101    /// distributions quietly sharing one keep-alive.
102    ///
103    /// # Errors
104    ///
105    /// [`WslError::InvalidName`] for a name that cannot be used at all.
106    pub fn for_distribution(distribution: &str) -> Result<Self, WslError> {
107        validate_distribution_name(distribution)?;
108        Ok(Self {
109            distribution: distribution.to_string(),
110            name: format!(
111                "{LIFECYCLE_TASK_PREFIX}-{}",
112                escaped_name_with_digest(distribution)
113            ),
114        })
115    }
116
117    /// The distribution this task keeps alive.
118    #[must_use]
119    pub fn distribution(&self) -> &str {
120        &self.distribution
121    }
122
123    /// The Task Scheduler name.
124    #[must_use]
125    pub fn name(&self) -> &str {
126        &self.name
127    }
128
129    /// The description the document carries, which also carries the marker.
130    #[must_use]
131    pub fn description(&self) -> String {
132        format!(
133            "Keeps the WSL distribution \"{}\" running so its runner-manager service can \
134             accept jobs after this account logs on. Created and owned by runner-manager \
135             ({PRODUCT_MARKER}); remove it with `runner-manager wsl detach --distribution \
136             {}`.",
137            self.distribution, self.distribution
138        )
139    }
140}
141
142// ---------------------------------------------------------------------------
143// The document
144// ---------------------------------------------------------------------------
145
146/// Everything needed to render the task.
147#[derive(Debug, Clone)]
148pub struct LifecycleTask {
149    identity: LifecycleTaskIdentity,
150    principal: TaskPrincipal,
151    wsl_executable: PathBuf,
152    linux_binary: String,
153    recovery_root: Option<PathBuf>,
154    windows_supervisor: Option<PathBuf>,
155    windows_child: Option<PathBuf>,
156}
157
158impl LifecycleTask {
159    /// Builds the task for one distribution and one Windows account.
160    #[must_use]
161    pub fn new(
162        identity: LifecycleTaskIdentity,
163        principal: TaskPrincipal,
164        wsl_executable: &WslExecutable,
165        linux_binary: impl Into<String>,
166    ) -> Self {
167        Self {
168            identity,
169            principal,
170            wsl_executable: wsl_executable.path().to_path_buf(),
171            linux_binary: linux_binary.into(),
172            recovery_root: None,
173            windows_supervisor: None,
174            windows_child: None,
175        }
176    }
177
178    /// Give the hold process the Windows directory used for recovery state.
179    #[must_use]
180    pub fn with_recovery_root(mut self, path: PathBuf) -> Self {
181        self.recovery_root = Some(path);
182        self
183    }
184
185    /// Run the keep-alive through a Windows process in the owning user's
186    /// session. That process can both hold the guest open and recover a WSL
187    /// transport which LocalSystem cannot see.
188    #[must_use]
189    pub fn with_windows_supervisor(mut self, supervisor: PathBuf, child: PathBuf) -> Self {
190        self.windows_supervisor = Some(supervisor);
191        self.windows_child = Some(child);
192        self
193    }
194
195    /// Which task this is.
196    #[must_use]
197    pub fn identity(&self) -> &LifecycleTaskIdentity {
198        &self.identity
199    }
200
201    /// The account it runs as.
202    #[must_use]
203    pub fn principal(&self) -> &TaskPrincipal {
204        &self.principal
205    }
206
207    /// The program the task starts.
208    #[must_use]
209    pub fn command(&self) -> &Path {
210        self.windows_supervisor
211            .as_deref()
212            .unwrap_or(&self.wsl_executable)
213    }
214
215    /// The action's argument **vector**.
216    ///
217    /// The same shape [`super::probe::LinuxCommand`] builds for every other
218    /// invocation, which is deliberate: the task starts the distribution the
219    /// same way the provisioning transaction does, so there is one thing to
220    /// get right rather than two.
221    #[must_use]
222    pub fn action_arguments(&self) -> Vec<String> {
223        if self.windows_supervisor.is_some() {
224            let mut argv = vec![
225                self.windows_child
226                    .as_ref()
227                    .expect("a Windows supervisor always has a child")
228                    .to_string_lossy()
229                    .into_owned(),
230                "wsl-host".to_string(),
231                "supervise".to_string(),
232                "--distribution".to_string(),
233                self.identity.distribution.clone(),
234                "--linux-binary".to_string(),
235                self.linux_binary.clone(),
236            ];
237            if let Some(root) = &self.recovery_root {
238                argv.push("--shared-root".to_string());
239                argv.push(root.to_string_lossy().into_owned());
240            }
241            return argv;
242        }
243        let mut argv = vec![
244            "--distribution".to_string(),
245            self.identity.distribution.clone(),
246            "--user".to_string(),
247            LINUX_USER.to_string(),
248            "--exec".to_string(),
249            self.linux_binary.clone(),
250        ];
251        argv.extend(
252            HOLD_ARGUMENTS
253                .iter()
254                .map(|argument| (*argument).to_string()),
255        );
256        if let Some(root) = &self.recovery_root {
257            argv.push("--shared-root".to_string());
258            argv.push(root.to_string_lossy().into_owned());
259        }
260        argv
261    }
262
263    /// The vector, quoted into the single string Task Scheduler stores.
264    #[must_use]
265    pub fn rendered_arguments(&self) -> String {
266        self.action_arguments()
267            .iter()
268            .map(|argument| quote_argument(argument))
269            .collect::<Vec<_>>()
270            .join(" ")
271    }
272
273    /// The Task Scheduler document.
274    #[must_use]
275    pub fn xml(&self) -> String {
276        let user = xml_escape(self.principal.user_id());
277        let mut out = String::new();
278        out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
279        out.push_str(
280            "<Task version=\"1.4\" \
281             xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
282        );
283        out.push_str("  <RegistrationInfo>\n");
284        out.push_str(&format!(
285            "    <Description>{}</Description>\n",
286            xml_escape(&self.identity.description())
287        ));
288        out.push_str(&format!(
289            "    <URI>\\{}</URI>\n",
290            xml_escape(self.identity.name())
291        ));
292        out.push_str("  </RegistrationInfo>\n");
293
294        out.push_str("  <Triggers>\n    <LogonTrigger>\n");
295        out.push_str("      <Enabled>true</Enabled>\n");
296        out.push_str(&format!("      <UserId>{user}</UserId>\n"));
297        out.push_str("    </LogonTrigger>\n  </Triggers>\n");
298
299        // `LeastPrivilege` is the whole of Windows' answer to "this task does
300        // not need administrator": `wsl.exe` needs no elevation to start a
301        // distribution the logged-on user owns, and the systemd unit inside it
302        // is root's business, not Windows'.
303        out.push_str("  <Principals>\n    <Principal id=\"Author\">\n");
304        out.push_str(&format!("      <UserId>{user}</UserId>\n"));
305        out.push_str("      <LogonType>InteractiveToken</LogonType>\n");
306        out.push_str("      <RunLevel>LeastPrivilege</RunLevel>\n");
307        out.push_str("    </Principal>\n  </Principals>\n");
308
309        out.push_str("  <Settings>\n");
310        // One hold process per distribution. A second would keep the same
311        // distribution alive twice and tell an operator nothing new.
312        out.push_str("    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
313        out.push_str("    <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
314        out.push_str("    <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
315        out.push_str("    <AllowHardTerminate>true</AllowHardTerminate>\n");
316        out.push_str("    <StartWhenAvailable>true</StartWhenAvailable>\n");
317        out.push_str("    <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
318        out.push_str("    <IdleSettings>\n");
319        out.push_str("      <StopOnIdleEnd>false</StopOnIdleEnd>\n");
320        out.push_str("      <RestartOnIdle>false</RestartOnIdle>\n");
321        out.push_str("    </IdleSettings>\n");
322        out.push_str("    <AllowStartOnDemand>true</AllowStartOnDemand>\n");
323        out.push_str("    <Enabled>true</Enabled>\n");
324        out.push_str("    <Hidden>false</Hidden>\n");
325        out.push_str("    <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
326        out.push_str("    <WakeToRun>false</WakeToRun>\n");
327        // The hold has no natural end, so a limit here would be a scheduled
328        // kill of the thing that keeps the distribution up.
329        out.push_str("    <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
330        out.push_str("    <Priority>7</Priority>\n");
331        out.push_str("    <RestartOnFailure>\n");
332        out.push_str("      <Interval>PT1M</Interval>\n");
333        out.push_str("      <Count>5</Count>\n");
334        out.push_str("    </RestartOnFailure>\n");
335        out.push_str("  </Settings>\n");
336
337        out.push_str("  <Actions Context=\"Author\">\n    <Exec>\n");
338        out.push_str(&format!(
339            "      <Command>{}</Command>\n",
340            xml_escape(&self.command().to_string_lossy())
341        ));
342        out.push_str(&format!(
343            "      <Arguments>{}</Arguments>\n",
344            xml_escape(&self.rendered_arguments())
345        ));
346        out.push_str("    </Exec>\n  </Actions>\n");
347        out.push_str("</Task>\n");
348        out
349    }
350}
351
352// ---------------------------------------------------------------------------
353// Reading a task back
354// ---------------------------------------------------------------------------
355
356/// What Task Scheduler says about a task that is registered.
357#[derive(Debug, Clone, PartialEq, Eq)]
358pub struct RegisteredTask {
359    name: String,
360    command: String,
361    arguments: String,
362    account: Option<String>,
363    description: String,
364    enabled: bool,
365    running: bool,
366}
367
368impl RegisteredTask {
369    /// Reads the fields this module cares about out of a `/Query /XML`
370    /// document.
371    #[must_use]
372    pub fn from_document(name: &str, document: &str, running: bool) -> Self {
373        Self {
374            name: name.to_string(),
375            command: xml_value(document, "Command").unwrap_or_default(),
376            arguments: xml_value(document, "Arguments").unwrap_or_default(),
377            account: xml_value(document, "UserId"),
378            description: xml_value(document, "Description").unwrap_or_default(),
379            enabled: task_is_enabled(document),
380            running,
381        }
382    }
383
384    /// The Task Scheduler name.
385    #[must_use]
386    pub fn name(&self) -> &str {
387        &self.name
388    }
389
390    /// The program it starts.
391    #[must_use]
392    pub fn command(&self) -> &str {
393        &self.command
394    }
395
396    /// The single argument string it stores.
397    #[must_use]
398    pub fn arguments(&self) -> &str {
399        &self.arguments
400    }
401
402    /// The account, when the document names one.
403    #[must_use]
404    pub fn account(&self) -> Option<&str> {
405        self.account.as_deref()
406    }
407
408    /// Its description.
409    #[must_use]
410    pub fn description(&self) -> &str {
411        &self.description
412    }
413
414    /// Whether it is enabled.
415    #[must_use]
416    pub fn enabled(&self) -> bool {
417        self.enabled
418    }
419
420    /// Whether Task Scheduler reports it as running.
421    ///
422    /// **Read from localised output**, exactly as
423    /// [`crate::service`]'s Windows backend reads it, and for the same reason:
424    /// `schtasks /Query /FO CSV` prints its `Status` column in the machine's
425    /// display language and there is no locale-independent equivalent short of
426    /// COM. On a non-English Windows this is `false` for a task that is in fact
427    /// running. Nothing in the provisioning transaction branches on it — the
428    /// authority for "is the Linux host healthy" is the Linux service's own
429    /// status — so it is a display value and only that.
430    #[must_use]
431    pub fn running(&self) -> bool {
432        self.running
433    }
434
435    /// Whether this product created it.
436    ///
437    /// The gate on every mutation. See the module documentation: the name is
438    /// derived, so it can collide with a hand-made task, and the marker is
439    /// what tells the two apart.
440    #[must_use]
441    pub fn is_product_owned(&self) -> bool {
442        self.description.contains(PRODUCT_MARKER)
443    }
444}
445
446/// Whether the *task* is enabled, which is not the first `<Enabled>` in the
447/// document.
448///
449/// A task has an `<Enabled>` inside its trigger and another inside its
450/// `<Settings>`, in that order, and it is the second one that Task Scheduler
451/// turns to `false` when an operator disables the task. Reading the first
452/// would report a task somebody switched off in `taskschd.msc` as enabled,
453/// which is the opposite of what a status line is for.
454///
455/// A document with no `<Settings>` at all — a hand-made task, or a fragment —
456/// is read as enabled, which is what an absent setting means to Windows.
457fn task_is_enabled(document: &str) -> bool {
458    let settings = document
459        .find("<Settings>")
460        .map_or(document, |start| &document[start..]);
461    xml_value(settings, "Enabled").as_deref() != Some("false")
462}
463
464// ---------------------------------------------------------------------------
465// The control
466// ---------------------------------------------------------------------------
467
468/// Registering, reading and removing the product's lifecycle task.
469///
470/// Everything goes through a [`CommandRunner`], so the whole of this — the
471/// argument vectors, the idempotent replacement, the foreign-task refusal and
472/// the non-destructive removal — is testable on a CI leg that has no Task
473/// Scheduler at all.
474#[derive(Debug)]
475pub struct LifecycleTaskControl<'runner> {
476    runner: &'runner dyn CommandRunner,
477    schtasks: PathBuf,
478}
479
480/// What [`LifecycleTaskControl::detach`] did, and what it deliberately did not.
481#[derive(Debug, Clone, PartialEq, Eq)]
482pub struct Detached {
483    /// Whether there was a task to remove.
484    pub removed: bool,
485    /// The task's name, whether or not it was there.
486    pub name: String,
487}
488
489impl<'runner> LifecycleTaskControl<'runner> {
490    /// Uses the host's `schtasks.exe`.
491    #[must_use]
492    pub fn new(runner: &'runner dyn CommandRunner) -> Self {
493        Self {
494            runner,
495            schtasks: locate_in_system32("schtasks.exe"),
496        }
497    }
498
499    /// Uses a named `schtasks.exe`, for a test.
500    #[must_use]
501    pub fn with_executable(
502        runner: &'runner dyn CommandRunner,
503        schtasks: impl Into<PathBuf>,
504    ) -> Self {
505        Self {
506            runner,
507            schtasks: schtasks.into(),
508        }
509    }
510
511    /// What Task Scheduler holds under this name, if anything.
512    ///
513    /// # Errors
514    ///
515    /// [`WslError::Spawn`] when `schtasks.exe` cannot be started at all.
516    pub fn query(
517        &self,
518        identity: &LifecycleTaskIdentity,
519    ) -> Result<Option<RegisteredTask>, WslError> {
520        let output = self.schtasks(&["/Query", "/TN", identity.name(), "/XML", "ONE"])?;
521        if !output.success() {
522            // `schtasks` reports "no such task" and "Task Scheduler is broken"
523            // with the same non-zero exit and no distinct code, and the
524            // sentence that would tell them apart is localised. Reading it as
525            // absence is what an operator with no task should see -- but it is
526            // only safe because nothing destructive trusts it on its own:
527            // [`Self::register`] asks [`Self::exists`] for a second, export-free
528            // opinion before it replaces anything.
529            return Ok(None);
530        }
531        let document = decode_console_output(output.stdout()).into_text();
532        Ok(Some(RegisteredTask::from_document(
533            identity.name(),
534            &document,
535            self.is_running(identity),
536        )))
537    }
538
539    /// Registers the task, replacing a previous registration of the same task.
540    ///
541    /// Idempotent: running it twice leaves one task whose definition is the
542    /// current one. `schtasks /Create … /F` is what makes the replacement
543    /// atomic from Task Scheduler's point of view — there is no window in
544    /// which the task is absent.
545    ///
546    /// # Errors
547    ///
548    /// [`WslError::ForeignTask`] when a task of this name exists and is not
549    /// this product's, or exists but cannot be exported and so cannot be shown
550    /// to be this product's; [`WslError::TaskControl`] when `schtasks` refused;
551    /// [`WslError::Record`] when the document could not be written to a
552    /// temporary file for `schtasks /XML` to read.
553    pub fn register(&self, task: &LifecycleTask) -> Result<(), WslError> {
554        let identity = task.identity();
555        match self.query(identity)? {
556            Some(existing) if !existing.is_product_owned() => {
557                return Err(WslError::ForeignTask {
558                    name: identity.name().to_string(),
559                    detail: format!(
560                        "a task of this name already exists, its description does not identify \
561                         it as this product's ({PRODUCT_MARKER}), and it starts `{}`. Rename or \
562                         remove it yourself if it is the hand-created keep-alive this feature \
563                         replaces.",
564                        existing.command()
565                    ),
566                });
567            }
568            Some(_) => {}
569            // `query` reads *any* `/Query /XML` failure as absence, and
570            // `/Create ... /F` replaces rather than refuses -- so a task that
571            // exists but cannot be exported would be overwritten by the very
572            // call the marker guard above exists to prevent. Ask again in the
573            // one form that answers "is there one" without an export, and
574            // refuse when the two answers disagree.
575            None if self.exists(identity) => {
576                return Err(WslError::ForeignTask {
577                    name: identity.name().to_string(),
578                    detail: format!(
579                        "a task of this name exists but Task Scheduler would not export its \
580                         definition, so it cannot be shown to be this product's \
581                         ({PRODUCT_MARKER}) and registering would replace it. Inspect it in \
582                         `taskschd.msc`, and rename or remove it yourself if it is the \
583                         hand-created keep-alive this feature replaces."
584                    ),
585                });
586            }
587            None => {}
588        }
589
590        let directory = tempfile::tempdir().map_err(|error| WslError::Record {
591            operation: "write",
592            path: PathBuf::from("<the scheduled-task document>"),
593            detail: error.to_string(),
594        })?;
595        let document = directory.path().join("task.xml");
596        write_utf16(&document, &task.xml()).map_err(|error| WslError::Record {
597            operation: "write",
598            path: document.clone(),
599            detail: error.to_string(),
600        })?;
601
602        let output = self.schtasks(&[
603            "/Create",
604            "/TN",
605            identity.name(),
606            "/XML",
607            &document.to_string_lossy(),
608            "/F",
609        ])?;
610        if !output.success() {
611            return Err(self.task_error("register", identity.name(), &output.diagnostic()));
612        }
613        Ok(())
614    }
615
616    /// Removes the product's task, and nothing else.
617    ///
618    /// This is the whole of `wsl detach`'s Windows half. It does not
619    /// unregister the WSL distribution, stop or uninstall the Linux service,
620    /// remove a credential, or delete any Linux data — it cannot, because the
621    /// only program it runs is `schtasks.exe`.
622    ///
623    /// # Errors
624    ///
625    /// [`WslError::ForeignTask`] when the task is not this product's, and
626    /// [`WslError::TaskControl`] when `schtasks` refused to delete it.
627    pub fn detach(&self, identity: &LifecycleTaskIdentity) -> Result<Detached, WslError> {
628        let Some(existing) = self.query(identity)? else {
629            return Ok(Detached {
630                removed: false,
631                name: identity.name().to_string(),
632            });
633        };
634        if !existing.is_product_owned() {
635            return Err(WslError::ForeignTask {
636                name: identity.name().to_string(),
637                detail: format!(
638                    "a task of this name exists but its description does not identify it as \
639                     this product's ({PRODUCT_MARKER}), so `detach` will not remove it."
640                ),
641            });
642        }
643        let output = self.schtasks(&["/Delete", "/TN", identity.name(), "/F"])?;
644        if !output.success() {
645            return Err(self.task_error("remove", identity.name(), &output.diagnostic()));
646        }
647        Ok(Detached {
648            removed: true,
649            name: identity.name().to_string(),
650        })
651    }
652
653    /// Starts the task now, rather than at the next logon.
654    ///
655    /// # Errors
656    ///
657    /// [`WslError::NoSuchTask`] when nothing is registered,
658    /// [`WslError::ForeignTask`] when the registration is not this product's,
659    /// and [`WslError::TaskControl`] when `schtasks` refused.
660    pub fn start(&self, identity: &LifecycleTaskIdentity) -> Result<(), WslError> {
661        self.require_ours("start", identity)?;
662        let output = self.schtasks(&["/Run", "/TN", identity.name()])?;
663        if !output.success() {
664            return Err(self.task_error("start", identity.name(), &output.diagnostic()));
665        }
666        Ok(())
667    }
668
669    /// Ends a running instance. Returns whether one was running.
670    ///
671    /// # Errors
672    ///
673    /// As [`LifecycleTaskControl::start`].
674    pub fn stop(&self, identity: &LifecycleTaskIdentity) -> Result<bool, WslError> {
675        let existing = self.require_ours("stop", identity)?;
676        if !existing.running() {
677            return Ok(false);
678        }
679        let output = self.schtasks(&["/End", "/TN", identity.name()])?;
680        if !output.success() {
681            return Err(self.task_error("stop", identity.name(), &output.diagnostic()));
682        }
683        Ok(true)
684    }
685
686    fn require_ours(
687        &self,
688        operation: &'static str,
689        identity: &LifecycleTaskIdentity,
690    ) -> Result<RegisteredTask, WslError> {
691        let Some(existing) = self.query(identity)? else {
692            return Err(WslError::NoSuchTask {
693                name: identity.name().to_string(),
694            });
695        };
696        if !existing.is_product_owned() {
697            return Err(WslError::ForeignTask {
698                name: identity.name().to_string(),
699                detail: format!(
700                    "a task of this name exists but is not this product's ({PRODUCT_MARKER}), \
701                     so it will not be used to {operation} anything."
702                ),
703            });
704        }
705        Ok(existing)
706    }
707
708    fn schtasks(&self, arguments: &[&str]) -> Result<super::exec::CommandOutput, WslError> {
709        let request =
710            CommandRequest::new(&self.schtasks).args(arguments.iter().map(OsString::from));
711        self.runner.run(&request)
712    }
713
714    /// The plain, headerless CSV listing of one task, when Task Scheduler holds
715    /// one and answered.
716    ///
717    /// It answers from the task store rather than from an XML export, so it
718    /// still says yes for a task [`Self::query`] cannot read back. `schtasks`
719    /// failing to run at all is read as "nothing", which leaves a caller
720    /// exactly where it stood before this second opinion existed.
721    ///
722    /// One function for both callers so that the two questions asked of this
723    /// listing — is there a task, and is it running — cannot drift onto
724    /// different `schtasks` invocations.
725    fn query_csv(&self, identity: &LifecycleTaskIdentity) -> Option<super::exec::CommandOutput> {
726        let output = self
727            .schtasks(&["/Query", "/TN", identity.name(), "/FO", "CSV", "/NH"])
728            .ok()?;
729        output.success().then_some(output)
730    }
731
732    /// Whether Task Scheduler holds anything at all under this name.
733    ///
734    /// Reads an exit status rather than a message, so it is unaffected by the
735    /// console's language.
736    fn exists(&self, identity: &LifecycleTaskIdentity) -> bool {
737        self.query_csv(identity).is_some()
738    }
739
740    /// Whether Task Scheduler reports the task as running. See
741    /// [`RegisteredTask::running`] for why this is best-effort.
742    fn is_running(&self, identity: &LifecycleTaskIdentity) -> bool {
743        let Some(output) = self.query_csv(identity) else {
744            return false;
745        };
746        decode_console_output(output.stdout())
747            .into_text()
748            .lines()
749            .filter_map(|line| line.rsplit(',').next())
750            .any(|status| {
751                status
752                    .trim()
753                    .trim_matches('"')
754                    .eq_ignore_ascii_case("running")
755            })
756    }
757
758    fn task_error(&self, operation: &'static str, name: &str, detail: &str) -> WslError {
759        if detail.to_ascii_lowercase().contains("access is denied") {
760            return WslError::NeedsElevation {
761                operation,
762                name: name.to_string(),
763                detail: detail.to_string(),
764            };
765        }
766        WslError::TaskControl {
767            operation,
768            name: name.to_string(),
769            detail: detail.to_string(),
770        }
771    }
772}
773
774/// Writes a task document as UTF-16LE with a byte-order mark.
775///
776/// `schtasks /XML` reads its input as UTF-16 and the `<?xml … encoding
777/// ="UTF-16"?>` declaration this module writes says so; handing it UTF-8 is
778/// the one mistake that makes a perfectly good document unreadable.
779fn write_utf16(path: &Path, text: &str) -> std::io::Result<()> {
780    let mut bytes = vec![0xFF, 0xFE];
781    for unit in text.encode_utf16() {
782        bytes.extend_from_slice(&unit.to_le_bytes());
783    }
784    std::fs::write(path, bytes)
785}
786
787#[cfg(test)]
788mod tests {
789    use super::*;
790    use crate::wsl::discovery::{DIGEST_SUFFIX_LENGTH, ESCAPED_NAME_BUDGET};
791    use crate::wsl::exec::{CommandOutput, ScriptedRunner};
792
793    fn identity(distribution: &str) -> LifecycleTaskIdentity {
794        LifecycleTaskIdentity::for_distribution(distribution).expect("a usable name")
795    }
796
797    fn task(distribution: &str) -> LifecycleTask {
798        LifecycleTask::new(
799            identity(distribution),
800            TaskPrincipal::named("IVANPC\\IvanD"),
801            &WslExecutable::at("C:\\Windows\\System32\\wsl.exe"),
802            "/usr/local/bin/runner-manager",
803        )
804    }
805
806    fn registered_document(distribution: &str) -> CommandOutput {
807        CommandOutput::exited(0, task(distribution).xml(), "")
808    }
809
810    // -- Identity ------------------------------------------------------------
811
812    #[test]
813    fn the_task_name_is_stable_for_a_distribution() {
814        assert_eq!(identity("Ubuntu").name(), identity("Ubuntu").name());
815        assert!(
816            identity("Ubuntu")
817                .name()
818                .starts_with("runner-manager-wsl-Ubuntu-")
819        );
820    }
821
822    #[test]
823    fn a_name_task_scheduler_could_not_hold_is_escaped_into_one_that_it_can() {
824        let name = identity("Debian GNU/Linux 12").name().to_string();
825        for forbidden in ['\\', '/', ':', '*', '?', '"', '<', '>', '|'] {
826            assert!(
827                !name.contains(forbidden),
828                "{name} still contains {forbidden:?}"
829            );
830        }
831        assert!(name.contains("Debian_GNU_Linux_12"), "{name}");
832    }
833
834    #[test]
835    fn two_distributions_that_escape_alike_still_get_different_tasks() {
836        // The reason the digest suffix exists. Without it these two would be
837        // one task, and the second `wsl install` would silently retarget the
838        // first distribution's keep-alive.
839        let first = identity("Debian GNU/Linux");
840        let second = identity("Debian GNU:Linux");
841        assert_ne!(first.name(), second.name());
842        assert!(first.name().contains("Debian_GNU_Linux"));
843        assert!(second.name().contains("Debian_GNU_Linux"));
844    }
845
846    #[test]
847    fn a_very_long_name_is_bounded_and_still_unique() {
848        let long = "u".repeat(200);
849        let other = format!("{long}x");
850        let first = identity(&long);
851        let second = identity(&other);
852        assert_ne!(first.name(), second.name());
853        assert!(
854            first.name().len()
855                <= LIFECYCLE_TASK_PREFIX.len() + 1 + ESCAPED_NAME_BUDGET + 1 + DIGEST_SUFFIX_LENGTH,
856            "{}",
857            first.name()
858        );
859    }
860
861    #[test]
862    fn a_distribution_name_that_is_not_usable_never_becomes_a_task_name() {
863        assert!(LifecycleTaskIdentity::for_distribution("--shutdown").is_err());
864        assert!(LifecycleTaskIdentity::for_distribution("").is_err());
865    }
866
867    // -- The document --------------------------------------------------------
868
869    #[test]
870    fn the_action_is_the_documented_argument_vector() {
871        assert_eq!(
872            task("Ubuntu").action_arguments(),
873            vec![
874                "--distribution",
875                "Ubuntu",
876                "--user",
877                "root",
878                "--exec",
879                "/usr/local/bin/runner-manager",
880                "wsl-host",
881                "hold",
882            ]
883        );
884    }
885
886    #[test]
887    fn a_name_with_spaces_is_quoted_so_windows_splits_it_back_into_one_argument() {
888        let rendered = task("My Ubuntu").rendered_arguments();
889        assert!(
890            rendered.contains("--distribution \"My Ubuntu\" --user root"),
891            "{rendered}"
892        );
893    }
894
895    #[test]
896    fn no_shell_text_reaches_the_task_document() {
897        // The P1 the 2026-09-06 review closed: an action that composed
898        // `systemctl` and a keep-alive through shell text.
899        let document = task("Ubuntu & echo pwned").xml();
900        let arguments = xml_value(&document, "Arguments").expect("the document has an action");
901        for shell in ["cmd", "powershell", "/c", "&&", "||", ";", "$(", "`"] {
902            assert!(
903                !arguments.contains(shell),
904                "the rendered arguments contain shell text {shell:?}: {arguments}"
905            );
906        }
907        assert_eq!(
908            xml_value(&document, "Command").as_deref(),
909            Some("C:\\Windows\\System32\\wsl.exe")
910        );
911        // The `&` in the distribution name survived as data, escaped in the
912        // document and quoted in the argument string.
913        assert!(document.contains("&amp;"), "{document}");
914        assert!(arguments.contains("\"Ubuntu & echo pwned\""), "{arguments}");
915    }
916
917    #[test]
918    fn the_document_is_a_least_privilege_logon_task_for_the_named_principal() {
919        let document = task("Ubuntu").xml();
920        assert!(document.contains("<LogonTrigger>"), "{document}");
921        assert!(
922            document.contains("<RunLevel>LeastPrivilege</RunLevel>"),
923            "{document}"
924        );
925        assert!(
926            document.contains("<UserId>IVANPC\\IvanD</UserId>"),
927            "{document}"
928        );
929        // No end to the hold, so no scheduled kill of it.
930        assert!(document.contains("<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>"));
931    }
932
933    #[test]
934    fn the_document_carries_the_ownership_marker_and_names_the_distribution() {
935        let document = task("Ubuntu").xml();
936        let description = xml_value(&document, "Description").expect("a description");
937        assert!(description.contains(PRODUCT_MARKER), "{description}");
938        assert!(description.contains("Ubuntu"), "{description}");
939        assert!(description.contains("wsl detach"), "{description}");
940    }
941
942    #[test]
943    fn a_rendered_document_reads_back_as_this_products_task() {
944        let document = task("Ubuntu").xml();
945        let read = RegisteredTask::from_document("whatever", &document, false);
946        assert!(read.is_product_owned());
947        assert_eq!(read.account(), Some("IVANPC\\IvanD"));
948        assert!(read.enabled());
949        assert!(
950            read.arguments().contains("wsl-host hold"),
951            "{}",
952            read.arguments()
953        );
954    }
955
956    #[test]
957    fn a_supervised_task_runs_in_windows_and_carries_the_exact_guest_identity() {
958        let supervised = task("Ubuntu")
959            .with_recovery_root(PathBuf::from(r"C:\state\wsl-recovery\Ubuntu"))
960            .with_windows_supervisor(
961                PathBuf::from(r"C:\runner-manager-supervisor.exe"),
962                PathBuf::from(r"C:\runner-manager.exe"),
963            );
964        assert_eq!(
965            supervised.command(),
966            Path::new(r"C:\runner-manager-supervisor.exe")
967        );
968        assert_eq!(
969            supervised.action_arguments(),
970            vec![
971                r"C:\runner-manager.exe",
972                "wsl-host",
973                "supervise",
974                "--distribution",
975                "Ubuntu",
976                "--linux-binary",
977                "/usr/local/bin/runner-manager",
978                "--shared-root",
979                r"C:\state\wsl-recovery\Ubuntu",
980            ]
981        );
982        let document = supervised.xml();
983        assert!(
984            document.contains(r"C:\runner-manager-supervisor.exe"),
985            "{document}"
986        );
987        assert!(document.contains("wsl-host supervise"), "{document}");
988        assert!(!document.contains("wsl.exe</Command>"), "{document}");
989    }
990
991    #[test]
992    fn a_task_an_operator_disabled_is_reported_as_disabled() {
993        // Task Scheduler leaves the *trigger's* `<Enabled>` alone and turns
994        // `<Settings><Enabled>` to `false`, and the trigger's is the first one
995        // in the document — so reading the first would report this task as
996        // enabled and a status line would say the keep-alive is fine.
997        // Anchored on the newline and the settings block's indentation, so
998        // that the trigger's own -- more deeply indented -- element is left
999        // exactly as Task Scheduler leaves it.
1000        let disabled = task("Ubuntu").xml().replace(
1001            "\n    <Enabled>true</Enabled>\n",
1002            "\n    <Enabled>false</Enabled>\n",
1003        );
1004        assert!(
1005            disabled.contains("      <Enabled>true</Enabled>"),
1006            "the trigger's own <Enabled> must still be true for this to prove anything"
1007        );
1008        assert!(!RegisteredTask::from_document("whatever", &disabled, false).enabled());
1009        assert!(RegisteredTask::from_document("whatever", &task("Ubuntu").xml(), false).enabled());
1010    }
1011
1012    #[test]
1013    fn a_task_this_product_did_not_write_is_not_product_owned() {
1014        let hand_made = concat!(
1015            "<Task><RegistrationInfo><Description>GitHub Actions Linux Runner - Ubuntu WSL",
1016            "</Description></RegistrationInfo><Actions><Exec><Command>wsl.exe</Command>",
1017            "<Arguments>-d Ubuntu -u root /bin/sleep infinity</Arguments></Exec></Actions></Task>",
1018        );
1019        let read = RegisteredTask::from_document("whatever", hand_made, false);
1020        assert!(!read.is_product_owned());
1021    }
1022
1023    // -- The control ---------------------------------------------------------
1024
1025    fn control(runner: &ScriptedRunner) -> LifecycleTaskControl<'_> {
1026        LifecycleTaskControl::with_executable(runner, "schtasks.exe")
1027    }
1028
1029    #[test]
1030    fn registering_writes_a_utf16_document_and_replaces_in_place() {
1031        let runner = ScriptedRunner::new().always("/Query", CommandOutput::exited(1, "", ""));
1032        let task = task("Ubuntu");
1033        control(&runner).register(&task).expect("registered");
1034
1035        let create = runner
1036            .recorded()
1037            .into_iter()
1038            .find(|request| request.arguments.first().map(String::as_str) == Some("/Create"))
1039            .expect("a /Create call");
1040        assert_eq!(create.arguments[1], "/TN");
1041        assert_eq!(create.arguments[2], task.identity().name());
1042        assert_eq!(create.arguments[3], "/XML");
1043        assert_eq!(
1044            create.arguments[5], "/F",
1045            "without /F a second `wsl install` fails instead of updating the task"
1046        );
1047    }
1048
1049    #[test]
1050    fn registering_over_this_products_own_task_is_allowed_and_idempotent() {
1051        let runner = ScriptedRunner::new()
1052            .always("/Query", registered_document("Ubuntu"))
1053            .always("/Create", CommandOutput::exited(0, "SUCCESS", ""));
1054        control(&runner)
1055            .register(&task("Ubuntu"))
1056            .expect("replaced");
1057        control(&runner)
1058            .register(&task("Ubuntu"))
1059            .expect("replaced again");
1060    }
1061
1062    #[test]
1063    fn registering_over_a_task_that_cannot_be_exported_refuses_and_changes_nothing() {
1064        // `/Create ... /F` replaces, so "the XML query failed" must not be
1065        // read as "the name is free": a task Task Scheduler will not export --
1066        // the hand-created keep-alive among them -- would be destroyed by the
1067        // install that the ownership marker exists to make impossible.
1068        let runner = ScriptedRunner::new()
1069            .always(
1070                "/XML",
1071                CommandOutput::exited(1, "", "the task image is corrupt"),
1072            )
1073            .always(
1074                "/FO",
1075                CommandOutput::exited(0, "\"whatever\",\"N/A\",\"Ready\"", ""),
1076            );
1077        let error = control(&runner)
1078            .register(&task("Ubuntu"))
1079            .expect_err("an unexportable task is not a free name");
1080        assert!(matches!(error, WslError::ForeignTask { .. }), "{error:?}");
1081        assert!(
1082            runner
1083                .command_lines()
1084                .iter()
1085                .all(|line| !line.contains("/Create")),
1086            "nothing may be written: {:?}",
1087            runner.command_lines()
1088        );
1089    }
1090
1091    #[test]
1092    fn registering_over_a_foreign_task_refuses_and_changes_nothing() {
1093        let hand_made = CommandOutput::exited(
1094            0,
1095            concat!(
1096                "<Task><RegistrationInfo><Description>GitHub Actions Linux Runner - Ubuntu WSL",
1097                "</Description></RegistrationInfo><Actions><Exec><Command>wsl.exe</Command>",
1098                "</Exec></Actions></Task>",
1099            ),
1100            "",
1101        );
1102        let runner = ScriptedRunner::new().always("/Query", hand_made);
1103        let error = control(&runner)
1104            .register(&task("Ubuntu"))
1105            .expect_err("not ours");
1106        assert!(matches!(error, WslError::ForeignTask { .. }), "{error:?}");
1107        assert!(
1108            runner
1109                .command_lines()
1110                .iter()
1111                .all(|line| !line.contains("/Create")),
1112            "nothing may be written: {:?}",
1113            runner.command_lines()
1114        );
1115    }
1116
1117    #[test]
1118    fn detach_removes_only_the_product_task_and_runs_nothing_else() {
1119        let runner = ScriptedRunner::new()
1120            .always("/Query", registered_document("Ubuntu"))
1121            .always("/Delete", CommandOutput::exited(0, "SUCCESS", ""));
1122        let detached = control(&runner)
1123            .detach(&identity("Ubuntu"))
1124            .expect("detached");
1125        assert!(detached.removed);
1126
1127        for request in runner.recorded() {
1128            assert_eq!(
1129                request.program.to_string_lossy(),
1130                "schtasks.exe",
1131                "detach must not run anything but Task Scheduler: {request:?}"
1132            );
1133        }
1134        let lines = runner.command_lines();
1135        assert!(
1136            lines.iter().all(|line| !line.contains("wsl.exe")),
1137            "detach must not reach into the distribution: {lines:?}"
1138        );
1139        assert!(
1140            lines
1141                .iter()
1142                .all(|line| !line.contains("--unregister") && !line.contains("systemctl")),
1143            "detach must not unregister WSL or touch the Linux service: {lines:?}"
1144        );
1145    }
1146
1147    #[test]
1148    fn detach_without_a_task_is_not_an_error() {
1149        let runner = ScriptedRunner::new().always("/Query", CommandOutput::exited(1, "", ""));
1150        let detached = control(&runner)
1151            .detach(&identity("Ubuntu"))
1152            .expect("nothing to remove");
1153        assert!(!detached.removed);
1154        assert!(
1155            runner
1156                .command_lines()
1157                .iter()
1158                .all(|line| !line.contains("/Delete"))
1159        );
1160    }
1161
1162    #[test]
1163    fn detach_refuses_a_foreign_task_rather_than_deleting_it() {
1164        let runner = ScriptedRunner::new().always(
1165            "/Query",
1166            CommandOutput::exited(
1167                0,
1168                "<Task><RegistrationInfo><Description>Somebody else's task</Description>\
1169                 </RegistrationInfo></Task>",
1170                "",
1171            ),
1172        );
1173        let error = control(&runner)
1174            .detach(&identity("Ubuntu"))
1175            .expect_err("not ours");
1176        assert!(matches!(error, WslError::ForeignTask { .. }), "{error:?}");
1177        assert!(
1178            runner
1179                .command_lines()
1180                .iter()
1181                .all(|line| !line.contains("/Delete")),
1182            "a task this product does not own must not be deleted"
1183        );
1184    }
1185
1186    #[test]
1187    fn access_denied_is_reported_as_needing_elevation_rather_than_as_a_generic_failure() {
1188        let runner = ScriptedRunner::new()
1189            .always("/Query", CommandOutput::exited(1, "", ""))
1190            .always(
1191                "/Create",
1192                CommandOutput::exited(1, "", "ERROR: Access is denied.\n"),
1193            );
1194        let error = control(&runner)
1195            .register(&task("Ubuntu"))
1196            .expect_err("denied");
1197        assert!(
1198            matches!(error, WslError::NeedsElevation { .. }),
1199            "{error:?}"
1200        );
1201    }
1202
1203    #[test]
1204    fn starting_a_task_that_is_not_registered_says_so() {
1205        let runner = ScriptedRunner::new().always("/Query", CommandOutput::exited(1, "", ""));
1206        let error = control(&runner)
1207            .start(&identity("Ubuntu"))
1208            .expect_err("not registered");
1209        assert!(matches!(error, WslError::NoSuchTask { .. }), "{error:?}");
1210    }
1211
1212    #[test]
1213    fn a_query_reads_a_utf16_document_as_schtasks_really_writes_it() {
1214        let mut bytes = vec![0xFF, 0xFE];
1215        for unit in task("Ubuntu").xml().encode_utf16() {
1216            bytes.extend_from_slice(&unit.to_le_bytes());
1217        }
1218        let runner = ScriptedRunner::new()
1219            .always("/XML ONE", CommandOutput::exited(0, bytes, ""))
1220            .always(
1221                "/FO CSV",
1222                CommandOutput::exited(0, "\"task\",\"N/A\",\"Ready\"\n", ""),
1223            );
1224        let found = control(&runner)
1225            .query(&identity("Ubuntu"))
1226            .expect("queried")
1227            .expect("registered");
1228        assert!(found.is_product_owned());
1229        assert!(!found.running());
1230        assert!(found.arguments().contains("wsl-host hold"));
1231    }
1232
1233    #[test]
1234    fn a_running_task_is_reported_from_the_csv_status_column() {
1235        let runner = ScriptedRunner::new()
1236            .always("/XML ONE", registered_document("Ubuntu"))
1237            .always(
1238                "/FO CSV",
1239                CommandOutput::exited(0, "\"\\task\",\"N/A\",\"Running\"\n", ""),
1240            );
1241        let found = control(&runner)
1242            .query(&identity("Ubuntu"))
1243            .expect("queried")
1244            .expect("registered");
1245        assert!(found.running());
1246    }
1247
1248    // -- The document round-trips through a file -----------------------------
1249
1250    #[test]
1251    fn the_document_is_written_as_utf16_little_endian_with_a_byte_order_mark() {
1252        let directory = tempfile::tempdir().expect("a temporary directory");
1253        let path = directory.path().join("task.xml");
1254        write_utf16(&path, &task("Ubuntu").xml()).expect("written");
1255        let bytes = std::fs::read(&path).expect("readable");
1256        assert_eq!(&bytes[..2], &[0xFF, 0xFE]);
1257        let decoded = decode_console_output(&bytes);
1258        assert_eq!(decoded.text(), task("Ubuntu").xml());
1259    }
1260
1261    #[test]
1262    fn no_credential_shaped_value_can_reach_the_document() {
1263        // `03-security-and-lifecycle.md` item 3 lists scheduled-task XML among
1264        // the places the credential document must be absent from. The control
1265        // is structural -- `LifecycleTask` has no field that could hold one --
1266        // and this is the test that says so about the rendered result.
1267        let document = task("Ubuntu").xml().to_ascii_lowercase();
1268        for shape in [
1269            "ghu_",
1270            "ghs_",
1271            "gho_",
1272            "github_pat_",
1273            "access_token",
1274            "refresh_token",
1275            "jitconfig",
1276            "secret",
1277            "password",
1278            "credential",
1279        ] {
1280            assert!(
1281                !document.contains(shape),
1282                "the task document mentions {shape:?}: {document}"
1283            );
1284        }
1285        // `token` on its own is deliberately *not* in that list: Task
1286        // Scheduler's own `<LogonType>InteractiveToken</LogonType>` contains
1287        // it, so a substring test for it would fail on a document that is
1288        // exactly right. The shapes above are credential-shaped; that one is a
1289        // Windows API word.
1290        assert!(document.contains("interactivetoken"));
1291    }
1292}