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