use std::ffi::OsString;
use std::path::{Path, PathBuf};
use super::WslError;
use super::discovery::{
decode_console_output, escaped_name_with_digest, validate_distribution_name,
};
use super::exec::{CommandRequest, CommandRunner};
use super::probe::{LINUX_USER, WslExecutable, locate_in_system32};
use crate::service::{TaskPrincipal, quote_argument, xml_escape, xml_value};
pub const LIFECYCLE_TASK_PREFIX: &str = "runner-manager-wsl";
pub const PRODUCT_MARKER: &str = "runner-manager-wsl-lifecycle/v1";
pub const HOLD_ARGUMENTS: [&str; 2] = ["wsl-host", "hold"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LifecycleTaskIdentity {
distribution: String,
name: String,
}
impl LifecycleTaskIdentity {
pub fn for_distribution(distribution: &str) -> Result<Self, WslError> {
validate_distribution_name(distribution)?;
Ok(Self {
distribution: distribution.to_string(),
name: format!(
"{LIFECYCLE_TASK_PREFIX}-{}",
escaped_name_with_digest(distribution)
),
})
}
#[must_use]
pub fn distribution(&self) -> &str {
&self.distribution
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn description(&self) -> String {
format!(
"Keeps the WSL distribution \"{}\" running so its runner-manager service can \
accept jobs after this account logs on. Created and owned by runner-manager \
({PRODUCT_MARKER}); remove it with `runner-manager wsl detach --distribution \
{}`.",
self.distribution, self.distribution
)
}
}
#[derive(Debug, Clone)]
pub struct LifecycleTask {
identity: LifecycleTaskIdentity,
principal: TaskPrincipal,
wsl_executable: PathBuf,
linux_binary: String,
}
impl LifecycleTask {
#[must_use]
pub fn new(
identity: LifecycleTaskIdentity,
principal: TaskPrincipal,
wsl_executable: &WslExecutable,
linux_binary: impl Into<String>,
) -> Self {
Self {
identity,
principal,
wsl_executable: wsl_executable.path().to_path_buf(),
linux_binary: linux_binary.into(),
}
}
#[must_use]
pub fn identity(&self) -> &LifecycleTaskIdentity {
&self.identity
}
#[must_use]
pub fn principal(&self) -> &TaskPrincipal {
&self.principal
}
#[must_use]
pub fn command(&self) -> &Path {
&self.wsl_executable
}
#[must_use]
pub fn action_arguments(&self) -> Vec<String> {
let mut argv = vec![
"--distribution".to_string(),
self.identity.distribution.clone(),
"--user".to_string(),
LINUX_USER.to_string(),
"--exec".to_string(),
self.linux_binary.clone(),
];
argv.extend(
HOLD_ARGUMENTS
.iter()
.map(|argument| (*argument).to_string()),
);
argv
}
#[must_use]
pub fn rendered_arguments(&self) -> String {
self.action_arguments()
.iter()
.map(|argument| quote_argument(argument))
.collect::<Vec<_>>()
.join(" ")
}
#[must_use]
pub fn xml(&self) -> String {
let user = xml_escape(self.principal.user_id());
let mut out = String::new();
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
out.push_str(
"<Task version=\"1.4\" \
xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
);
out.push_str(" <RegistrationInfo>\n");
out.push_str(&format!(
" <Description>{}</Description>\n",
xml_escape(&self.identity.description())
));
out.push_str(&format!(
" <URI>\\{}</URI>\n",
xml_escape(self.identity.name())
));
out.push_str(" </RegistrationInfo>\n");
out.push_str(" <Triggers>\n <LogonTrigger>\n");
out.push_str(" <Enabled>true</Enabled>\n");
out.push_str(&format!(" <UserId>{user}</UserId>\n"));
out.push_str(" </LogonTrigger>\n </Triggers>\n");
out.push_str(" <Principals>\n <Principal id=\"Author\">\n");
out.push_str(&format!(" <UserId>{user}</UserId>\n"));
out.push_str(" <LogonType>InteractiveToken</LogonType>\n");
out.push_str(" <RunLevel>LeastPrivilege</RunLevel>\n");
out.push_str(" </Principal>\n </Principals>\n");
out.push_str(" <Settings>\n");
out.push_str(" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
out.push_str(" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
out.push_str(" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
out.push_str(" <AllowHardTerminate>true</AllowHardTerminate>\n");
out.push_str(" <StartWhenAvailable>true</StartWhenAvailable>\n");
out.push_str(" <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
out.push_str(" <IdleSettings>\n");
out.push_str(" <StopOnIdleEnd>false</StopOnIdleEnd>\n");
out.push_str(" <RestartOnIdle>false</RestartOnIdle>\n");
out.push_str(" </IdleSettings>\n");
out.push_str(" <AllowStartOnDemand>true</AllowStartOnDemand>\n");
out.push_str(" <Enabled>true</Enabled>\n");
out.push_str(" <Hidden>false</Hidden>\n");
out.push_str(" <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
out.push_str(" <WakeToRun>false</WakeToRun>\n");
out.push_str(" <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
out.push_str(" <Priority>7</Priority>\n");
out.push_str(" <RestartOnFailure>\n");
out.push_str(" <Interval>PT1M</Interval>\n");
out.push_str(" <Count>5</Count>\n");
out.push_str(" </RestartOnFailure>\n");
out.push_str(" </Settings>\n");
out.push_str(" <Actions Context=\"Author\">\n <Exec>\n");
out.push_str(&format!(
" <Command>{}</Command>\n",
xml_escape(&self.wsl_executable.to_string_lossy())
));
out.push_str(&format!(
" <Arguments>{}</Arguments>\n",
xml_escape(&self.rendered_arguments())
));
out.push_str(" </Exec>\n </Actions>\n");
out.push_str("</Task>\n");
out
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RegisteredTask {
name: String,
command: String,
arguments: String,
account: Option<String>,
description: String,
enabled: bool,
running: bool,
}
impl RegisteredTask {
#[must_use]
pub fn from_document(name: &str, document: &str, running: bool) -> Self {
Self {
name: name.to_string(),
command: xml_value(document, "Command").unwrap_or_default(),
arguments: xml_value(document, "Arguments").unwrap_or_default(),
account: xml_value(document, "UserId"),
description: xml_value(document, "Description").unwrap_or_default(),
enabled: task_is_enabled(document),
running,
}
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn command(&self) -> &str {
&self.command
}
#[must_use]
pub fn arguments(&self) -> &str {
&self.arguments
}
#[must_use]
pub fn account(&self) -> Option<&str> {
self.account.as_deref()
}
#[must_use]
pub fn description(&self) -> &str {
&self.description
}
#[must_use]
pub fn enabled(&self) -> bool {
self.enabled
}
#[must_use]
pub fn running(&self) -> bool {
self.running
}
#[must_use]
pub fn is_product_owned(&self) -> bool {
self.description.contains(PRODUCT_MARKER)
}
}
fn task_is_enabled(document: &str) -> bool {
let settings = document
.find("<Settings>")
.map_or(document, |start| &document[start..]);
xml_value(settings, "Enabled").as_deref() != Some("false")
}
#[derive(Debug)]
pub struct LifecycleTaskControl<'runner> {
runner: &'runner dyn CommandRunner,
schtasks: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Detached {
pub removed: bool,
pub name: String,
}
impl<'runner> LifecycleTaskControl<'runner> {
#[must_use]
pub fn new(runner: &'runner dyn CommandRunner) -> Self {
Self {
runner,
schtasks: locate_in_system32("schtasks.exe"),
}
}
#[must_use]
pub fn with_executable(
runner: &'runner dyn CommandRunner,
schtasks: impl Into<PathBuf>,
) -> Self {
Self {
runner,
schtasks: schtasks.into(),
}
}
pub fn query(
&self,
identity: &LifecycleTaskIdentity,
) -> Result<Option<RegisteredTask>, WslError> {
let output = self.schtasks(&["/Query", "/TN", identity.name(), "/XML", "ONE"])?;
if !output.success() {
return Ok(None);
}
let document = decode_console_output(output.stdout()).into_text();
Ok(Some(RegisteredTask::from_document(
identity.name(),
&document,
self.is_running(identity),
)))
}
pub fn register(&self, task: &LifecycleTask) -> Result<(), WslError> {
let identity = task.identity();
match self.query(identity)? {
Some(existing) if !existing.is_product_owned() => {
return Err(WslError::ForeignTask {
name: identity.name().to_string(),
detail: format!(
"a task of this name already exists, its description does not identify \
it as this product's ({PRODUCT_MARKER}), and it starts `{}`. Rename or \
remove it yourself if it is the hand-created keep-alive this feature \
replaces.",
existing.command()
),
});
}
Some(_) => {}
None if self.exists(identity) => {
return Err(WslError::ForeignTask {
name: identity.name().to_string(),
detail: format!(
"a task of this name exists but Task Scheduler would not export its \
definition, so it cannot be shown to be this product's \
({PRODUCT_MARKER}) and registering would replace it. Inspect it in \
`taskschd.msc`, and rename or remove it yourself if it is the \
hand-created keep-alive this feature replaces."
),
});
}
None => {}
}
let directory = tempfile::tempdir().map_err(|error| WslError::Record {
operation: "write",
path: PathBuf::from("<the scheduled-task document>"),
detail: error.to_string(),
})?;
let document = directory.path().join("task.xml");
write_utf16(&document, &task.xml()).map_err(|error| WslError::Record {
operation: "write",
path: document.clone(),
detail: error.to_string(),
})?;
let output = self.schtasks(&[
"/Create",
"/TN",
identity.name(),
"/XML",
&document.to_string_lossy(),
"/F",
])?;
if !output.success() {
return Err(self.task_error("register", identity.name(), &output.diagnostic()));
}
Ok(())
}
pub fn detach(&self, identity: &LifecycleTaskIdentity) -> Result<Detached, WslError> {
let Some(existing) = self.query(identity)? else {
return Ok(Detached {
removed: false,
name: identity.name().to_string(),
});
};
if !existing.is_product_owned() {
return Err(WslError::ForeignTask {
name: identity.name().to_string(),
detail: format!(
"a task of this name exists but its description does not identify it as \
this product's ({PRODUCT_MARKER}), so `detach` will not remove it."
),
});
}
let output = self.schtasks(&["/Delete", "/TN", identity.name(), "/F"])?;
if !output.success() {
return Err(self.task_error("remove", identity.name(), &output.diagnostic()));
}
Ok(Detached {
removed: true,
name: identity.name().to_string(),
})
}
pub fn start(&self, identity: &LifecycleTaskIdentity) -> Result<(), WslError> {
self.require_ours("start", identity)?;
let output = self.schtasks(&["/Run", "/TN", identity.name()])?;
if !output.success() {
return Err(self.task_error("start", identity.name(), &output.diagnostic()));
}
Ok(())
}
pub fn stop(&self, identity: &LifecycleTaskIdentity) -> Result<bool, WslError> {
let existing = self.require_ours("stop", identity)?;
if !existing.running() {
return Ok(false);
}
let output = self.schtasks(&["/End", "/TN", identity.name()])?;
if !output.success() {
return Err(self.task_error("stop", identity.name(), &output.diagnostic()));
}
Ok(true)
}
fn require_ours(
&self,
operation: &'static str,
identity: &LifecycleTaskIdentity,
) -> Result<RegisteredTask, WslError> {
let Some(existing) = self.query(identity)? else {
return Err(WslError::NoSuchTask {
name: identity.name().to_string(),
});
};
if !existing.is_product_owned() {
return Err(WslError::ForeignTask {
name: identity.name().to_string(),
detail: format!(
"a task of this name exists but is not this product's ({PRODUCT_MARKER}), \
so it will not be used to {operation} anything."
),
});
}
Ok(existing)
}
fn schtasks(&self, arguments: &[&str]) -> Result<super::exec::CommandOutput, WslError> {
let request =
CommandRequest::new(&self.schtasks).args(arguments.iter().map(OsString::from));
self.runner.run(&request)
}
fn query_csv(&self, identity: &LifecycleTaskIdentity) -> Option<super::exec::CommandOutput> {
let output = self
.schtasks(&["/Query", "/TN", identity.name(), "/FO", "CSV", "/NH"])
.ok()?;
output.success().then_some(output)
}
fn exists(&self, identity: &LifecycleTaskIdentity) -> bool {
self.query_csv(identity).is_some()
}
fn is_running(&self, identity: &LifecycleTaskIdentity) -> bool {
let Some(output) = self.query_csv(identity) else {
return false;
};
decode_console_output(output.stdout())
.into_text()
.lines()
.filter_map(|line| line.rsplit(',').next())
.any(|status| {
status
.trim()
.trim_matches('"')
.eq_ignore_ascii_case("running")
})
}
fn task_error(&self, operation: &'static str, name: &str, detail: &str) -> WslError {
if detail.to_ascii_lowercase().contains("access is denied") {
return WslError::NeedsElevation {
operation,
name: name.to_string(),
detail: detail.to_string(),
};
}
WslError::TaskControl {
operation,
name: name.to_string(),
detail: detail.to_string(),
}
}
}
fn write_utf16(path: &Path, text: &str) -> std::io::Result<()> {
let mut bytes = vec![0xFF, 0xFE];
for unit in text.encode_utf16() {
bytes.extend_from_slice(&unit.to_le_bytes());
}
std::fs::write(path, bytes)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::wsl::discovery::{DIGEST_SUFFIX_LENGTH, ESCAPED_NAME_BUDGET};
use crate::wsl::exec::{CommandOutput, ScriptedRunner};
fn identity(distribution: &str) -> LifecycleTaskIdentity {
LifecycleTaskIdentity::for_distribution(distribution).expect("a usable name")
}
fn task(distribution: &str) -> LifecycleTask {
LifecycleTask::new(
identity(distribution),
TaskPrincipal::named("IVANPC\\IvanD"),
&WslExecutable::at("C:\\Windows\\System32\\wsl.exe"),
"/usr/local/bin/runner-manager",
)
}
fn registered_document(distribution: &str) -> CommandOutput {
CommandOutput::exited(0, task(distribution).xml(), "")
}
#[test]
fn the_task_name_is_stable_for_a_distribution() {
assert_eq!(identity("Ubuntu").name(), identity("Ubuntu").name());
assert!(
identity("Ubuntu")
.name()
.starts_with("runner-manager-wsl-Ubuntu-")
);
}
#[test]
fn a_name_task_scheduler_could_not_hold_is_escaped_into_one_that_it_can() {
let name = identity("Debian GNU/Linux 12").name().to_string();
for forbidden in ['\\', '/', ':', '*', '?', '"', '<', '>', '|'] {
assert!(
!name.contains(forbidden),
"{name} still contains {forbidden:?}"
);
}
assert!(name.contains("Debian_GNU_Linux_12"), "{name}");
}
#[test]
fn two_distributions_that_escape_alike_still_get_different_tasks() {
let first = identity("Debian GNU/Linux");
let second = identity("Debian GNU:Linux");
assert_ne!(first.name(), second.name());
assert!(first.name().contains("Debian_GNU_Linux"));
assert!(second.name().contains("Debian_GNU_Linux"));
}
#[test]
fn a_very_long_name_is_bounded_and_still_unique() {
let long = "u".repeat(200);
let other = format!("{long}x");
let first = identity(&long);
let second = identity(&other);
assert_ne!(first.name(), second.name());
assert!(
first.name().len()
<= LIFECYCLE_TASK_PREFIX.len() + 1 + ESCAPED_NAME_BUDGET + 1 + DIGEST_SUFFIX_LENGTH,
"{}",
first.name()
);
}
#[test]
fn a_distribution_name_that_is_not_usable_never_becomes_a_task_name() {
assert!(LifecycleTaskIdentity::for_distribution("--shutdown").is_err());
assert!(LifecycleTaskIdentity::for_distribution("").is_err());
}
#[test]
fn the_action_is_the_documented_argument_vector() {
assert_eq!(
task("Ubuntu").action_arguments(),
vec![
"--distribution",
"Ubuntu",
"--user",
"root",
"--exec",
"/usr/local/bin/runner-manager",
"wsl-host",
"hold",
]
);
}
#[test]
fn a_name_with_spaces_is_quoted_so_windows_splits_it_back_into_one_argument() {
let rendered = task("My Ubuntu").rendered_arguments();
assert!(
rendered.contains("--distribution \"My Ubuntu\" --user root"),
"{rendered}"
);
}
#[test]
fn no_shell_text_reaches_the_task_document() {
let document = task("Ubuntu & echo pwned").xml();
let arguments = xml_value(&document, "Arguments").expect("the document has an action");
for shell in ["cmd", "powershell", "/c", "&&", "||", ";", "$(", "`"] {
assert!(
!arguments.contains(shell),
"the rendered arguments contain shell text {shell:?}: {arguments}"
);
}
assert_eq!(
xml_value(&document, "Command").as_deref(),
Some("C:\\Windows\\System32\\wsl.exe")
);
assert!(document.contains("&"), "{document}");
assert!(arguments.contains("\"Ubuntu & echo pwned\""), "{arguments}");
}
#[test]
fn the_document_is_a_least_privilege_logon_task_for_the_named_principal() {
let document = task("Ubuntu").xml();
assert!(document.contains("<LogonTrigger>"), "{document}");
assert!(
document.contains("<RunLevel>LeastPrivilege</RunLevel>"),
"{document}"
);
assert!(
document.contains("<UserId>IVANPC\\IvanD</UserId>"),
"{document}"
);
assert!(document.contains("<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>"));
}
#[test]
fn the_document_carries_the_ownership_marker_and_names_the_distribution() {
let document = task("Ubuntu").xml();
let description = xml_value(&document, "Description").expect("a description");
assert!(description.contains(PRODUCT_MARKER), "{description}");
assert!(description.contains("Ubuntu"), "{description}");
assert!(description.contains("wsl detach"), "{description}");
}
#[test]
fn a_rendered_document_reads_back_as_this_products_task() {
let document = task("Ubuntu").xml();
let read = RegisteredTask::from_document("whatever", &document, false);
assert!(read.is_product_owned());
assert_eq!(read.account(), Some("IVANPC\\IvanD"));
assert!(read.enabled());
assert!(
read.arguments().contains("wsl-host hold"),
"{}",
read.arguments()
);
}
#[test]
fn a_task_an_operator_disabled_is_reported_as_disabled() {
let disabled = task("Ubuntu").xml().replace(
"\n <Enabled>true</Enabled>\n",
"\n <Enabled>false</Enabled>\n",
);
assert!(
disabled.contains(" <Enabled>true</Enabled>"),
"the trigger's own <Enabled> must still be true for this to prove anything"
);
assert!(!RegisteredTask::from_document("whatever", &disabled, false).enabled());
assert!(RegisteredTask::from_document("whatever", &task("Ubuntu").xml(), false).enabled());
}
#[test]
fn a_task_this_product_did_not_write_is_not_product_owned() {
let hand_made = concat!(
"<Task><RegistrationInfo><Description>GitHub Actions Linux Runner - Ubuntu WSL",
"</Description></RegistrationInfo><Actions><Exec><Command>wsl.exe</Command>",
"<Arguments>-d Ubuntu -u root /bin/sleep infinity</Arguments></Exec></Actions></Task>",
);
let read = RegisteredTask::from_document("whatever", hand_made, false);
assert!(!read.is_product_owned());
}
fn control(runner: &ScriptedRunner) -> LifecycleTaskControl<'_> {
LifecycleTaskControl::with_executable(runner, "schtasks.exe")
}
#[test]
fn registering_writes_a_utf16_document_and_replaces_in_place() {
let runner = ScriptedRunner::new().always("/Query", CommandOutput::exited(1, "", ""));
let task = task("Ubuntu");
control(&runner).register(&task).expect("registered");
let create = runner
.recorded()
.into_iter()
.find(|request| request.arguments.first().map(String::as_str) == Some("/Create"))
.expect("a /Create call");
assert_eq!(create.arguments[1], "/TN");
assert_eq!(create.arguments[2], task.identity().name());
assert_eq!(create.arguments[3], "/XML");
assert_eq!(
create.arguments[5], "/F",
"without /F a second `wsl install` fails instead of updating the task"
);
}
#[test]
fn registering_over_this_products_own_task_is_allowed_and_idempotent() {
let runner = ScriptedRunner::new()
.always("/Query", registered_document("Ubuntu"))
.always("/Create", CommandOutput::exited(0, "SUCCESS", ""));
control(&runner)
.register(&task("Ubuntu"))
.expect("replaced");
control(&runner)
.register(&task("Ubuntu"))
.expect("replaced again");
}
#[test]
fn registering_over_a_task_that_cannot_be_exported_refuses_and_changes_nothing() {
let runner = ScriptedRunner::new()
.always(
"/XML",
CommandOutput::exited(1, "", "the task image is corrupt"),
)
.always(
"/FO",
CommandOutput::exited(0, "\"whatever\",\"N/A\",\"Ready\"", ""),
);
let error = control(&runner)
.register(&task("Ubuntu"))
.expect_err("an unexportable task is not a free name");
assert!(matches!(error, WslError::ForeignTask { .. }), "{error:?}");
assert!(
runner
.command_lines()
.iter()
.all(|line| !line.contains("/Create")),
"nothing may be written: {:?}",
runner.command_lines()
);
}
#[test]
fn registering_over_a_foreign_task_refuses_and_changes_nothing() {
let hand_made = CommandOutput::exited(
0,
concat!(
"<Task><RegistrationInfo><Description>GitHub Actions Linux Runner - Ubuntu WSL",
"</Description></RegistrationInfo><Actions><Exec><Command>wsl.exe</Command>",
"</Exec></Actions></Task>",
),
"",
);
let runner = ScriptedRunner::new().always("/Query", hand_made);
let error = control(&runner)
.register(&task("Ubuntu"))
.expect_err("not ours");
assert!(matches!(error, WslError::ForeignTask { .. }), "{error:?}");
assert!(
runner
.command_lines()
.iter()
.all(|line| !line.contains("/Create")),
"nothing may be written: {:?}",
runner.command_lines()
);
}
#[test]
fn detach_removes_only_the_product_task_and_runs_nothing_else() {
let runner = ScriptedRunner::new()
.always("/Query", registered_document("Ubuntu"))
.always("/Delete", CommandOutput::exited(0, "SUCCESS", ""));
let detached = control(&runner)
.detach(&identity("Ubuntu"))
.expect("detached");
assert!(detached.removed);
for request in runner.recorded() {
assert_eq!(
request.program.to_string_lossy(),
"schtasks.exe",
"detach must not run anything but Task Scheduler: {request:?}"
);
}
let lines = runner.command_lines();
assert!(
lines.iter().all(|line| !line.contains("wsl.exe")),
"detach must not reach into the distribution: {lines:?}"
);
assert!(
lines
.iter()
.all(|line| !line.contains("--unregister") && !line.contains("systemctl")),
"detach must not unregister WSL or touch the Linux service: {lines:?}"
);
}
#[test]
fn detach_without_a_task_is_not_an_error() {
let runner = ScriptedRunner::new().always("/Query", CommandOutput::exited(1, "", ""));
let detached = control(&runner)
.detach(&identity("Ubuntu"))
.expect("nothing to remove");
assert!(!detached.removed);
assert!(
runner
.command_lines()
.iter()
.all(|line| !line.contains("/Delete"))
);
}
#[test]
fn detach_refuses_a_foreign_task_rather_than_deleting_it() {
let runner = ScriptedRunner::new().always(
"/Query",
CommandOutput::exited(
0,
"<Task><RegistrationInfo><Description>Somebody else's task</Description>\
</RegistrationInfo></Task>",
"",
),
);
let error = control(&runner)
.detach(&identity("Ubuntu"))
.expect_err("not ours");
assert!(matches!(error, WslError::ForeignTask { .. }), "{error:?}");
assert!(
runner
.command_lines()
.iter()
.all(|line| !line.contains("/Delete")),
"a task this product does not own must not be deleted"
);
}
#[test]
fn access_denied_is_reported_as_needing_elevation_rather_than_as_a_generic_failure() {
let runner = ScriptedRunner::new()
.always("/Query", CommandOutput::exited(1, "", ""))
.always(
"/Create",
CommandOutput::exited(1, "", "ERROR: Access is denied.\n"),
);
let error = control(&runner)
.register(&task("Ubuntu"))
.expect_err("denied");
assert!(
matches!(error, WslError::NeedsElevation { .. }),
"{error:?}"
);
}
#[test]
fn starting_a_task_that_is_not_registered_says_so() {
let runner = ScriptedRunner::new().always("/Query", CommandOutput::exited(1, "", ""));
let error = control(&runner)
.start(&identity("Ubuntu"))
.expect_err("not registered");
assert!(matches!(error, WslError::NoSuchTask { .. }), "{error:?}");
}
#[test]
fn a_query_reads_a_utf16_document_as_schtasks_really_writes_it() {
let mut bytes = vec![0xFF, 0xFE];
for unit in task("Ubuntu").xml().encode_utf16() {
bytes.extend_from_slice(&unit.to_le_bytes());
}
let runner = ScriptedRunner::new()
.always("/XML ONE", CommandOutput::exited(0, bytes, ""))
.always(
"/FO CSV",
CommandOutput::exited(0, "\"task\",\"N/A\",\"Ready\"\n", ""),
);
let found = control(&runner)
.query(&identity("Ubuntu"))
.expect("queried")
.expect("registered");
assert!(found.is_product_owned());
assert!(!found.running());
assert!(found.arguments().contains("wsl-host hold"));
}
#[test]
fn a_running_task_is_reported_from_the_csv_status_column() {
let runner = ScriptedRunner::new()
.always("/XML ONE", registered_document("Ubuntu"))
.always(
"/FO CSV",
CommandOutput::exited(0, "\"\\task\",\"N/A\",\"Running\"\n", ""),
);
let found = control(&runner)
.query(&identity("Ubuntu"))
.expect("queried")
.expect("registered");
assert!(found.running());
}
#[test]
fn the_document_is_written_as_utf16_little_endian_with_a_byte_order_mark() {
let directory = tempfile::tempdir().expect("a temporary directory");
let path = directory.path().join("task.xml");
write_utf16(&path, &task("Ubuntu").xml()).expect("written");
let bytes = std::fs::read(&path).expect("readable");
assert_eq!(&bytes[..2], &[0xFF, 0xFE]);
let decoded = decode_console_output(&bytes);
assert_eq!(decoded.text(), task("Ubuntu").xml());
}
#[test]
fn no_credential_shaped_value_can_reach_the_document() {
let document = task("Ubuntu").xml().to_ascii_lowercase();
for shape in [
"ghu_",
"ghs_",
"gho_",
"github_pat_",
"access_token",
"refresh_token",
"jitconfig",
"secret",
"password",
"credential",
] {
assert!(
!document.contains(shape),
"the task document mentions {shape:?}: {document}"
);
}
assert!(document.contains("interactivetoken"));
}
}