Skip to main content

rightkit_qa/
interactive.rs

1//! Run a journey in the logged-on user's interactive desktop session from a session-0 process
2//! (an SSH logon or a service on Windows), through a ONE-SHOT Task Scheduler task.
3//!
4//! Why: a hidden WebView2 app never gets a ready window from session 0, so a native journey driven
5//! over SSH fails with "app window never became ready ... session 0". The whole journey command
6//! (the harness AND the app) must run in the interactive session; this module relaunches it there.
7//!
8//! Design (see the README, Windows section):
9//! * Opt-in only: [`opted_in`] (`--interactive` or `RIGHTKIT_QA_INTERACTIVE=1`). Without it a
10//!   session-0 process keeps failing with the existing clear error ([`Decision::NotOptedIn`]).
11//! * [`decide`] is pure: it maps [`SessionFacts`] to a [`Decision`]. The task is only created when
12//!   the current account is the console (logged-on interactive) account.
13//! * The task runs THIS executable in `__child <spec.json>` mode ([`child_main`]), never `cmd.exe`
14//!   or a shell: the journey program and its arguments travel as JSON data and are spawned as argv,
15//!   so no journey argument is ever parsed by a shell. Only one generated path appears on the task
16//!   command line, quoted with the `CommandLineToArgvW` rules ([`quote_windows_arg`]) and
17//!   XML-escaped ([`task_xml`]).
18//! * The child redirects stdout and stderr to files in a private temp dir and writes the exit code
19//!   file last, by rename (atomic). The parent polls, streams new output, and propagates the code.
20//! * [`run_interactive`] owns a cleanup guard: the task is ended and deleted and the temp dir
21//!   removed on success, error, timeout and unwinding.
22//!
23//! Account and token: `LogonType = InteractiveToken`, `UserId` = the current account, `RunLevel =
24//! LeastPrivilege`. No password is stored, requested or accepted. Task Scheduler runs an
25//! InteractiveToken task only for a user who is logged on, in that user's session.
26use 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
33/// Opt-in environment variable (`1` or `true`).
34pub const OPT_IN_ENV: &str = "RIGHTKIT_QA_INTERACTIVE";
35/// Set by the child wrapper in the journey's environment so a journey never relaunches itself.
36pub const CHILD_MARKER_ENV: &str = "RIGHTKIT_QA_INTERACTIVE_CHILD";
37/// Task Scheduler folder every one-shot task lives in; the task name is `<folder><uuid>`.
38pub const TASK_FOLDER: &str = "\\RightKit\\QA\\";
39/// Exit code the wrapper reports when it could not start the journey at all.
40pub const WRAPPER_FAILED: i32 = 125;
41/// Files inside the job directory.
42pub 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
49/// True when the flag is given or the environment value is `1` / `true`.
50pub 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/// A Windows account, `domain\user` (domain may be empty).
58#[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
80/// Same account: user names equal ignoring case, and domains equal ignoring case unless either
81/// side has none.
82pub 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/// Who is logged on at the physical console.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum ConsoleUser {
92    /// The query failed; the Task Scheduler decides.
93    Unknown,
94    /// No session, or a session with no user (the logon screen).
95    NobodyLoggedOn,
96    User(Account),
97}
98
99/// What the process can tell about where it runs. Pure data so [`decide`] is testable.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct SessionFacts {
102    pub session_id: Option<u32>,
103    /// `Some(false)` when the process window station is not visible (service or SSH shape).
104    pub visible_station: Option<bool>,
105    pub console_user: ConsoleUser,
106    pub current_user: Option<Account>,
107}
108
109/// `Some(reason)` when the process is outside the interactive desktop; `None` when interactive or
110/// undeterminable (the same rule the launch hint uses).
111pub 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    /// Already interactive (or not Windows): run the journey directly.
125    Direct,
126    /// Relaunch in the interactive session as this account.
127    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(&current, c) => Decision::WrongUser {
164            current: current.qualified(),
165            console: c.qualified(),
166        },
167        _ => Decision::Relaunch(current),
168    }
169}
170
171impl Decision {
172    /// The error a caller reports for a decision that cannot proceed; `None` for Direct/Relaunch.
173    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
189/// Quote one argument for a Windows command line (`CommandLineToArgvW` rules).
190pub 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("&amp;"),
221            '<' => out.push_str("&lt;"),
222            '>' => out.push_str("&gt;"),
223            '"' => out.push_str("&quot;"),
224            '\'' => out.push_str("&apos;"),
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/// What the generated task runs.
238#[derive(Debug, Clone)]
239pub struct TaskSpec {
240    /// `domain\user`; the same account as the caller (InteractiveToken).
241    pub user: String,
242    pub exe: PathBuf,
243    /// Already-quoted command line arguments for `exe`.
244    pub arguments: String,
245    pub working_dir: PathBuf,
246    pub time_limit_secs: u64,
247    pub description: String,
248}
249
250/// The one-shot task definition: no triggers (started only by `/run`), InteractiveToken for the
251/// given user, least privilege, no password anywhere, a hard execution time limit.
252pub 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
293/// UTF-16 little endian with a byte order mark: the encoding `schtasks /xml` always accepts.
294pub 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
302/// Parse the exit code file: one decimal integer. Windows codes above `i32::MAX` (for example
303/// `0xC0000005`) are accepted as unsigned and returned with the same bit pattern as `i32`.
304pub 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
319/// Environment forwarded to the journey. Scheduled tasks do not inherit the SSH environment, so it
320/// is explicit: every `RIGHTKIT_*` variable (except the opt-in and child markers), the variables
321/// named in `pass` (case-insensitive), then `explicit` pairs, which win.
322pub 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/// Everything the child wrapper needs; written as JSON into the job directory.
346#[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
358/// The child wrapper, run inside the interactive session as `<exe> __child <spec.json>`. Reads and
359/// deletes the spec, starts the journey with the files as stdout and stderr, records its pid,
360/// waits, then writes the exit code file by rename. Returns the journey's exit code.
361pub 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    // The spec may carry environment values; do not leave it around longer than needed.
376    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); // CREATE_NO_WINDOW: stdin is null and output goes to files
407    }
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
426/// Task Scheduler operations, a trait so the orchestration is tested without a real scheduler.
427pub trait Scheduler {
428    /// Register the task `name` from the XML file (must fail if the name exists).
429    fn create(&self, name: &str, xml_path: &Path) -> Result<()>;
430    fn run(&self, name: &str) -> Result<()>;
431    /// Best effort; never fails the caller.
432    fn end(&self, name: &str);
433    /// Best effort; never fails the caller.
434    fn delete(&self, name: &str);
435    /// Human diagnostic for a task that did not start (best effort).
436    fn describe(&self, name: &str) -> String;
437    /// Terminate the journey process tree by recorded pid.
438    fn kill_tree(&self, pid: u32);
439}
440
441/// `schtasks.exe` from `%SystemRoot%\System32`, invoked with argv (no shell).
442pub 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/// One interactive run.
515#[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    /// Whole-run limit, also the task's execution time limit (plus a margin).
522    pub timeout: Duration,
523    /// This executable (it must implement `__child`, see the `rightkit-qa-interactive` binary).
524    pub child_exe: PathBuf,
525    pub account: Account,
526    pub poll: Duration,
527    /// How long the journey may take to write its pid file before the task counts as not started.
528    pub start_grace: Duration,
529}
530
531/// Ends and deletes the task, kills a still-running journey and removes the job directory when
532/// dropped: on success, error, timeout and unwinding alike.
533struct 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
585/// Run the request through `sched` and return the journey's exit code. Output is handed to `sink`
586/// as it is written. Errors: task creation or start failure, the task never starting, the timeout
587/// (the journey tree is killed), an unreadable exit file.
588pub 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    // Set before create so a half-created task is still deleted.
636    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/// Facts about this process. Not Windows: nothing known, so [`decide`] returns `Direct`.
677#[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    // SAFETY: plain Win32 queries with correctly sized out-parameters; WTS buffers are freed.
735    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        // Shell metacharacters need no special handling: nothing here is ever parsed by a shell.
843        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 &amp; Co</UserId>"));
860        assert!(xml.contains("<ExecutionTimeLimit>PT330S</ExecutionTimeLimit>"));
861        assert!(xml.contains("<Arguments>__child &quot;C:\\t\\a b\\spec.json&quot;</Arguments>"));
862        assert!(xml.contains("x &lt;y&gt;"));
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        // A non-visible station outside session 0 is the same shape.
931        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        // Unknown console user: the Task Scheduler enforces the account.
953        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        // Undeterminable facts never block a run.
958        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    /// Records calls and plays the child by writing the result files next to the task XML.
1029    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}