use crate::platform::{
independent_spawn::check,
process::{SpawnStdio, StdioSource, SyncEnvironment},
};
use std::{
io,
path::Path,
process::Command,
sync::atomic::AtomicBool,
time::{Duration, Instant},
};
const CONTROL: &str = r#"param([string]$Operation,[string]$TaskName,[string]$Definition)
$ErrorActionPreference = 'Stop'
try {
$service = New-Object -ComObject 'Schedule.Service'
$service.Connect()
$folder = $service.GetFolder('\')
switch ($Operation) {
'create' {
$document = [xml]$Definition
$now = [DateTime]::UtcNow
$document.Task.Triggers.TimeTrigger.StartBoundary = $now.ToString('yyyy-MM-ddTHH:mm:ssZ')
$document.Task.Triggers.TimeTrigger.EndBoundary = $now.AddSeconds(45).ToString('yyyy-MM-ddTHH:mm:ssZ')
$sid = [string]$document.Task.Principals.Principal.UserId
# COM transports a Unicode BSTR, not an encoded XML byte stream.
$folder.RegisterTask($TaskName,$document.DocumentElement.OuterXml,2,$sid,$null,3,$null) | Out-Null
}
'run' { $folder.GetTask($TaskName).Run($null) | Out-Null }
'end' { $folder.GetTask($TaskName).Stop(0) }
'delete' { $folder.DeleteTask($TaskName,0) }
'exists' { $folder.GetTask($TaskName) | Out-Null }
default { exit 6 }
}
exit 0
} catch {
$errorObject = $_.Exception
while ($null -ne $errorObject.InnerException) { $errorObject = $errorObject.InnerException }
$code = ([long]$errorObject.HResult) -band 4294967295L
if ($code -eq 2147942405L) { exit 3 }
if ($code -eq 2147942402L) { exit 5 }
if ($code -eq 2147944122L -or $code -eq 2147746132L -or $code -eq 2147750677L) { exit 4 }
exit $errorObject.HResult
}
"#;
pub(super) struct ScheduledTask {
name: String,
definition: String,
registered: bool,
}
impl ScheduledTask {
pub(super) fn prepare(helper: &Path) -> io::Result<(Self, String)> {
if !helper.is_absolute() {
return Err(io::Error::from(io::ErrorKind::InvalidInput));
}
let mut entropy = [0_u8; 16];
getrandom::fill(&mut entropy).map_err(io::Error::other)?;
let nonce: String = entropy.iter().map(|byte| format!("{byte:02x}")).collect();
let name = format!("rp-independent-{nonce}");
let endpoint = format!(r"\\.\pipe\{name}");
let definition = task_xml(helper, &endpoint, &super::ipc::current_user_sid_text()?)?;
Ok((
Self {
name,
definition,
registered: false,
},
endpoint,
))
}
pub(super) fn start(&mut self, deadline: Instant, cancelled: &AtomicBool) -> io::Result<()> {
self.registered = true;
self.command("create", deadline, cancelled)
.map_err(|error| io::Error::new(error.kind(), format!("Task Scheduler create: {error}")))?;
self.command("run", deadline, cancelled)
.map_err(|error| io::Error::new(error.kind(), format!("Task Scheduler run: {error}")))
}
pub(super) fn remove_definition(
&mut self,
deadline: Instant,
cancelled: &AtomicBool,
) -> io::Result<()> {
self.command("delete", deadline, cancelled)?;
self.registered = false;
Ok(())
}
fn command(
&self,
operation: &str,
deadline: Instant,
cancelled: &AtomicBool,
) -> io::Result<()> {
check(deadline, cancelled)?;
let environment = super::host::login_environment()?;
let system_root = environment
.iter()
.find(|(key, _)| {
key.to_str()
.is_some_and(|key| key.eq_ignore_ascii_case("SystemRoot"))
})
.map(|(_, value)| value)
.map(std::path::PathBuf::from)
.filter(|root| root.is_absolute())
.ok_or_else(|| {
io::Error::new(io::ErrorKind::Unsupported, "absolute SystemRoot unavailable")
})?;
let mut command = Command::new(
system_root.join(r"System32\WindowsPowerShell\v1.0\powershell.exe"),
);
let literal = |value: &str| format!("'{}'", value.replace('\'', "''"));
let script = format!(
"& {{ {CONTROL} }} -Operation {} -TaskName {} -Definition {}",
literal(operation),
literal(&self.name),
literal(&self.definition)
);
command.args([
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-Command",
&script,
]);
let stdio = SpawnStdio {
stdout: StdioSource::Null,
stderr: StdioSource::Null,
drain_timeout: None,
..SpawnStdio::default()
};
let mut child =
crate::spawn_sync(&mut command, stdio, SyncEnvironment::Explicit(environment)).map_err(|error| {
if error.kind() == io::ErrorKind::NotFound {
io::Error::new(
io::ErrorKind::Unsupported,
"Task Scheduler COM host unavailable",
)
} else {
error
}
})?;
loop {
check(deadline, cancelled)?;
if let Some(code) = child.try_wait()? {
return super::scheduler_error::result(code, operation);
}
std::thread::sleep(
Duration::from_millis(5).min(deadline.saturating_duration_since(Instant::now())),
);
}
}
}
impl Drop for ScheduledTask {
fn drop(&mut self) {
if self.registered {
let cancelled = AtomicBool::new(false);
let _ = self.command("end", Instant::now() + Duration::from_secs(2), &cancelled);
let _ = self.command(
"delete",
Instant::now() + Duration::from_secs(2),
&cancelled,
);
}
}
}
fn escape(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn task_xml(helper: &Path, endpoint: &str, sid: &str) -> io::Result<String> {
let helper = helper
.to_str()
.ok_or_else(|| io::Error::from(io::ErrorKind::InvalidInput))?;
Ok(format!(
r#"<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<RegistrationInfo><Description>running-process independent launcher</Description></RegistrationInfo>
<Triggers><TimeTrigger><StartBoundary>2000-01-01T00:00:00Z</StartBoundary><EndBoundary>2000-01-01T00:00:45Z</EndBoundary><Enabled>false</Enabled></TimeTrigger></Triggers>
<Principals><Principal id="Caller"><UserId>{}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
<Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy><DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries><StopIfGoingOnBatteries>false</StopIfGoingOnBatteries><AllowStartOnDemand>true</AllowStartOnDemand><Enabled>true</Enabled><ExecutionTimeLimit>PT0S</ExecutionTimeLimit><DeleteExpiredTaskAfter>PT1S</DeleteExpiredTaskAfter></Settings>
<Actions Context="Caller"><Exec><Command>{}</Command><Arguments>"{}"</Arguments></Exec></Actions>
</Task>"#,
escape(sid),
escape(helper),
escape(endpoint)
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn task_is_on_demand_same_user_and_not_elevated() {
let xml = task_xml(
Path::new(r"C:\Program Files\helper.exe"),
r"\\.\pipe\fixture",
"S-1-5-21-123",
)
.unwrap();
assert!(xml.contains("<Enabled>false</Enabled></TimeTrigger>"));
assert!(xml.contains("<DeleteExpiredTaskAfter>PT1S</DeleteExpiredTaskAfter>"));
assert!(!xml.contains("<BootTrigger"));
assert!(!xml.contains("<LogonTrigger"));
assert!(xml.contains("<RunLevel>LeastPrivilege</RunLevel>"));
assert!(xml.contains("<LogonType>InteractiveToken</LogonType>"));
assert!(xml.contains("S-1-5-21-123"));
assert!(!xml.contains("HighestAvailable"));
}
#[test]
#[ignore = "requires real Task Scheduler; verifies registration-only crash cleanup"]
fn abandoned_registration_expires_without_running() {
let (mut task, _) = ScheduledTask::prepare(&std::env::current_exe().unwrap()).unwrap();
let cancelled = AtomicBool::new(false);
task.registered = true;
task.command("create", Instant::now() + Duration::from_secs(15), &cancelled).unwrap();
let deadline = Instant::now() + Duration::from_secs(100);
loop {
match task.command("exists", deadline, &cancelled) {
Err(error) if error.kind() == io::ErrorKind::NotFound => break,
Err(error) => panic!("expiration query failed: {error}"),
Ok(()) => std::thread::sleep(Duration::from_millis(100)),
}
}
task.registered = false;
}
}