1use crate::util::{err, new_id, sleep_ms, Result};
27use serde::{Deserialize, Serialize};
28use std::collections::BTreeMap;
29use std::fs::File;
30use std::path::{Path, PathBuf};
31use std::time::{Duration, Instant};
32
33pub const OPT_IN_ENV: &str = "RIGHTKIT_QA_INTERACTIVE";
35pub const CHILD_MARKER_ENV: &str = "RIGHTKIT_QA_INTERACTIVE_CHILD";
37pub const TASK_FOLDER: &str = "\\RightKit\\QA\\";
39pub const WRAPPER_FAILED: i32 = 125;
41pub const SPEC_FILE: &str = "spec.json";
43pub const OUT_FILE: &str = "stdout.log";
44pub const ERR_FILE: &str = "stderr.log";
45pub const EXIT_FILE: &str = "exit.txt";
46pub const PID_FILE: &str = "pid.txt";
47pub const TASK_XML_FILE: &str = "task.xml";
48
49pub fn opted_in(flag: bool, env_value: Option<&str>) -> bool {
51 flag || matches!(
52 env_value.map(|v| v.trim().to_ascii_lowercase()).as_deref(),
53 Some("1") | Some("true")
54 )
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct Account {
60 pub domain: String,
61 pub user: String,
62}
63
64impl Account {
65 pub fn new(domain: impl Into<String>, user: impl Into<String>) -> Self {
66 Account {
67 domain: domain.into(),
68 user: user.into(),
69 }
70 }
71 pub fn qualified(&self) -> String {
72 if self.domain.is_empty() {
73 self.user.clone()
74 } else {
75 format!("{}\\{}", self.domain, self.user)
76 }
77 }
78}
79
80pub fn same_account(a: &Account, b: &Account) -> bool {
83 a.user.to_lowercase() == b.user.to_lowercase()
84 && (a.domain.is_empty()
85 || b.domain.is_empty()
86 || a.domain.to_lowercase() == b.domain.to_lowercase())
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum ConsoleUser {
92 Unknown,
94 NobodyLoggedOn,
96 User(Account),
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct SessionFacts {
102 pub session_id: Option<u32>,
103 pub visible_station: Option<bool>,
105 pub console_user: ConsoleUser,
106 pub current_user: Option<Account>,
107}
108
109pub fn non_interactive_reason(f: &SessionFacts) -> Option<String> {
112 match (f.session_id, f.visible_station) {
113 (Some(0), _) => Some("session 0".into()),
114 (s, Some(false)) => Some(format!(
115 "session {}, non-visible window station",
116 s.map(|v| v.to_string()).unwrap_or_else(|| "?".into())
117 )),
118 _ => None,
119 }
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub enum Decision {
124 Direct,
126 Relaunch(Account),
128 NotOptedIn {
129 reason: String,
130 },
131 NoInteractiveUser {
132 reason: String,
133 },
134 WrongUser {
135 current: String,
136 console: String,
137 },
138}
139
140pub fn decide(f: &SessionFacts, opt_in: bool) -> Decision {
141 let Some(reason) = non_interactive_reason(f) else {
142 return Decision::Direct;
143 };
144 if !opt_in {
145 return Decision::NotOptedIn { reason };
146 }
147 let console = match &f.console_user {
148 ConsoleUser::NobodyLoggedOn => {
149 return Decision::NoInteractiveUser { reason };
150 }
151 ConsoleUser::Unknown => None,
152 ConsoleUser::User(a) => Some(a),
153 };
154 let Some(current) = f.current_user.clone() else {
155 return Decision::WrongUser {
156 current: "<unknown>".into(),
157 console: console
158 .map(|a| a.qualified())
159 .unwrap_or_else(|| "<unknown>".into()),
160 };
161 };
162 match console {
163 Some(c) if !same_account(¤t, c) => Decision::WrongUser {
164 current: current.qualified(),
165 console: c.qualified(),
166 },
167 _ => Decision::Relaunch(current),
168 }
169}
170
171impl Decision {
172 pub fn refusal(&self) -> Option<String> {
174 match self {
175 Decision::Direct | Decision::Relaunch(_) => None,
176 Decision::NotOptedIn { reason } => Some(format!(
177 "this harness process is not in the interactive desktop session ({reason}); a hidden WebView2 app cannot get a ready window from here. Set {OPT_IN_ENV}=1 (or pass --interactive) to run the journey in the logged-on user's session through a one-shot Task Scheduler task"
178 )),
179 Decision::NoInteractiveUser { reason } => Some(format!(
180 "this harness process is not in the interactive desktop session ({reason}) and nobody is logged on at the console, so there is no interactive session to run in. Log on at the machine and retry"
181 )),
182 Decision::WrongUser { current, console } => Some(format!(
183 "the logged-on interactive user is {console} but this process runs as {current}; the interactive run is only started for the same account (no password is ever used). Sign in over SSH as {console}, or log the console user off"
184 )),
185 }
186 }
187}
188
189pub fn quote_windows_arg(arg: &str) -> String {
191 if !arg.is_empty() && !arg.contains([' ', '\t', '\n', '\u{b}', '"']) {
192 return arg.to_string();
193 }
194 let mut out = String::from("\"");
195 let mut backslashes = 0usize;
196 for c in arg.chars() {
197 match c {
198 '\\' => backslashes += 1,
199 '"' => {
200 out.push_str(&"\\".repeat(backslashes * 2 + 1));
201 out.push('"');
202 backslashes = 0;
203 }
204 _ => {
205 out.push_str(&"\\".repeat(backslashes));
206 backslashes = 0;
207 out.push(c);
208 }
209 }
210 }
211 out.push_str(&"\\".repeat(backslashes * 2));
212 out.push('"');
213 out
214}
215
216fn xml_escape(s: &str) -> Result<String> {
217 let mut out = String::with_capacity(s.len());
218 for c in s.chars() {
219 match c {
220 '&' => out.push_str("&"),
221 '<' => out.push_str("<"),
222 '>' => out.push_str(">"),
223 '"' => out.push_str("""),
224 '\'' => out.push_str("'"),
225 c if (c as u32) < 0x20 && !matches!(c, '\t' | '\n' | '\r') => {
226 return err(format!(
227 "control character U+{:04X} cannot go into a task definition",
228 c as u32
229 ));
230 }
231 c => out.push(c),
232 }
233 }
234 Ok(out)
235}
236
237#[derive(Debug, Clone)]
239pub struct TaskSpec {
240 pub user: String,
242 pub exe: PathBuf,
243 pub arguments: String,
245 pub working_dir: PathBuf,
246 pub time_limit_secs: u64,
247 pub description: String,
248}
249
250pub fn task_xml(t: &TaskSpec) -> Result<String> {
253 let s = |p: &Path| -> Result<String> {
254 match p.to_str() {
255 Some(v) => xml_escape(v),
256 None => err(format!("path is not valid Unicode: {}", p.display())),
257 }
258 };
259 Ok(format!(
260 concat!(
261 "<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n",
262 "<Task version=\"1.2\" xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
263 " <RegistrationInfo><Description>{desc}</Description></RegistrationInfo>\n",
264 " <Principals><Principal id=\"Author\"><UserId>{user}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>\n",
265 " <Settings>\n",
266 " <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n",
267 " <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n",
268 " <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n",
269 " <AllowHardTerminate>true</AllowHardTerminate>\n",
270 " <StartWhenAvailable>false</StartWhenAvailable>\n",
271 " <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n",
272 " <IdleSettings><StopOnIdleEnd>false</StopOnIdleEnd><RestartOnIdle>false</RestartOnIdle></IdleSettings>\n",
273 " <AllowStartOnDemand>true</AllowStartOnDemand>\n",
274 " <Enabled>true</Enabled>\n",
275 " <Hidden>false</Hidden>\n",
276 " <RunOnlyIfIdle>false</RunOnlyIfIdle>\n",
277 " <WakeToRun>false</WakeToRun>\n",
278 " <ExecutionTimeLimit>PT{limit}S</ExecutionTimeLimit>\n",
279 " <Priority>7</Priority>\n",
280 " </Settings>\n",
281 " <Actions Context=\"Author\"><Exec><Command>{cmd}</Command><Arguments>{args}</Arguments><WorkingDirectory>{wd}</WorkingDirectory></Exec></Actions>\n",
282 "</Task>\n"
283 ),
284 desc = xml_escape(&t.description)?,
285 user = xml_escape(&t.user)?,
286 limit = t.time_limit_secs.max(1),
287 cmd = s(&t.exe)?,
288 args = xml_escape(&t.arguments)?,
289 wd = s(&t.working_dir)?,
290 ))
291}
292
293pub fn utf16le_with_bom(s: &str) -> Vec<u8> {
295 let mut out = vec![0xFF, 0xFE];
296 for u in s.encode_utf16() {
297 out.extend_from_slice(&u.to_le_bytes());
298 }
299 out
300}
301
302pub fn parse_exit_code(text: &str) -> Result<i32> {
305 let t = text.trim();
306 let n: i64 = match t.parse() {
307 Ok(n) => n,
308 Err(_) => return err(format!("exit code file is not a number: {t:?}")),
309 };
310 if n >= i32::MIN as i64 && n <= i32::MAX as i64 {
311 Ok(n as i32)
312 } else if n > i32::MAX as i64 && n <= u32::MAX as i64 {
313 Ok(n as u32 as i32)
314 } else {
315 err(format!("exit code out of range: {n}"))
316 }
317}
318
319pub fn select_env(
323 vars: impl IntoIterator<Item = (String, String)>,
324 pass: &[String],
325 explicit: &[(String, String)],
326) -> Result<BTreeMap<String, String>> {
327 let mut out = BTreeMap::new();
328 for (k, v) in vars {
329 let upper = k.to_ascii_uppercase();
330 let internal = upper == OPT_IN_ENV || upper == CHILD_MARKER_ENV;
331 let named = pass.iter().any(|p| p.eq_ignore_ascii_case(&k));
332 if !internal && (upper.starts_with("RIGHTKIT_") || named) {
333 out.insert(k, v);
334 }
335 }
336 for (k, v) in explicit {
337 if k.is_empty() || k.contains(['=', '\0']) || v.contains('\0') {
338 return err(format!("invalid environment entry {k:?}"));
339 }
340 out.insert(k.clone(), v.clone());
341 }
342 Ok(out)
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
347pub struct ChildSpec {
348 pub program: String,
349 pub args: Vec<String>,
350 pub cwd: Option<PathBuf>,
351 pub env: BTreeMap<String, String>,
352 pub stdout: PathBuf,
353 pub stderr: PathBuf,
354 pub exit: PathBuf,
355 pub pid: PathBuf,
356}
357
358pub fn child_main(spec_path: &Path) -> i32 {
362 let spec: ChildSpec = match std::fs::read(spec_path)
363 .map_err(|e| e.to_string())
364 .and_then(|b| serde_json::from_slice(&b).map_err(|e| e.to_string()))
365 {
366 Ok(s) => s,
367 Err(e) => {
368 eprintln!(
369 "rightkit-qa-interactive: cannot read {}: {e}",
370 spec_path.display()
371 );
372 return WRAPPER_FAILED;
373 }
374 };
375 let _ = std::fs::remove_file(spec_path);
377 let code = match run_child(&spec) {
378 Ok(c) => c,
379 Err(e) => {
380 use std::io::Write;
381 if let Ok(mut f) = std::fs::OpenOptions::new()
382 .create(true)
383 .append(true)
384 .open(&spec.stderr)
385 {
386 let _ = writeln!(f, "rightkit-qa-interactive: {e}");
387 }
388 WRAPPER_FAILED
389 }
390 };
391 let tmp = spec.exit.with_extension("tmp");
392 if std::fs::write(&tmp, format!("{code}\n")).is_ok() {
393 let _ = std::fs::rename(&tmp, &spec.exit);
394 }
395 code
396}
397
398fn run_child(spec: &ChildSpec) -> Result<i32> {
399 use std::process::{Command, Stdio};
400 let out = File::create(&spec.stdout)?;
401 let errf = File::create(&spec.stderr)?;
402 let mut cmd = Command::new(&spec.program);
403 #[cfg(windows)]
404 {
405 use std::os::windows::process::CommandExt;
406 cmd.creation_flags(0x0800_0000); }
408 cmd.args(&spec.args)
409 .stdin(Stdio::null())
410 .stdout(Stdio::from(out))
411 .stderr(Stdio::from(errf))
412 .env_remove(OPT_IN_ENV)
413 .env(CHILD_MARKER_ENV, "1");
414 if let Some(cwd) = &spec.cwd {
415 cmd.current_dir(cwd);
416 }
417 cmd.envs(&spec.env);
418 let mut child = cmd
419 .spawn()
420 .map_err(|e| crate::Error(format!("cannot start {}: {e}", spec.program)))?;
421 let _ = std::fs::write(&spec.pid, child.id().to_string());
422 let status = child.wait()?;
423 Ok(status.code().unwrap_or(-1))
424}
425
426pub trait Scheduler {
428 fn create(&self, name: &str, xml_path: &Path) -> Result<()>;
430 fn run(&self, name: &str) -> Result<()>;
431 fn end(&self, name: &str);
433 fn delete(&self, name: &str);
435 fn describe(&self, name: &str) -> String;
437 fn kill_tree(&self, pid: u32);
439}
440
441pub struct Schtasks;
443
444impl Schtasks {
445 fn exe() -> PathBuf {
446 match std::env::var_os("SystemRoot") {
447 Some(root) => PathBuf::from(root).join("System32").join("schtasks.exe"),
448 None => PathBuf::from("schtasks.exe"),
449 }
450 }
451 #[cfg(windows)]
452 fn call(args: &[&str]) -> Result<(bool, String)> {
453 use std::os::windows::process::CommandExt;
454 let out = crate::process::output_bounded(
455 std::process::Command::new(Self::exe())
456 .args(args)
457 .creation_flags(0x0800_0000),
458 Duration::from_secs(60),
459 )
460 .map_err(|e| crate::Error(format!("cannot run schtasks.exe: {e}")))?
461 .ok_or_else(|| crate::Error("schtasks.exe did not finish within 60s".into()))?;
462 let mut text = String::from_utf8_lossy(&out.stdout).into_owned();
463 text.push_str(&String::from_utf8_lossy(&out.stderr));
464 Ok((out.status.success(), text.trim().to_string()))
465 }
466 #[cfg(not(windows))]
467 fn call(_args: &[&str]) -> Result<(bool, String)> {
468 let _ = Self::exe();
469 err("the interactive Task Scheduler run is Windows only")
470 }
471}
472
473impl Scheduler for Schtasks {
474 fn create(&self, name: &str, xml_path: &Path) -> Result<()> {
475 let xml = xml_path.to_string_lossy();
476 let (ok, text) = Self::call(&["/create", "/tn", name, "/xml", &xml])?;
477 if ok {
478 Ok(())
479 } else {
480 err(format!("schtasks /create failed: {text}"))
481 }
482 }
483 fn run(&self, name: &str) -> Result<()> {
484 let (ok, text) = Self::call(&["/run", "/tn", name])?;
485 if ok {
486 Ok(())
487 } else {
488 err(format!("schtasks /run failed: {text}"))
489 }
490 }
491 fn end(&self, name: &str) {
492 let _ = Self::call(&["/end", "/tn", name]);
493 }
494 fn delete(&self, name: &str) {
495 let _ = Self::call(&["/delete", "/tn", name, "/f"]);
496 }
497 fn describe(&self, name: &str) -> String {
498 match Self::call(&["/query", "/tn", name, "/v", "/fo", "list"]) {
499 Ok((_, text)) => crate::util::tail(&text, 1500),
500 Err(e) => e.to_string(),
501 }
502 }
503 fn kill_tree(&self, pid: u32) {
504 crate::process::kill_tree(pid);
505 }
506}
507
508#[derive(Debug, Clone, Copy, PartialEq, Eq)]
509pub enum Stream {
510 Stdout,
511 Stderr,
512}
513
514#[derive(Debug, Clone)]
516pub struct Request {
517 pub program: String,
518 pub args: Vec<String>,
519 pub cwd: Option<PathBuf>,
520 pub env: BTreeMap<String, String>,
521 pub timeout: Duration,
523 pub child_exe: PathBuf,
525 pub account: Account,
526 pub poll: Duration,
527 pub start_grace: Duration,
529}
530
531struct Cleanup<'a, S: Scheduler> {
534 sched: &'a S,
535 task: Option<String>,
536 dir: PathBuf,
537}
538
539impl<S: Scheduler> Drop for Cleanup<'_, S> {
540 fn drop(&mut self) {
541 if let Some(task) = &self.task {
542 self.sched.end(task);
543 if !self.dir.join(EXIT_FILE).exists() {
544 if let Some(pid) = read_pid(&self.dir.join(PID_FILE)) {
545 self.sched.kill_tree(pid);
546 }
547 }
548 self.sched.delete(task);
549 }
550 for _ in 0..10 {
551 if std::fs::remove_dir_all(&self.dir).is_ok() || !self.dir.exists() {
552 break;
553 }
554 sleep_ms(100);
555 }
556 }
557}
558
559fn read_pid(p: &Path) -> Option<u32> {
560 std::fs::read_to_string(p).ok()?.trim().parse().ok()
561}
562
563struct Tail {
564 path: PathBuf,
565 offset: u64,
566}
567
568impl Tail {
569 fn drain(&mut self, f: &mut dyn FnMut(&[u8])) {
570 use std::io::{Read, Seek, SeekFrom};
571 let Ok(mut file) = File::open(&self.path) else {
572 return;
573 };
574 if file.seek(SeekFrom::Start(self.offset)).is_err() {
575 return;
576 }
577 let mut buf = Vec::new();
578 if file.read_to_end(&mut buf).is_ok() && !buf.is_empty() {
579 self.offset += buf.len() as u64;
580 f(&buf);
581 }
582 }
583}
584
585pub fn run_interactive<S: Scheduler>(
589 sched: &S,
590 req: &Request,
591 sink: &mut dyn FnMut(Stream, &[u8]),
592) -> Result<i32> {
593 let id = new_id();
594 let dir = std::env::temp_dir().join(format!("rightkit-qa-interactive-{id}"));
595 std::fs::create_dir_all(&dir)?;
596 let mut guard = Cleanup {
597 sched,
598 task: None,
599 dir: dir.clone(),
600 };
601 let (out_p, err_p, exit_p, pid_p) = (
602 dir.join(OUT_FILE),
603 dir.join(ERR_FILE),
604 dir.join(EXIT_FILE),
605 dir.join(PID_FILE),
606 );
607 let spec = ChildSpec {
608 program: req.program.clone(),
609 args: req.args.clone(),
610 cwd: req.cwd.clone(),
611 env: req.env.clone(),
612 stdout: out_p.clone(),
613 stderr: err_p.clone(),
614 exit: exit_p.clone(),
615 pid: pid_p.clone(),
616 };
617 let spec_p = dir.join(SPEC_FILE);
618 std::fs::write(&spec_p, serde_json::to_vec(&spec)?)?;
619 let spec_str = match spec_p.to_str() {
620 Some(s) => s.to_string(),
621 None => return err("temp directory path is not valid Unicode"),
622 };
623 let xml = task_xml(&TaskSpec {
624 user: req.account.qualified(),
625 exe: req.child_exe.clone(),
626 arguments: format!("__child {}", quote_windows_arg(&spec_str)),
627 working_dir: dir.clone(),
628 time_limit_secs: req.timeout.as_secs() + 30,
629 description: format!("rightkit-qa one-shot interactive journey {id}"),
630 })?;
631 let xml_p = dir.join(TASK_XML_FILE);
632 std::fs::write(&xml_p, utf16le_with_bom(&xml))?;
633
634 let name = format!("{TASK_FOLDER}{id}");
635 guard.task = Some(name.clone());
637 sched.create(&name, &xml_p)?;
638 sched.run(&name)?;
639
640 let mut tail_out = Tail {
641 path: out_p,
642 offset: 0,
643 };
644 let mut tail_err = Tail {
645 path: err_p,
646 offset: 0,
647 };
648 let start = Instant::now();
649 let deadline = start + req.timeout;
650 loop {
651 tail_out.drain(&mut |b| sink(Stream::Stdout, b));
652 tail_err.drain(&mut |b| sink(Stream::Stderr, b));
653 if let Ok(text) = std::fs::read_to_string(&exit_p) {
654 tail_out.drain(&mut |b| sink(Stream::Stdout, b));
655 tail_err.drain(&mut |b| sink(Stream::Stderr, b));
656 return parse_exit_code(&text);
657 }
658 if Instant::now() >= deadline {
659 return err(format!(
660 "interactive journey timed out after {}s (the task and the journey process were stopped)",
661 req.timeout.as_secs()
662 ));
663 }
664 if !pid_p.exists() && start.elapsed() >= req.start_grace {
665 return err(format!(
666 "the interactive task did not start the journey within {}s. It only runs while {} is logged on at the console. Task Scheduler says: {}",
667 req.start_grace.as_secs(),
668 req.account.qualified(),
669 sched.describe(&name)
670 ));
671 }
672 std::thread::sleep(req.poll);
673 }
674}
675
676#[cfg(not(windows))]
678pub fn session_facts() -> SessionFacts {
679 SessionFacts {
680 session_id: None,
681 visible_station: None,
682 console_user: ConsoleUser::Unknown,
683 current_user: None,
684 }
685}
686
687#[cfg(windows)]
688pub fn session_facts() -> SessionFacts {
689 use std::ffi::c_void;
690 #[repr(C)]
691 struct UserObjectFlags {
692 inherit: i32,
693 reserved: i32,
694 flags: u32,
695 }
696 #[link(name = "user32")]
697 extern "system" {
698 fn GetProcessWindowStation() -> *mut c_void;
699 fn GetUserObjectInformationW(
700 h: *mut c_void,
701 index: i32,
702 info: *mut c_void,
703 len: u32,
704 needed: *mut u32,
705 ) -> i32;
706 }
707 #[link(name = "kernel32")]
708 extern "system" {
709 fn GetCurrentProcessId() -> u32;
710 fn ProcessIdToSessionId(pid: u32, session: *mut u32) -> i32;
711 fn WTSGetActiveConsoleSessionId() -> u32;
712 }
713 #[link(name = "wtsapi32")]
714 extern "system" {
715 fn WTSQuerySessionInformationW(
716 server: *mut c_void,
717 session: u32,
718 class: i32,
719 buffer: *mut *mut u16,
720 bytes: *mut u32,
721 ) -> i32;
722 fn WTSFreeMemory(p: *mut c_void);
723 }
724 #[link(name = "advapi32")]
725 extern "system" {
726 fn GetUserNameW(buf: *mut u16, len: *mut u32) -> i32;
727 }
728 const UOI_FLAGS: i32 = 1;
729 const WSF_VISIBLE: u32 = 1;
730 const WTS_USER_NAME: i32 = 5;
731 const WTS_DOMAIN_NAME: i32 = 7;
732 const NO_SESSION: u32 = 0xFFFF_FFFF;
733
734 unsafe {
736 let mut session = 0u32;
737 let session_id = if ProcessIdToSessionId(GetCurrentProcessId(), &mut session) != 0 {
738 Some(session)
739 } else {
740 None
741 };
742 let mut visible_station = None;
743 let station = GetProcessWindowStation();
744 if !station.is_null() {
745 let mut info = UserObjectFlags {
746 inherit: 0,
747 reserved: 0,
748 flags: 0,
749 };
750 let mut needed = 0u32;
751 if GetUserObjectInformationW(
752 station,
753 UOI_FLAGS,
754 &mut info as *mut _ as *mut c_void,
755 std::mem::size_of::<UserObjectFlags>() as u32,
756 &mut needed,
757 ) != 0
758 {
759 visible_station = Some(info.flags & WSF_VISIBLE != 0);
760 }
761 }
762 let wts = |sid: u32, class: i32| -> Option<String> {
763 let mut buf: *mut u16 = std::ptr::null_mut();
764 let mut bytes = 0u32;
765 if WTSQuerySessionInformationW(std::ptr::null_mut(), sid, class, &mut buf, &mut bytes)
766 == 0
767 || buf.is_null()
768 {
769 return None;
770 }
771 let mut len = 0usize;
772 while *buf.add(len) != 0 {
773 len += 1;
774 }
775 let s = String::from_utf16_lossy(std::slice::from_raw_parts(buf, len));
776 WTSFreeMemory(buf as *mut c_void);
777 Some(s)
778 };
779 let console = WTSGetActiveConsoleSessionId();
780 let console_user = if console == NO_SESSION {
781 ConsoleUser::NobodyLoggedOn
782 } else {
783 match wts(console, WTS_USER_NAME) {
784 None => ConsoleUser::Unknown,
785 Some(u) if u.trim().is_empty() => ConsoleUser::NobodyLoggedOn,
786 Some(u) => ConsoleUser::User(Account::new(
787 wts(console, WTS_DOMAIN_NAME).unwrap_or_default(),
788 u,
789 )),
790 }
791 };
792 let mut name = [0u16; 257];
793 let mut n = name.len() as u32;
794 let current_user = if GetUserNameW(name.as_mut_ptr(), &mut n) != 0 && n > 1 {
795 let user = String::from_utf16_lossy(&name[..(n as usize - 1)]);
796 let domain = std::env::var("USERDOMAIN").unwrap_or_default();
797 Some(Account::new(domain, user))
798 } else {
799 None
800 };
801 SessionFacts {
802 session_id,
803 visible_station,
804 console_user,
805 current_user,
806 }
807 }
808}
809
810#[cfg(test)]
811mod tests {
812 use super::*;
813 use std::cell::RefCell;
814
815 fn acct(d: &str, u: &str) -> Account {
816 Account::new(d, u)
817 }
818
819 fn facts(
820 session: u32,
821 visible: bool,
822 console: ConsoleUser,
823 cur: Option<Account>,
824 ) -> SessionFacts {
825 SessionFacts {
826 session_id: Some(session),
827 visible_station: Some(visible),
828 console_user: console,
829 current_user: cur,
830 }
831 }
832
833 #[test]
834 fn quoting_follows_commandlinetoargvw() {
835 assert_eq!(quote_windows_arg("plain"), "plain");
836 assert_eq!(quote_windows_arg(""), "\"\"");
837 assert_eq!(quote_windows_arg("a b"), "\"a b\"");
838 assert_eq!(quote_windows_arg(r#"a"b"#), r#""a\"b""#);
839 assert_eq!(quote_windows_arg(r"C:\dir with\"), r#""C:\dir with\\""#);
840 assert_eq!(quote_windows_arg(r#"x\"y z"#), r#""x\\\"y z""#);
841 assert_eq!(quote_windows_arg(r"C:\a\b"), r"C:\a\b");
842 assert_eq!(quote_windows_arg("a&b"), "a&b");
844 }
845
846 #[test]
847 fn task_xml_is_interactive_token_without_password() {
848 let xml = task_xml(&TaskSpec {
849 user: "PC\\Ann & Co".into(),
850 exe: PathBuf::from("C:\\bin\\rightkit-qa-interactive.exe"),
851 arguments: "__child \"C:\\t\\a b\\spec.json\"".into(),
852 working_dir: PathBuf::from("C:\\t\\a b"),
853 time_limit_secs: 330,
854 description: "x <y>".into(),
855 })
856 .unwrap();
857 assert!(xml.contains("<LogonType>InteractiveToken</LogonType>"));
858 assert!(xml.contains("<RunLevel>LeastPrivilege</RunLevel>"));
859 assert!(xml.contains("<UserId>PC\\Ann & Co</UserId>"));
860 assert!(xml.contains("<ExecutionTimeLimit>PT330S</ExecutionTimeLimit>"));
861 assert!(xml.contains("<Arguments>__child "C:\\t\\a b\\spec.json"</Arguments>"));
862 assert!(xml.contains("x <y>"));
863 assert!(!xml.contains("Password"));
864 assert!(!xml.contains("<Triggers"));
865 assert!(!xml.contains("cmd.exe"));
866 }
867
868 #[test]
869 fn task_xml_rejects_control_characters() {
870 let r = task_xml(&TaskSpec {
871 user: "u\u{1}".into(),
872 exe: PathBuf::from("x"),
873 arguments: String::new(),
874 working_dir: PathBuf::from("y"),
875 time_limit_secs: 5,
876 description: String::new(),
877 });
878 assert!(r.is_err());
879 }
880
881 #[test]
882 fn utf16_has_bom_and_little_endian() {
883 assert_eq!(
884 utf16le_with_bom("A\u{e9}"),
885 vec![0xFF, 0xFE, 0x41, 0x00, 0xE9, 0x00]
886 );
887 }
888
889 #[test]
890 fn exit_code_file_parsing() {
891 assert_eq!(parse_exit_code("0\n").unwrap(), 0);
892 assert_eq!(parse_exit_code(" 42 \r\n").unwrap(), 42);
893 assert_eq!(parse_exit_code("-1").unwrap(), -1);
894 assert_eq!(
895 parse_exit_code("3221225477").unwrap(),
896 0xC000_0005u32 as i32
897 );
898 assert!(parse_exit_code("").is_err());
899 assert!(parse_exit_code("abc").is_err());
900 assert!(parse_exit_code("4294967296").is_err());
901 }
902
903 #[test]
904 fn opt_in_rules() {
905 assert!(opted_in(true, None));
906 assert!(opted_in(false, Some("1")));
907 assert!(opted_in(false, Some(" TRUE ")));
908 assert!(!opted_in(false, Some("0")));
909 assert!(!opted_in(false, Some("yes please")));
910 assert!(!opted_in(false, None));
911 }
912
913 #[test]
914 fn session_zero_decision() {
915 let me = acct("PC", "ann");
916 let interactive = facts(1, true, ConsoleUser::User(me.clone()), Some(me.clone()));
917 assert_eq!(decide(&interactive, true), Decision::Direct);
918 assert_eq!(decide(&interactive, false), Decision::Direct);
919
920 let ssh = facts(
921 0,
922 false,
923 ConsoleUser::User(acct("pc", "ANN")),
924 Some(me.clone()),
925 );
926 assert!(matches!(decide(&ssh, false), Decision::NotOptedIn { .. }));
927 assert!(decide(&ssh, false).refusal().unwrap().contains(OPT_IN_ENV));
928 assert_eq!(decide(&ssh, true), Decision::Relaunch(me.clone()));
929
930 let svc = facts(2, false, ConsoleUser::User(me.clone()), Some(me.clone()));
932 assert!(matches!(decide(&svc, false), Decision::NotOptedIn { .. }));
933
934 let other = facts(
935 0,
936 false,
937 ConsoleUser::User(acct("PC", "bob")),
938 Some(me.clone()),
939 );
940 match decide(&other, true) {
941 Decision::WrongUser { current, console } => {
942 assert_eq!(current, "PC\\ann");
943 assert_eq!(console, "PC\\bob");
944 }
945 d => panic!("{d:?}"),
946 }
947 let nobody = facts(0, false, ConsoleUser::NobodyLoggedOn, Some(me.clone()));
948 assert!(matches!(
949 decide(&nobody, true),
950 Decision::NoInteractiveUser { .. }
951 ));
952 let unknown = facts(0, false, ConsoleUser::Unknown, Some(me.clone()));
954 assert_eq!(decide(&unknown, true), Decision::Relaunch(me.clone()));
955 let nocur = facts(0, false, ConsoleUser::Unknown, None);
956 assert!(matches!(decide(&nocur, true), Decision::WrongUser { .. }));
957 let none = SessionFacts {
959 session_id: None,
960 visible_station: None,
961 console_user: ConsoleUser::Unknown,
962 current_user: None,
963 };
964 assert_eq!(decide(&none, false), Decision::Direct);
965 }
966
967 #[test]
968 fn account_comparison() {
969 assert!(same_account(&acct("PC", "Ann"), &acct("pc", "ann")));
970 assert!(same_account(&acct("", "ann"), &acct("PC", "ann")));
971 assert!(!same_account(&acct("PC", "ann"), &acct("OTHER", "ann")));
972 assert!(!same_account(&acct("PC", "ann"), &acct("PC", "bob")));
973 }
974
975 #[test]
976 fn env_selection_is_explicit() {
977 let vars = vec![
978 ("RIGHTKIT_MANAGED_ROOT".to_string(), "r".to_string()),
979 ("RIGHTKIT_QA_INTERACTIVE".to_string(), "1".to_string()),
980 ("rightkit_qa_interactive_child".to_string(), "1".to_string()),
981 ("Path".to_string(), "p".to_string()),
982 ("SECRET".to_string(), "s".to_string()),
983 ("Keep".to_string(), "k".to_string()),
984 ];
985 let got = select_env(
986 vars,
987 &["keep".to_string()],
988 &[
989 ("RIGHTKIT_MANAGED_ROOT".into(), "override".into()),
990 ("X".into(), "y".into()),
991 ],
992 )
993 .unwrap();
994 assert_eq!(
995 got.get("RIGHTKIT_MANAGED_ROOT").map(String::as_str),
996 Some("override")
997 );
998 assert_eq!(got.get("Keep").map(String::as_str), Some("k"));
999 assert_eq!(got.get("X").map(String::as_str), Some("y"));
1000 assert!(!got.contains_key("Path") && !got.contains_key("SECRET"));
1001 assert!(!got.contains_key("RIGHTKIT_QA_INTERACTIVE"));
1002 assert!(!got.contains_key("rightkit_qa_interactive_child"));
1003 assert!(select_env(Vec::new(), &[], &[("A=B".into(), "c".into())]).is_err());
1004 assert!(select_env(Vec::new(), &[], &[(String::new(), "c".into())]).is_err());
1005 }
1006
1007 #[test]
1008 fn child_spec_roundtrips_hostile_arguments_as_data() {
1009 let spec = ChildSpec {
1010 program: "journey.exe".into(),
1011 args: vec![
1012 "a b".into(),
1013 "\"; calc & ".into(),
1014 "%PATH%".into(),
1015 "$(x)".into(),
1016 ],
1017 cwd: None,
1018 env: BTreeMap::from([("K".to_string(), "v\nw".to_string())]),
1019 stdout: "o".into(),
1020 stderr: "e".into(),
1021 exit: "x".into(),
1022 pid: "p".into(),
1023 };
1024 let back: ChildSpec = serde_json::from_slice(&serde_json::to_vec(&spec).unwrap()).unwrap();
1025 assert_eq!(back, spec);
1026 }
1027
1028 struct Fake {
1030 calls: RefCell<Vec<String>>,
1031 dir: RefCell<Option<PathBuf>>,
1032 fail_create: bool,
1033 behave: Option<(&'static str, &'static str, &'static str)>,
1034 }
1035
1036 impl Fake {
1037 fn new(behave: Option<(&'static str, &'static str, &'static str)>) -> Self {
1038 Fake {
1039 calls: RefCell::new(vec![]),
1040 dir: RefCell::new(None),
1041 fail_create: false,
1042 behave,
1043 }
1044 }
1045 fn log(&self, s: &str) {
1046 self.calls.borrow_mut().push(s.to_string());
1047 }
1048 }
1049
1050 impl Scheduler for Fake {
1051 fn create(&self, name: &str, xml_path: &Path) -> Result<()> {
1052 assert!(name.starts_with(TASK_FOLDER));
1053 *self.dir.borrow_mut() = xml_path.parent().map(|p| p.to_path_buf());
1054 self.log("create");
1055 if self.fail_create {
1056 return err("denied");
1057 }
1058 Ok(())
1059 }
1060 fn run(&self, _name: &str) -> Result<()> {
1061 self.log("run");
1062 if let (Some((out, errs, code)), Some(dir)) = (self.behave, self.dir.borrow().clone()) {
1063 std::fs::write(dir.join(OUT_FILE), out).unwrap();
1064 std::fs::write(dir.join(ERR_FILE), errs).unwrap();
1065 std::fs::write(dir.join(PID_FILE), "1234").unwrap();
1066 std::fs::write(dir.join(EXIT_FILE), code).unwrap();
1067 }
1068 Ok(())
1069 }
1070 fn end(&self, _name: &str) {
1071 self.log("end");
1072 }
1073 fn delete(&self, _name: &str) {
1074 self.log("delete");
1075 }
1076 fn describe(&self, _name: &str) -> String {
1077 "no such task".into()
1078 }
1079 fn kill_tree(&self, pid: u32) {
1080 self.log(&format!("kill {pid}"));
1081 }
1082 }
1083
1084 fn request(timeout_ms: u64, grace_ms: u64) -> Request {
1085 Request {
1086 program: "journey.exe".into(),
1087 args: vec!["--x".into()],
1088 cwd: None,
1089 env: BTreeMap::new(),
1090 timeout: Duration::from_millis(timeout_ms),
1091 child_exe: PathBuf::from("rightkit-qa-interactive.exe"),
1092 account: acct("PC", "ann"),
1093 poll: Duration::from_millis(10),
1094 start_grace: Duration::from_millis(grace_ms),
1095 }
1096 }
1097
1098 #[test]
1099 fn success_streams_output_propagates_code_and_cleans_up() {
1100 let fake = Fake::new(Some(("hello\n", "warn\n", "3\n")));
1101 let mut got: Vec<(Stream, Vec<u8>)> = vec![];
1102 let code = run_interactive(&fake, &request(5000, 5000), &mut |s, b| {
1103 got.push((s, b.to_vec()))
1104 })
1105 .unwrap();
1106 assert_eq!(code, 3);
1107 assert!(got.contains(&(Stream::Stdout, b"hello\n".to_vec())));
1108 assert!(got.contains(&(Stream::Stderr, b"warn\n".to_vec())));
1109 let calls = fake.calls.borrow().clone();
1110 assert_eq!(calls, vec!["create", "run", "end", "delete"]);
1111 assert!(!fake.dir.borrow().clone().unwrap().exists());
1112 }
1113
1114 #[test]
1115 fn timeout_kills_deletes_and_removes_files() {
1116 let fake = Fake::new(None);
1117 let r = run_interactive(&fake, &request(150, 60_000), &mut |_, _| {});
1118 let e = r.unwrap_err().to_string();
1119 assert!(e.contains("timed out"), "{e}");
1120 let calls = fake.calls.borrow().clone();
1121 assert_eq!(calls.last().map(String::as_str), Some("delete"));
1122 assert!(calls.contains(&"end".to_string()));
1123 assert!(!fake.dir.borrow().clone().unwrap().exists());
1124 }
1125
1126 #[test]
1127 fn task_that_never_starts_is_reported_and_cleaned() {
1128 let fake = Fake::new(None);
1129 let e = run_interactive(&fake, &request(60_000, 50), &mut |_, _| {})
1130 .unwrap_err()
1131 .to_string();
1132 assert!(e.contains("did not start"), "{e}");
1133 assert!(e.contains("no such task"), "{e}");
1134 assert_eq!(
1135 fake.calls.borrow().last().map(String::as_str),
1136 Some("delete")
1137 );
1138 assert!(!fake.dir.borrow().clone().unwrap().exists());
1139 }
1140
1141 #[test]
1142 fn failed_create_still_deletes_and_cleans() {
1143 let mut fake = Fake::new(None);
1144 fake.fail_create = true;
1145 let e = run_interactive(&fake, &request(1000, 1000), &mut |_, _| {})
1146 .unwrap_err()
1147 .to_string();
1148 assert!(e.contains("denied"));
1149 assert_eq!(
1150 fake.calls.borrow().last().map(String::as_str),
1151 Some("delete")
1152 );
1153 assert!(!fake.dir.borrow().clone().unwrap().exists());
1154 }
1155
1156 #[test]
1157 fn unparsable_exit_file_is_an_error_not_success() {
1158 let fake = Fake::new(Some(("", "", "oops")));
1159 assert!(run_interactive(&fake, &request(1000, 1000), &mut |_, _| {}).is_err());
1160 assert_eq!(
1161 fake.calls.borrow().last().map(String::as_str),
1162 Some("delete")
1163 );
1164 }
1165}