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