| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| |
| use std::{ |
| collections::HashSet, |
| mem::size_of, |
| os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}, |
| path::Path, |
| sync::{Mutex, OnceLock}, |
| time::{Duration, Instant}, |
| }; |
| |
| use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; |
| use napi::bindgen_prelude::{Error as NapiError, Result, Status}; |
| use napi_derive::napi; |
| use windows::{ |
| Win32::{ |
| Foundation::{HANDLE, RPC_E_CHANGED_MODE, VARIANT_FALSE, VARIANT_TRUE}, |
| System::{ |
| Com::{ |
| CLSCTX_INPROC_SERVER, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, |
| CoUninitialize, |
| }, |
| Diagnostics::ToolHelp::{ |
| CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, |
| TH32CS_SNAPPROCESS, |
| }, |
| JobObjects::{ |
| AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, |
| JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, |
| SetInformationJobObject, |
| }, |
| TaskScheduler::{ |
| IDailyTrigger, IExecAction, ILogonTrigger, IRegisteredTask, IRegistrationTrigger, |
| ITaskFolder, ITaskService, TASK_ACTION_EXEC, TASK_CREATE_OR_UPDATE, |
| TASK_INSTANCES_IGNORE_NEW, TASK_LOGON_INTERACTIVE_TOKEN, TASK_RUNLEVEL_LUA, |
| TASK_STATE_DISABLED, TASK_STATE_QUEUED, TASK_STATE_READY, TASK_STATE_RUNNING, |
| TASK_TRIGGER_DAILY, TASK_TRIGGER_LOGON, TASK_TRIGGER_REGISTRATION, TaskScheduler, |
| }, |
| Threading::GetCurrentProcess, |
| Variant::VARIANT, |
| }, |
| }, |
| core::{BSTR, Interface, PCWSTR}, |
| }; |
| |
| const ROOT_ID_BYTES: usize = 64; |
| const MAX_COMMAND_ARGUMENTS: usize = 64; |
| const MAX_ARGUMENT_BYTES: usize = 4 * 1024; |
| const STOP_TIMEOUT: Duration = Duration::from_secs(30); |
| |
| static PROCESS_JOB: OnceLock<Mutex<Option<OwnedHandle>>> = OnceLock::new(); |
| |
| #[derive(Clone, Copy)] |
| enum Target { |
| Host, |
| Reconciliation, |
| } |
| |
| #[napi(object)] |
| pub struct WindowsTaskStatus { |
| pub installed: bool, |
| pub enabled: bool, |
| pub state: String, |
| pub pid: Option<u32>, |
| pub last_exit_code: Option<u32>, |
| } |
| |
| #[napi] |
| pub fn windows_task_probe() -> Result<()> { |
| scheduler().map(|_| ()).map_err(native_error) |
| } |
| |
| #[napi] |
| pub fn windows_task_converge( |
| root_id: String, |
| target: String, |
| runner_path: String, |
| command: Vec<String>, |
| ) -> Result<()> { |
| let launcher_path = launcher_for_legacy_runner(&runner_path).map_err(native_error)?; |
| converge_launcher_task(root_id, target, launcher_path, command) |
| } |
| |
| #[napi] |
| pub fn windows_task_converge_launcher( |
| root_id: String, |
| target: String, |
| launcher_path: String, |
| command: Vec<String>, |
| ) -> Result<()> { |
| converge_launcher_task(root_id, target, launcher_path, command) |
| } |
| |
| fn converge_launcher_task( |
| root_id: String, |
| target: String, |
| launcher_path: String, |
| command: Vec<String>, |
| ) -> Result<()> { |
| let target = require_target(&root_id, &target)?; |
| validate_command(&command).map_err(|_| invalid("Windows lifecycle command is invalid"))?; |
| converge_task( |
| &scheduler().map_err(native_error)?, |
| &root_id, |
| target, |
| &launcher_path, |
| &command, |
| ) |
| .map_err(native_error) |
| } |
| |
| #[napi] |
| pub fn windows_task_verify( |
| root_id: String, |
| target: String, |
| runner_path: String, |
| command: Vec<String>, |
| ) -> Result<()> { |
| let launcher_path = launcher_for_legacy_runner(&runner_path).map_err(native_error)?; |
| verify_launcher_task(root_id, target, launcher_path, command) |
| } |
| |
| #[napi] |
| pub fn windows_task_verify_launcher( |
| root_id: String, |
| target: String, |
| launcher_path: String, |
| command: Vec<String>, |
| ) -> Result<()> { |
| verify_launcher_task(root_id, target, launcher_path, command) |
| } |
| |
| fn verify_launcher_task( |
| root_id: String, |
| target: String, |
| launcher_path: String, |
| command: Vec<String>, |
| ) -> Result<()> { |
| let target = require_target(&root_id, &target)?; |
| validate_command(&command).map_err(|_| invalid("Windows lifecycle command is invalid"))?; |
| let context = scheduler().map_err(native_error)?; |
| let name = task_name(&root_id, target); |
| let task = |
| required_owned_task(&context.folder, &name, &root_id, target).map_err(native_error)?; |
| verify_registered_definition(&task, target, &launcher_path, &command, &context.user) |
| .map_err(native_error) |
| } |
| |
| #[napi] |
| pub fn windows_task_status(root_id: String, target: String) -> Result<WindowsTaskStatus> { |
| let target = require_target(&root_id, &target)?; |
| let context = scheduler().map_err(native_error)?; |
| let name = task_name(&root_id, target); |
| read_status( |
| owned_task(&context.folder, &name, &root_id, target).map_err(native_error)?, |
| target, |
| ) |
| .map_err(native_error) |
| } |
| |
| #[napi] |
| pub fn windows_task_activate(root_id: String) -> Result<()> { |
| require_root_id(&root_id)?; |
| let target = Target::Host; |
| let context = scheduler().map_err(native_error)?; |
| let name = task_name(&root_id, target); |
| let task = |
| required_owned_task(&context.folder, &name, &root_id, target).map_err(native_error)?; |
| // SAFETY: task is a live thread-local COM interface and Run copies the empty argument. |
| unsafe { |
| if task.State().map_err(native_error)? != TASK_STATE_RUNNING { |
| task.Run(&VARIANT::default()).map_err(native_error)?; |
| } |
| } |
| Ok(()) |
| } |
| |
| #[napi] |
| pub fn windows_task_retire(root_id: String) -> Result<()> { |
| require_root_id(&root_id)?; |
| let target = Target::Host; |
| let context = scheduler().map_err(native_error)?; |
| let name = task_name(&root_id, target); |
| if let Some(task) = |
| owned_task(&context.folder, &name, &root_id, target).map_err(native_error)? |
| { |
| stop_task(&task).map_err(native_error)?; |
| } |
| Ok(()) |
| } |
| |
| #[napi] |
| pub fn windows_task_uninstall(root_id: String, target: String) -> Result<()> { |
| let target = require_target(&root_id, &target)?; |
| let context = scheduler().map_err(native_error)?; |
| let name = task_name(&root_id, target); |
| if let Some(task) = |
| owned_task(&context.folder, &name, &root_id, target).map_err(native_error)? |
| { |
| stop_task(&task).map_err(native_error)?; |
| // SAFETY: folder is a live thread-local COM interface and DeleteTask copies the BSTR. |
| unsafe { |
| context |
| .folder |
| .DeleteTask(&BSTR::from(&name), 0) |
| .map_err(native_error)?; |
| } |
| } |
| Ok(()) |
| } |
| |
| #[napi] |
| pub fn own_current_process_tree() -> Result<()> { |
| let slot = PROCESS_JOB.get_or_init(|| Mutex::new(None)); |
| let mut guard = slot |
| .lock() |
| .map_err(|_| NapiError::new(Status::GenericFailure, "Windows process job is poisoned"))?; |
| if guard.is_some() { |
| return Ok(()); |
| } |
| // SAFETY: the owned handle remains live in PROCESS_JOB until process exit. The initialized |
| // structure and information class have matching layouts, and GetCurrentProcess is valid here. |
| unsafe { |
| let created = CreateJobObjectW(None, PCWSTR::null()).map_err(native_error)?; |
| let owned = OwnedHandle::from_raw_handle(created.0); |
| let handle = HANDLE(owned.as_raw_handle()); |
| let mut information = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); |
| information.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; |
| SetInformationJobObject( |
| handle, |
| JobObjectExtendedLimitInformation, |
| (&raw const information).cast(), |
| size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32, |
| ) |
| .map_err(native_error)?; |
| AssignProcessToJobObject(handle, GetCurrentProcess()).map_err(native_error)?; |
| *guard = Some(owned); |
| } |
| Ok(()) |
| } |
| |
| struct Scheduler { |
| service: ITaskService, |
| folder: ITaskFolder, |
| user: String, |
| _apartment: ComApartment, |
| } |
| |
| fn scheduler() -> windows::core::Result<Scheduler> { |
| let apartment = ComApartment::initialize()?; |
| // SAFETY: COM is initialized for this thread and all interfaces remain thread-local. |
| unsafe { |
| let service: ITaskService = CoCreateInstance(&TaskScheduler, None, CLSCTX_INPROC_SERVER)?; |
| let empty = VARIANT::default(); |
| service.Connect(&empty, &empty, &empty, &empty)?; |
| let folder = service.GetFolder(&BSTR::from("\\"))?; |
| let user = service.ConnectedUser()?.to_string(); |
| Ok(Scheduler { |
| service, |
| folder, |
| user, |
| _apartment: apartment, |
| }) |
| } |
| } |
| |
| fn converge_task( |
| context: &Scheduler, |
| root_id: &str, |
| target: Target, |
| launcher_path: &str, |
| command: &[String], |
| ) -> windows::core::Result<()> { |
| let name = task_name(root_id, target); |
| if let Some(task) = owned_task(&context.folder, &name, root_id, target)? |
| && matches!(target, Target::Host) |
| { |
| stop_task(&task)?; |
| } |
| let desired = normalized_definition( |
| &context.service, |
| root_id, |
| target, |
| launcher_path, |
| command, |
| &context.user, |
| )?; |
| let empty = VARIANT::default(); |
| let user_id = VARIANT::from(context.user.as_str()); |
| // SAFETY: all COM interfaces are live on this thread and registration copies its arguments. |
| unsafe { |
| context.folder.RegisterTaskDefinition( |
| &BSTR::from(&name), |
| &desired, |
| TASK_CREATE_OR_UPDATE.0, |
| &user_id, |
| &empty, |
| TASK_LOGON_INTERACTIVE_TOKEN, |
| &empty, |
| )?; |
| } |
| Ok(()) |
| } |
| |
| fn normalized_definition( |
| service: &ITaskService, |
| root_id: &str, |
| target: Target, |
| launcher_path: &str, |
| command: &[String], |
| user: &str, |
| ) -> windows::core::Result<windows::Win32::System::TaskScheduler::ITaskDefinition> { |
| // SAFETY: service is a live thread-local COM interface and SetXmlText copies the BSTR. |
| unsafe { |
| let definition = service.NewTask(0)?; |
| definition.SetXmlText(&BSTR::from(render_task_xml( |
| root_id, |
| target, |
| launcher_path, |
| command, |
| user, |
| )?))?; |
| Ok(definition) |
| } |
| } |
| |
| fn owned_task( |
| folder: &ITaskFolder, |
| name: &str, |
| root_id: &str, |
| target: Target, |
| ) -> windows::core::Result<Option<IRegisteredTask>> { |
| // SAFETY: folder is a live thread-local COM interface and GetTask copies the BSTR. |
| unsafe { |
| match folder.GetTask(&BSTR::from(name)) { |
| Ok(task) => { |
| assert_owned(&task, root_id, target)?; |
| Ok(Some(task)) |
| } |
| Err(error) if error.code().0 as u32 == 0x80070002 => Ok(None), |
| Err(error) => Err(error), |
| } |
| } |
| } |
| |
| fn verify_registered_definition( |
| task: &IRegisteredTask, |
| target: Target, |
| launcher_path: &str, |
| command: &[String], |
| user: &str, |
| ) -> windows::core::Result<()> { |
| // SAFETY: every interface is obtained from this thread's live registered-task definition; |
| // all out pointers refer to initialized local values for the duration of each call. |
| unsafe { |
| let expected_command = task_action_command(target, launcher_path, command)?; |
| let definition = task.Definition()?; |
| |
| let actions = definition.Actions()?; |
| let mut action_count = 0; |
| actions.Count(&mut action_count)?; |
| let action = actions.get_Item(1)?; |
| let mut action_type = TASK_ACTION_EXEC; |
| action.Type(&mut action_type)?; |
| let executable: IExecAction = action.cast()?; |
| let mut path = BSTR::new(); |
| let mut arguments = BSTR::new(); |
| executable.Path(&mut path)?; |
| executable.Arguments(&mut arguments)?; |
| |
| let principal = definition.Principal()?; |
| let mut principal_user = BSTR::new(); |
| let mut logon_type = TASK_LOGON_INTERACTIVE_TOKEN; |
| let mut run_level = TASK_RUNLEVEL_LUA; |
| principal.UserId(&mut principal_user)?; |
| principal.LogonType(&mut logon_type)?; |
| principal.RunLevel(&mut run_level)?; |
| |
| let settings = definition.Settings()?; |
| let mut instances = TASK_INSTANCES_IGNORE_NEW; |
| let mut allow_demand = VARIANT_FALSE; |
| let mut allow_hard_terminate = VARIANT_FALSE; |
| let mut disallow_battery_start = VARIANT_TRUE; |
| let mut enabled = VARIANT_FALSE; |
| let mut network_required = VARIANT_TRUE; |
| let mut start_when_available = VARIANT_FALSE; |
| let mut stop_on_battery = VARIANT_TRUE; |
| let mut execution_limit = BSTR::new(); |
| let mut restart_interval = BSTR::new(); |
| let mut restart_count = 0; |
| settings.MultipleInstances(&mut instances)?; |
| settings.AllowDemandStart(&mut allow_demand)?; |
| settings.AllowHardTerminate(&mut allow_hard_terminate)?; |
| settings.DisallowStartIfOnBatteries(&mut disallow_battery_start)?; |
| settings.Enabled(&mut enabled)?; |
| settings.ExecutionTimeLimit(&mut execution_limit)?; |
| settings.RunOnlyIfNetworkAvailable(&mut network_required)?; |
| settings.StartWhenAvailable(&mut start_when_available)?; |
| settings.StopIfGoingOnBatteries(&mut stop_on_battery)?; |
| settings.RestartInterval(&mut restart_interval)?; |
| settings.RestartCount(&mut restart_count)?; |
| |
| let triggers = definition.Triggers()?; |
| let mut trigger_count = 0; |
| triggers.Count(&mut trigger_count)?; |
| let triggers_match = match target { |
| Target::Host if trigger_count == 1 => { |
| let trigger = triggers.get_Item(1)?; |
| let mut trigger_type = TASK_TRIGGER_LOGON; |
| let mut trigger_enabled = VARIANT_FALSE; |
| trigger.Type(&mut trigger_type)?; |
| trigger.Enabled(&mut trigger_enabled)?; |
| let logon: ILogonTrigger = trigger.cast()?; |
| let mut trigger_user = BSTR::new(); |
| logon.UserId(&mut trigger_user)?; |
| trigger_type == TASK_TRIGGER_LOGON |
| && trigger_enabled == VARIANT_TRUE |
| && same_windows_user(&trigger_user.to_string(), user) |
| } |
| Target::Reconciliation if trigger_count == 2 => { |
| let registration = triggers.get_Item(1)?; |
| let daily = triggers.get_Item(2)?; |
| let mut registration_type = TASK_TRIGGER_REGISTRATION; |
| let mut daily_type = TASK_TRIGGER_DAILY; |
| let mut registration_enabled = VARIANT_FALSE; |
| let mut daily_enabled = VARIANT_FALSE; |
| registration.Type(&mut registration_type)?; |
| registration.Enabled(&mut registration_enabled)?; |
| daily.Type(&mut daily_type)?; |
| daily.Enabled(&mut daily_enabled)?; |
| let registration: IRegistrationTrigger = registration.cast()?; |
| let daily: IDailyTrigger = daily.cast()?; |
| let mut delay = BSTR::new(); |
| let mut random_delay = BSTR::new(); |
| let mut start_boundary = BSTR::new(); |
| let mut days = 0; |
| registration.Delay(&mut delay)?; |
| daily.RandomDelay(&mut random_delay)?; |
| daily.StartBoundary(&mut start_boundary)?; |
| daily.DaysInterval(&mut days)?; |
| registration_type == TASK_TRIGGER_REGISTRATION |
| && daily_type == TASK_TRIGGER_DAILY |
| && registration_enabled == VARIANT_TRUE |
| && daily_enabled == VARIANT_TRUE |
| && delay == "PT15M" |
| && random_delay == "PT1H" |
| && start_boundary == "2000-01-01T03:00:00" |
| && days == 1 |
| } |
| _ => false, |
| }; |
| |
| if action_count != 1 |
| || action_type != TASK_ACTION_EXEC |
| || path != expected_command[0] |
| || arguments != command_line(&expected_command[1..]) |
| || !same_windows_user(&principal_user.to_string(), user) |
| || logon_type != TASK_LOGON_INTERACTIVE_TOKEN |
| || run_level != TASK_RUNLEVEL_LUA |
| || instances != TASK_INSTANCES_IGNORE_NEW |
| || allow_demand != VARIANT_TRUE |
| || allow_hard_terminate != VARIANT_TRUE |
| || disallow_battery_start != VARIANT_FALSE |
| || enabled != VARIANT_TRUE |
| || execution_limit != "PT0S" |
| || network_required != VARIANT_FALSE |
| || start_when_available != VARIANT_TRUE |
| || stop_on_battery != VARIANT_FALSE |
| || !restart_interval.to_string().is_empty() |
| || restart_count != 0 |
| || !triggers_match |
| { |
| return Err(invalid_task_definition()); |
| } |
| } |
| Ok(()) |
| } |
| |
| fn same_windows_user(observed: &str, connected: &str) -> bool { |
| let observed = observed |
| .rsplit_once('\\') |
| .map_or(observed, |(_, user)| user); |
| let connected = connected |
| .rsplit_once('\\') |
| .map_or(connected, |(_, user)| user); |
| !observed.is_empty() && observed.eq_ignore_ascii_case(connected) |
| } |
| |
| fn invalid_task_definition() -> windows::core::Error { |
| windows::core::Error::new( |
| windows::core::HRESULT(0x8007000D_u32 as i32), |
| "The Windows scheduled task does not match its managed deployment", |
| ) |
| } |
| |
| fn required_owned_task( |
| folder: &ITaskFolder, |
| name: &str, |
| root_id: &str, |
| target: Target, |
| ) -> windows::core::Result<IRegisteredTask> { |
| owned_task(folder, name, root_id, target)?.ok_or_else(|| { |
| windows::core::Error::new( |
| windows::core::HRESULT(0x80070002_u32 as i32), |
| "The Windows scheduled task is not installed", |
| ) |
| }) |
| } |
| |
| fn assert_owned( |
| task: &IRegisteredTask, |
| root_id: &str, |
| target: Target, |
| ) -> windows::core::Result<()> { |
| // SAFETY: every interface is obtained from this thread's live registered task. |
| let description = unsafe { |
| let definition = task.Definition()?; |
| let registration = definition.RegistrationInfo()?; |
| let mut description = BSTR::new(); |
| registration.Description(&mut description)?; |
| description |
| }; |
| if description != ownership_marker(root_id, target) { |
| return Err(windows::core::Error::new( |
| windows::core::HRESULT(0x80070005_u32 as i32), |
| "Refusing to modify a scheduled task not owned by Maka", |
| )); |
| } |
| Ok(()) |
| } |
| |
| fn read_status( |
| task: Option<IRegisteredTask>, |
| target: Target, |
| ) -> windows::core::Result<WindowsTaskStatus> { |
| let Some(task) = task else { |
| return Ok(WindowsTaskStatus { |
| installed: false, |
| enabled: false, |
| state: "not_installed".to_owned(), |
| pid: None, |
| last_exit_code: None, |
| }); |
| }; |
| // SAFETY: task and instances are live thread-local COM interfaces. |
| let (state, instances) = unsafe { (task.State()?, task.GetInstances(0)?) }; |
| let count = unsafe { instances.Count()? }; |
| if count > 1 { |
| return Err(windows::core::Error::new( |
| windows::core::HRESULT(0x8007000D_u32 as i32), |
| "The Windows scheduled task has multiple running instances", |
| )); |
| } |
| let engine_pid = if count == 1 { |
| Some(unsafe { instances.get_Item(&VARIANT::from(1_i32))?.EnginePID()? }) |
| } else { |
| None |
| }; |
| let pid = match (target, engine_pid) { |
| (Target::Host, Some(wrapper_pid)) => direct_child_pid(wrapper_pid)?, |
| (_, pid) => pid, |
| }; |
| let enabled = unsafe { task.Enabled()? } != VARIANT_FALSE; |
| Ok(WindowsTaskStatus { |
| installed: true, |
| enabled, |
| state: if state == TASK_STATE_RUNNING && matches!(target, Target::Host) && pid.is_none() { |
| "starting" |
| } else if state == TASK_STATE_RUNNING { |
| "running" |
| } else if state == TASK_STATE_QUEUED { |
| "starting" |
| } else if state == TASK_STATE_READY || state == TASK_STATE_DISABLED { |
| "stopped" |
| } else { |
| "failed" |
| } |
| .to_owned(), |
| pid, |
| last_exit_code: Some(unsafe { task.LastTaskResult()? } as u32), |
| }) |
| } |
| |
| fn direct_child_pid(parent_pid: u32) -> windows::core::Result<Option<u32>> { |
| direct_child_pid_in_snapshot(parent_pid, &process_snapshot()?) |
| } |
| |
| fn process_snapshot() -> windows::core::Result<Vec<PROCESSENTRY32W>> { |
| // SAFETY: the snapshot handle is converted immediately to OwnedHandle, and the initialized |
| // PROCESSENTRY32W layout matches the ToolHelp API contract. |
| unsafe { |
| let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)?; |
| let snapshot = OwnedHandle::from_raw_handle(snapshot.0); |
| let handle = HANDLE(snapshot.as_raw_handle()); |
| let mut entry = PROCESSENTRY32W { |
| dwSize: size_of::<PROCESSENTRY32W>() as u32, |
| ..Default::default() |
| }; |
| Process32FirstW(handle, &raw mut entry)?; |
| let mut processes = Vec::new(); |
| loop { |
| processes.push(entry); |
| match Process32NextW(handle, &raw mut entry) { |
| Ok(()) => {} |
| Err(error) if error.code().0 as u32 == 0x80070012 => break, |
| Err(error) => return Err(error), |
| } |
| } |
| Ok(processes) |
| } |
| } |
| |
| fn direct_child_pid_in_snapshot( |
| parent_pid: u32, |
| processes: &[PROCESSENTRY32W], |
| ) -> windows::core::Result<Option<u32>> { |
| if !processes |
| .iter() |
| .any(|process| process.th32ProcessID == parent_pid) |
| { |
| return Err(windows::core::Error::new( |
| windows::core::HRESULT(0x80070002_u32 as i32), |
| "The Windows Runtime Host supervisor process is not available", |
| )); |
| } |
| let mut child = None; |
| for process in processes { |
| if process.th32ParentProcessID == parent_pid |
| && child.replace(process.th32ProcessID).is_some() |
| { |
| return Err(windows::core::Error::new( |
| windows::core::HRESULT(0x8007000D_u32 as i32), |
| "The Windows Runtime Host supervisor has multiple direct children", |
| )); |
| } |
| } |
| Ok(child) |
| } |
| |
| fn wait_until_task_stopped(task: &IRegisteredTask) -> windows::core::Result<()> { |
| let deadline = Instant::now() + STOP_TIMEOUT; |
| while Instant::now() < deadline { |
| // SAFETY: task and the returned collection are live thread-local COM interfaces. |
| if unsafe { task.GetInstances(0)?.Count()? } == 0 { |
| return Ok(()); |
| } |
| std::thread::sleep(Duration::from_millis(50)); |
| } |
| Err(windows::core::Error::new( |
| windows::core::HRESULT(0x800705B4_u32 as i32), |
| "The Windows scheduled task did not stop", |
| )) |
| } |
| |
| fn task_owned_process_ids(task: &IRegisteredTask) -> windows::core::Result<HashSet<u32>> { |
| // SAFETY: task, instances, and the returned collection are live thread-local COM interfaces. |
| let instances = unsafe { task.GetInstances(0)? }; |
| let count = unsafe { instances.Count()? }; |
| let processes = process_snapshot()?; |
| let mut owned = HashSet::new(); |
| for index in 1..=count { |
| let wrapper_pid = unsafe { instances.get_Item(&VARIANT::from(index))?.EnginePID()? }; |
| if let Some(host_pid) = direct_child_pid_in_snapshot(wrapper_pid, &processes)? { |
| owned.insert(host_pid); |
| } |
| } |
| loop { |
| let before = owned.len(); |
| for process in &processes { |
| if owned.contains(&process.th32ParentProcessID) { |
| owned.insert(process.th32ProcessID); |
| } |
| } |
| if owned.len() == before { |
| return Ok(owned); |
| } |
| } |
| } |
| |
| fn wait_until_processes_exit(process_ids: &HashSet<u32>) -> windows::core::Result<()> { |
| let deadline = Instant::now() + STOP_TIMEOUT; |
| while Instant::now() < deadline { |
| if process_snapshot()? |
| .iter() |
| .all(|process| !process_ids.contains(&process.th32ProcessID)) |
| { |
| return Ok(()); |
| } |
| std::thread::sleep(Duration::from_millis(50)); |
| } |
| Err(windows::core::Error::new( |
| windows::core::HRESULT(0x800705B4_u32 as i32), |
| "The Windows Runtime Host process tree did not stop", |
| )) |
| } |
| |
| fn stop_task(task: &IRegisteredTask) -> windows::core::Result<()> { |
| // SAFETY: task and the returned collection are live thread-local COM interfaces. |
| if unsafe { task.GetInstances(0)?.Count()? } > 0 { |
| let process_ids = task_owned_process_ids(task)?; |
| unsafe { task.Stop(0)? }; |
| wait_until_task_stopped(task)?; |
| wait_until_processes_exit(&process_ids)?; |
| } |
| Ok(()) |
| } |
| |
| fn render_task_xml( |
| root_id: &str, |
| target: Target, |
| launcher_path: &str, |
| command: &[String], |
| user: &str, |
| ) -> windows::core::Result<String> { |
| let marker = ownership_marker(root_id, target); |
| let trigger = match target { |
| Target::Host => format!( |
| "<LogonTrigger><Enabled>true</Enabled><UserId>{}</UserId></LogonTrigger>", |
| xml_escape(user) |
| ), |
| Target::Reconciliation => concat!( |
| "<RegistrationTrigger><Enabled>true</Enabled><Delay>PT15M</Delay></RegistrationTrigger>", |
| "<CalendarTrigger><StartBoundary>2000-01-01T03:00:00</StartBoundary>", |
| "<Enabled>true</Enabled><RandomDelay>PT1H</RandomDelay>", |
| "<ScheduleByDay><DaysInterval>1</DaysInterval></ScheduleByDay></CalendarTrigger>" |
| ) |
| .to_owned(), |
| }; |
| let action_command = task_action_command(target, launcher_path, command)?; |
| let arguments = command_line(&action_command[1..]); |
| Ok(format!( |
| concat!( |
| "<?xml version=\"1.0\" encoding=\"UTF-16\"?>", |
| "<Task version=\"1.4\" xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">", |
| "<RegistrationInfo><Description>{marker}</Description></RegistrationInfo>", |
| "<Triggers>{trigger}</Triggers>", |
| "<Principals><Principal id=\"Maka\"><UserId>{user}</UserId><LogonType>InteractiveToken</LogonType>", |
| "<RunLevel>LeastPrivilege</RunLevel></Principal></Principals>", |
| "<Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>", |
| "<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>", |
| "<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>", |
| "<AllowHardTerminate>true</AllowHardTerminate><StartWhenAvailable>true</StartWhenAvailable>", |
| "<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>", |
| "<IdleSettings><StopOnIdleEnd>false</StopOnIdleEnd><RestartOnIdle>false</RestartOnIdle></IdleSettings>", |
| "<AllowStartOnDemand>true</AllowStartOnDemand><Enabled>true</Enabled><Hidden>false</Hidden>", |
| "<RunOnlyIfIdle>false</RunOnlyIfIdle><WakeToRun>false</WakeToRun>", |
| "<ExecutionTimeLimit>PT0S</ExecutionTimeLimit><Priority>7</Priority></Settings>", |
| "<Actions Context=\"Maka\"><Exec><Command>{executable}</Command>", |
| "<Arguments>{arguments}</Arguments></Exec></Actions></Task>" |
| ), |
| marker = marker, |
| trigger = trigger, |
| executable = xml_escape(&action_command[0]), |
| arguments = xml_escape(&arguments), |
| user = xml_escape(user), |
| )) |
| } |
| |
| fn task_action_command( |
| target: Target, |
| launcher_path: &str, |
| command: &[String], |
| ) -> windows::core::Result<Vec<String>> { |
| if !Path::new(launcher_path).is_absolute() |
| || launcher_path.contains('%') |
| || command[0].contains('%') |
| || (matches!(target, Target::Host) |
| && (command.len() < 4 || command[2] != "runtime-host" || command[3] != "serve")) |
| { |
| return Err(invalid_windows_request()); |
| } |
| let mode = match target { |
| Target::Host => "--supervise", |
| Target::Reconciliation => "--once", |
| }; |
| let mut projected = vec![launcher_path.to_owned(), mode.to_owned()]; |
| projected.extend( |
| command |
| .iter() |
| .map(|argument| URL_SAFE_NO_PAD.encode(argument.as_bytes())), |
| ); |
| if command_line(&projected[1..]).encode_utf16().count() >= 32_767 { |
| return Err(invalid_windows_request()); |
| } |
| Ok(projected) |
| } |
| |
| fn launcher_for_legacy_runner(runner_path: &str) -> windows::core::Result<String> { |
| let runner = Path::new(runner_path); |
| let valid_runner = runner.is_absolute() |
| && runner |
| .file_name() |
| .and_then(|name| name.to_str()) |
| .is_some_and(|name| name.eq_ignore_ascii_case("runtime-host-windows-task-runner.js")) |
| && runner |
| .parent() |
| .and_then(Path::file_name) |
| .and_then(|name| name.to_str()) |
| .is_some_and(|name| name.eq_ignore_ascii_case("dist")); |
| let package_root = valid_runner |
| .then_some(runner) |
| .and_then(Path::parent) |
| .and_then(Path::parent) |
| .ok_or_else(invalid_windows_request)?; |
| let launcher = package_root |
| .join("native") |
| .join("runtime-host-windows-task-launcher") |
| .join("prebuilds") |
| .join("win32-x64") |
| .join("maka-runtime-host-task-launcher.exe"); |
| let launcher = std::fs::canonicalize(launcher).map_err(|_| invalid_windows_request())?; |
| if !launcher.is_file() { |
| return Err(invalid_windows_request()); |
| } |
| canonical_windows_path(&launcher).ok_or_else(invalid_windows_request) |
| } |
| |
| fn canonical_windows_path(path: &Path) -> Option<String> { |
| let path = path.to_str()?; |
| if let Some(path) = path.strip_prefix(r"\\?\UNC\") { |
| return Some(format!(r"\\{path}")); |
| } |
| Some(path.strip_prefix(r"\\?\").unwrap_or(path).to_owned()) |
| } |
| |
| fn ownership_marker(root_id: &str, target: Target) -> String { |
| format!( |
| "maka-runtime-host/windows-task/v1/{root_id}/{}", |
| match target { |
| Target::Host => "host", |
| Target::Reconciliation => "reconciliation", |
| } |
| ) |
| } |
| |
| fn task_name(root_id: &str, target: Target) -> String { |
| format!( |
| "Maka-RuntimeHost-{root_id}{}", |
| match target { |
| Target::Host => "", |
| Target::Reconciliation => "-Reconciliation", |
| } |
| ) |
| } |
| |
| fn validate_root_id(root_id: &str) -> std::result::Result<(), ()> { |
| if root_id.len() == ROOT_ID_BYTES |
| && root_id |
| .bytes() |
| .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) |
| { |
| Ok(()) |
| } else { |
| Err(()) |
| } |
| } |
| |
| fn require_target(root_id: &str, target: &str) -> Result<Target> { |
| require_root_id(root_id)?; |
| match target { |
| "host" => Ok(Target::Host), |
| "reconciliation" => Ok(Target::Reconciliation), |
| _ => Err(invalid("Windows lifecycle task target is invalid")), |
| } |
| } |
| |
| fn require_root_id(root_id: &str) -> Result<()> { |
| validate_root_id(root_id).map_err(|_| invalid("Windows lifecycle Root ID is invalid")) |
| } |
| |
| fn validate_command(command: &[String]) -> std::result::Result<(), ()> { |
| if command.is_empty() |
| || command.len() > MAX_COMMAND_ARGUMENTS |
| || !Path::new(&command[0]).is_absolute() |
| || command.iter().any(|argument| { |
| argument.is_empty() |
| || argument.len() > MAX_ARGUMENT_BYTES |
| || argument.chars().any(char::is_control) |
| }) |
| { |
| return Err(()); |
| } |
| let utf16_length = command_line(command).encode_utf16().count(); |
| if utf16_length >= 32_767 { |
| return Err(()); |
| } |
| Ok(()) |
| } |
| |
| fn command_line(arguments: &[String]) -> String { |
| arguments |
| .iter() |
| .map(|argument| quote_windows_argument(argument)) |
| .collect::<Vec<_>>() |
| .join(" ") |
| } |
| |
| fn quote_windows_argument(argument: &str) -> String { |
| if !argument.is_empty() |
| && !argument |
| .chars() |
| .any(|character| character.is_whitespace() || character == '"') |
| { |
| return argument.to_owned(); |
| } |
| let mut quoted = String::from("\""); |
| let mut backslashes = 0; |
| for character in argument.chars() { |
| if character == '\\' { |
| backslashes += 1; |
| } else if character == '"' { |
| quoted.push_str(&"\\".repeat(backslashes * 2 + 1)); |
| quoted.push('"'); |
| backslashes = 0; |
| } else { |
| quoted.push_str(&"\\".repeat(backslashes)); |
| quoted.push(character); |
| backslashes = 0; |
| } |
| } |
| quoted.push_str(&"\\".repeat(backslashes * 2)); |
| quoted.push('"'); |
| quoted |
| } |
| |
| fn xml_escape(value: &str) -> String { |
| value |
| .replace('&', "&") |
| .replace('<', "<") |
| .replace('>', ">") |
| .replace('"', """) |
| .replace('\'', "'") |
| } |
| |
| struct ComApartment { |
| uninitialize: bool, |
| } |
| |
| impl ComApartment { |
| fn initialize() -> windows::core::Result<Self> { |
| // SAFETY: balances successful initialization on this thread in Drop. |
| let result = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }; |
| if result.is_ok() { |
| Ok(Self { uninitialize: true }) |
| } else if result == RPC_E_CHANGED_MODE { |
| Ok(Self { |
| uninitialize: false, |
| }) |
| } else { |
| result.ok()?; |
| unreachable!() |
| } |
| } |
| } |
| |
| impl Drop for ComApartment { |
| fn drop(&mut self) { |
| if self.uninitialize { |
| // SAFETY: paired with this thread's successful CoInitializeEx call. |
| unsafe { CoUninitialize() }; |
| } |
| } |
| } |
| |
| fn invalid(message: &str) -> NapiError { |
| NapiError::new(Status::InvalidArg, message) |
| } |
| |
| fn invalid_windows_request() -> windows::core::Error { |
| windows::core::Error::new( |
| windows::core::HRESULT(0x80070057_u32 as i32), |
| "Windows lifecycle request is invalid", |
| ) |
| } |
| |
| fn native_error(error: impl std::fmt::Display) -> NapiError { |
| NapiError::new(Status::GenericFailure, error.to_string()) |
| } |