Skip to main content

rightkit_qa/
workspace.rs

1//! An isolated, reliably removable home for one QA run: own data dir (so the app
2//! takes its own single-instance lock), own evidence dir, retrying disposal that
3//! reports what it could not remove, and a sweep for crashed runs.
4use crate::storage;
5use crate::util::{new_id, require_text, Result};
6use std::collections::BTreeMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::time::Duration;
10
11#[derive(Debug, Clone)]
12pub struct QaWorkspace {
13    pub run_id: String,
14    /// Managed run root: `<managed>/runs/<app>/<run-id>`.
15    pub root: PathBuf,
16    /// Owned app home on internal system temp storage.
17    pub home: PathBuf,
18    pub data_dir: PathBuf,
19    pub evidence_dir: PathBuf,
20    pub capture_dir: PathBuf,
21    /// Environment that points an app at this workspace (profile isolation).
22    pub env: BTreeMap<String, String>,
23}
24
25#[derive(Debug, Default, Clone)]
26pub struct DisposeReport {
27    pub removed: Vec<PathBuf>,
28    pub retained: Vec<(PathBuf, String)>,
29}
30
31/// What happens before a run is created. `Full` sweeps dead homes and enforces the QA root limits
32/// (the product cap and the root ceiling). `SweepOnly` sweeps and leaves the limit check to the
33/// caller, which runs it later through [`storage::enforce_root_limits`].
34#[derive(Clone, Copy)]
35enum Preflight {
36    Full(storage::RootLimits),
37    SweepOnly,
38}
39
40/// Create a workspace. The QA root limits come from the environment (`RIGHTKIT_QA_ROOT_CAP_BYTES`,
41/// `RIGHTKIT_QA_ROOT_CEILING_BYTES`), else the defaults; see [`create_with_limits`] to set them in
42/// code.
43pub fn create(cache_root: &Path, run_id: Option<&str>, app_key: &str) -> Result<QaWorkspace> {
44    create_with_limits(cache_root, run_id, app_key, storage::RootLimits::default())
45}
46
47/// [`create`] with the QA root limits set in code. A limit set in `limits` wins over its
48/// environment variable (`0` disables it); a limit left `None` falls back to the environment, then
49/// the default. This is for a test process that cannot receive the environment variable, such as
50/// one run under `rightkit cargo`.
51pub fn create_with_limits(
52    cache_root: &Path,
53    run_id: Option<&str>,
54    app_key: &str,
55    limits: storage::RootLimits,
56) -> Result<QaWorkspace> {
57    create_impl(Some(cache_root), run_id, app_key, Preflight::Full(limits))
58}
59
60/// Create under RIGHTKIT_MANAGED_ROOT (or its platform default).
61pub fn create_managed(run_id: Option<&str>, app_key: &str) -> Result<QaWorkspace> {
62    create_impl(
63        None,
64        run_id,
65        app_key,
66        Preflight::Full(storage::RootLimits::default()),
67    )
68}
69
70/// The harness sweeps at construction and checks its limits at its first scenario, so limits set
71/// with `Harness::with_root_cap_bytes` / `with_root_ceiling_bytes` after construction apply.
72pub(crate) fn create_impl_for_harness(
73    managed_root: Option<&Path>,
74    app_key: &str,
75) -> Result<QaWorkspace> {
76    create_impl(managed_root, None, app_key, Preflight::SweepOnly)
77}
78
79fn create_impl(
80    cache_root: Option<&Path>,
81    run_id: Option<&str>,
82    app_key: &str,
83    preflight: Preflight,
84) -> Result<QaWorkspace> {
85    let app_key = require_text(app_key, "app")?;
86    // Sweep dead homes and, unless the caller checks later, refuse to start over a QA root limit,
87    // before a run exists.
88    match preflight {
89        Preflight::Full(limits) => storage::preflight_home_root(&app_key, limits)?,
90        Preflight::SweepOnly => storage::sweep_home_root(),
91    }
92    let mut managed =
93        storage::create(cache_root, &app_key, run_id, Duration::from_secs(24 * 3600))?;
94    // Storage works on canonical (on Windows verbatim, `\\?\C:\...`) paths and keeps them in the
95    // run marker. What this workspace hands to apps and tests is the ordinary drive path: some
96    // programs reject or mis-handle the verbatim form (Orthic-Labs/rightkit#185). Both name the
97    // same directory; every storage check canonicalises before it compares.
98    let home = crate::util::plain_path(&storage::create_app_root(&mut managed)?);
99    let run_id = managed.marker.run_id.clone();
100    let root = crate::util::plain_path(&managed.root);
101    // Generic managed storage creates this directory; QA profiles belong only in home.
102    fs::remove_dir(root.join("data"))?;
103    let data_dir = home.join("data");
104    let evidence_dir = root.join("evidence");
105    let capture_dir = root.join("captures");
106    let tmp_dir = home.join("tmp");
107    let cache_dir = home.join("cache");
108    fs::create_dir_all(&data_dir)?;
109    fs::create_dir_all(&evidence_dir)?;
110    fs::create_dir_all(&capture_dir)?;
111    fs::create_dir_all(&tmp_dir)?;
112    fs::create_dir_all(&cache_dir)?;
113    fs::create_dir_all(home.join("evidence"))?;
114    fs::create_dir_all(home.join("captures"))?;
115    create_profile_dirs(&home)?;
116    let mut env = BTreeMap::new();
117    // Test-mode activation & its bound root travel together (M20/M21): the root is the bound
118    // home itself (it carries `.rightkit-app.json`), never `home/data`. A test-capable app
119    // validates the pair & derives its children from that decision; either half alone refuses.
120    env.insert("RIGHTKIT_UNDER_TEST".into(), "1".into());
121    env.insert("RIGHTKIT_QA_NATIVE".into(), "1".into());
122    env.insert("RIGHTKIT_QA_RUN_ID".into(), run_id.clone());
123    env.insert("RIGHTKIT_RUN_ROOT".into(), home.to_string_lossy().into());
124    env.insert(
125        "RIGHTKIT_SUITE_ROOT".into(),
126        home.join("suite").to_string_lossy().into(),
127    );
128    env.insert("HOME".into(), home.to_string_lossy().into());
129    env.insert("USERPROFILE".into(), home.to_string_lossy().into());
130    env.insert("APPDATA".into(), data_dir.to_string_lossy().into());
131    env.insert("LOCALAPPDATA".into(), data_dir.to_string_lossy().into());
132    env.insert("XDG_CACHE_HOME".into(), cache_dir.to_string_lossy().into());
133    env.insert("RIGHTKIT_QA_DATA_DIR".into(), home.to_string_lossy().into());
134    env.insert(
135        "RIGHTKIT_QA_EVIDENCE_DIR".into(),
136        home.join("evidence").to_string_lossy().into(),
137    );
138    env.insert(
139        "RIGHTKIT_QA_CAPTURE_DIR".into(),
140        home.join("captures").to_string_lossy().into(),
141    );
142    env.insert("TMPDIR".into(), tmp_dir.to_string_lossy().into());
143    env.insert("TMP".into(), tmp_dir.to_string_lossy().into());
144    env.insert("TEMP".into(), tmp_dir.to_string_lossy().into());
145    env.insert("RIGHTKIT_QA_MUTE".into(), "1".into());
146    env.insert("XDG_DATA_HOME".into(), data_dir.to_string_lossy().into());
147    env.insert("XDG_CONFIG_HOME".into(), data_dir.to_string_lossy().into());
148    env.insert(
149        "WEBVIEW2_USER_DATA_FOLDER".into(),
150        data_dir.join("webview2").to_string_lossy().into(),
151    );
152    let key: String = app_key
153        .to_uppercase()
154        .chars()
155        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
156        .collect();
157    env.insert(format!("{key}_DATA_DIR"), data_dir.to_string_lossy().into());
158    // Every directory an exported variable points at must exist: apps and
159    // frameworks treat a missing one as "unknown path".
160    for value in env.values() {
161        let path = Path::new(value);
162        if path.starts_with(&home) {
163            fs::create_dir_all(path)?;
164        }
165    }
166    Ok(QaWorkspace {
167        run_id,
168        root,
169        home,
170        data_dir,
171        evidence_dir,
172        capture_dir,
173        env,
174    })
175}
176
177/// Create the per-user directories that platform known-folder lookups resolve
178/// under the isolated profile. On Windows `SHGetKnownFolderPath` expands the
179/// registry's `%USERPROFILE%\AppData\Local` against the overridden
180/// USERPROFILE and fails (Tauri "unknown path", `dirs::cache_dir` None) when the
181/// result is absent, even though LOCALAPPDATA itself points elsewhere.
182fn create_profile_dirs(home: &Path) -> Result<()> {
183    let relative: &[&str] = if cfg!(windows) {
184        &[
185            "AppData/Local",
186            "AppData/Local/Temp",
187            "AppData/LocalLow",
188            "AppData/Roaming",
189            "Desktop",
190            "Documents",
191            "Downloads",
192            "Music",
193            "Pictures",
194            "Videos",
195        ]
196    } else if cfg!(target_os = "macos") {
197        &[
198            "Library/Application Support",
199            "Library/Caches",
200            "Library/Preferences",
201            "Library/Logs",
202            "Desktop",
203            "Documents",
204            "Downloads",
205        ]
206    } else {
207        &[
208            ".local/share",
209            ".local/state",
210            ".config",
211            ".cache",
212            "Desktop",
213            "Documents",
214            "Downloads",
215        ]
216    };
217    for dir in relative {
218        fs::create_dir_all(home.join(dir))?;
219    }
220    Ok(())
221}
222
223impl QaWorkspace {
224    /// Persist an app-produced artifact before hashing it into managed evidence.
225    pub(crate) fn preserve_artifact(&self, path: &Path) -> Result<PathBuf> {
226        let Ok(relative) = path.strip_prefix(&self.home) else {
227            return Ok(path.to_path_buf());
228        };
229        if relative
230            .components()
231            .any(|c| c == std::path::Component::ParentDir)
232        {
233            return crate::util::err("app artifact escapes QA app home");
234        }
235        let target = if let Ok(p) = relative.strip_prefix("captures") {
236            self.capture_dir.join(p)
237        } else {
238            self.evidence_dir
239                .join("app")
240                .join(relative.strip_prefix("evidence").unwrap_or(relative))
241        };
242        storage::copy_tree(path, &target)?;
243        Ok(target)
244    }
245
246    /// Record the run kind in the run marker (see [`storage::RunKind`]).
247    pub fn set_kind(&self, kind: storage::RunKind) -> Result<()> {
248        storage::set_run_kind(&self.root, kind)
249    }
250
251    /// Record how the run ended in the run marker. Retention reads this and nothing else; a run
252    /// that never gets an outcome is never pruned by it.
253    pub fn set_outcome(&self, outcome: storage::RunOutcome) -> Result<()> {
254        storage::set_run_outcome(&self.root, outcome)
255    }
256
257    /// Preserve app-written outputs before marking this run complete.
258    pub fn finish(&self) -> Result<()> {
259        if let Some(marker) = storage::read_marker(&self.root.join(storage::RUN_MARKER)) {
260            storage::finish(&storage::ManagedRun {
261                root: self.root.clone(),
262                marker,
263            })?;
264        }
265        Ok(())
266    }
267    /// A fresh per-scenario directory inside the isolated data dir.
268    pub fn scenario_dir(&self, name: &str) -> Result<PathBuf> {
269        let safe: String = name
270            .chars()
271            .map(|c| {
272                if c.is_ascii_alphanumeric() || c == '-' {
273                    c
274                } else {
275                    '_'
276                }
277            })
278            .collect();
279        let dir = self.data_dir.join(format!("{safe}-{}", &new_id()[..8]));
280        fs::create_dir_all(&dir)?;
281        Ok(dir)
282    }
283}
284
285/// A process that still uses a QA home.
286#[derive(Debug, Clone, PartialEq, Eq)]
287pub struct HomeHolder {
288    pub pid: u32,
289    pub name: String,
290    /// Its executable is inside the home.
291    pub runs_from_home: bool,
292    /// It descends from an app this run launched into the home (see [`record_launched`]).
293    pub launched_by_run: bool,
294}
295
296impl HomeHolder {
297    /// Whether disposal may end it. A process is never ended merely because its command line
298    /// names the home: only one that runs from the home, or that the run's own app started.
299    pub fn owned_by_run(&self) -> bool {
300        self.runs_from_home || self.launched_by_run
301    }
302}
303
304/// One row of the process scan.
305#[derive(Debug, Clone, PartialEq, Eq)]
306struct ScannedProcess {
307    pid: u32,
308    parent: u32,
309    names_home: bool,
310    runs_from_home: bool,
311    name: String,
312}
313
314/// The process scan's output: `pid<TAB>parent pid<TAB>flags<TAB>name` per process, where flags
315/// holds `C` (the command line names the home) and `E` (the executable is inside it).
316#[cfg_attr(not(windows), allow(dead_code))]
317fn parse_scan(output: &str) -> Vec<ScannedProcess> {
318    output
319        .lines()
320        .filter_map(|line| {
321            let mut fields = line.trim().splitn(4, '\t');
322            let pid = fields.next()?.trim().parse::<u32>().ok()?;
323            let parent = fields.next()?.trim().parse::<u32>().ok()?;
324            let flags = fields.next()?;
325            let name = fields.next()?.trim().to_string();
326            Some(ScannedProcess {
327                pid,
328                parent,
329                names_home: flags.contains('C'),
330                runs_from_home: flags.contains('E'),
331                name,
332            })
333        })
334        .collect()
335}
336
337/// Add to `launched` every scanned process whose parent is already in it, until nothing is
338/// added: the apps the run launched and everything they started that is still listed. The set
339/// is kept across scans, so a helper stays known after its parent has exited.
340fn extend_descendants(launched: &mut std::collections::BTreeSet<u32>, scan: &[ScannedProcess]) {
341    loop {
342        let before = launched.len();
343        for process in scan {
344            if process.pid != 0 && launched.contains(&process.parent) {
345                launched.insert(process.pid);
346            }
347        }
348        if launched.len() == before {
349            return;
350        }
351    }
352}
353
354/// The scanned processes that use the home, without this process.
355fn holders_in(
356    scan: &[ScannedProcess],
357    launched: &std::collections::BTreeSet<u32>,
358) -> Vec<HomeHolder> {
359    let mut holders: Vec<HomeHolder> = scan
360        .iter()
361        .filter(|process| process.pid != std::process::id())
362        .filter(|process| process.names_home || process.runs_from_home)
363        .map(|process| HomeHolder {
364            pid: process.pid,
365            name: process.name.clone(),
366            runs_from_home: process.runs_from_home,
367            launched_by_run: launched.contains(&process.pid),
368        })
369        .collect();
370    holders.sort_by_key(|holder| holder.pid);
371    holders.dedup();
372    holders
373}
374
375fn describe_holders(holders: &[HomeHolder]) -> String {
376    holders
377        .iter()
378        .map(|holder| format!("{} (pid {})", holder.name, holder.pid))
379        .collect::<Vec<_>>()
380        .join(", ")
381}
382
383/// Windows: every process, marked when its command line names `home` or its executable lives in
384/// it. `None` when the scan itself failed.
385#[cfg(windows)]
386fn scan_processes(home: &Path) -> Option<Vec<ScannedProcess>> {
387    use std::os::windows::process::CommandExt;
388    use std::process::Command;
389    const SCAN: &str = "$h = $env:RIGHTKIT_QA_SCAN_HOME; \
390        Get-CimInstance Win32_Process | ForEach-Object { \
391        $f = '-'; \
392        if ($_.CommandLine -and $_.CommandLine.IndexOf($h, [StringComparison]::OrdinalIgnoreCase) -ge 0) { $f += 'C' }; \
393        if ($_.ExecutablePath -and $_.ExecutablePath.StartsWith($h + [char]92, [StringComparison]::OrdinalIgnoreCase)) { $f += 'E' }; \
394        [string]$_.ProcessId + [char]9 + [string]$_.ParentProcessId + [char]9 + $f + [char]9 + $_.Name }";
395    let system_root = std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into());
396    let powershell = Path::new(&system_root)
397        .join("System32")
398        .join("WindowsPowerShell")
399        .join("v1.0")
400        .join("powershell.exe");
401    // Bounded: a stalled WMI query must not hang the run; no answer reads as "scan unavailable".
402    let output = crate::process::output_bounded(
403        Command::new(powershell)
404            .args(["-NoProfile", "-NonInteractive", "-Command", SCAN])
405            // The home travels in the environment, so the scanner's own command line never names it.
406            .env("RIGHTKIT_QA_SCAN_HOME", home)
407            .creation_flags(0x0800_0000),
408        Duration::from_secs(60),
409    )
410    .ok()??;
411    output
412        .status
413        .success()
414        .then(|| parse_scan(&String::from_utf8_lossy(&output.stdout)))
415}
416
417/// Other systems remove a directory whatever holds its files open: nothing to scan.
418#[cfg(not(windows))]
419fn scan_processes(_home: &Path) -> Option<Vec<ScannedProcess>> {
420    Some(Vec::new())
421}
422
423/// Windows: the processes that use `home`: their command line names it, or their executable is
424/// inside it. Empty on other systems. `None` when the scan itself failed.
425///
426/// An app launched into a QA home starts helpers that outlive it for a moment after it is
427/// stopped: WebView2 and GPU processes carry the home in `--user-data-dir=` and keep its
428/// `EBWebView`, shader cache and log files open while they exit, so removing the home at once
429/// fails with "Access is denied".
430pub fn home_holders(home: &Path) -> Option<Vec<HomeHolder>> {
431    let scan = scan_processes(home)?;
432    Some(holders_in(&scan, &Default::default()))
433}
434
435/// The file in a run root that lists the pids of the apps launched into its home.
436const LAUNCHED_PIDS: &str = ".launched-pids";
437
438/// Record that this run launched process `pid` into the workspace's home. Disposal may end
439/// what that process started and left behind; see [`dispose`]. Called by `control::launch`;
440/// call it for an app started by hand.
441pub fn record_launched(ws: &QaWorkspace, pid: u32) {
442    use std::io::Write;
443    if let Ok(mut file) = fs::OpenOptions::new()
444        .create(true)
445        .append(true)
446        .open(ws.root.join(LAUNCHED_PIDS))
447    {
448        let _ = writeln!(file, "{pid}");
449    }
450}
451
452fn launched_pids(ws: &QaWorkspace) -> std::collections::BTreeSet<u32> {
453    fs::read_to_string(ws.root.join(LAUNCHED_PIDS))
454        .unwrap_or_default()
455        .lines()
456        .filter_map(|line| line.trim().parse().ok())
457        .filter(|pid| *pid != 0 && *pid != std::process::id())
458        .collect()
459}
460
461/// Wait until no process uses the home, at most `wait`. Then end the holders that belong to the
462/// run (they run from the home, or descend from an app the run launched) and leave the others.
463/// Returns the holders that were still there when the wait ran out.
464fn settle_home_holders(ws: &QaWorkspace, wait: Duration) -> Vec<HomeHolder> {
465    let deadline = std::time::Instant::now() + wait;
466    let mut launched = launched_pids(ws);
467    let mut holders;
468    loop {
469        let Some(scan) = scan_processes(&ws.home) else {
470            return Vec::new();
471        };
472        extend_descendants(&mut launched, &scan);
473        holders = holders_in(&scan, &launched);
474        if holders.is_empty() || std::time::Instant::now() >= deadline {
475            break;
476        }
477        crate::util::sleep_ms(250);
478    }
479    for holder in holders.iter().filter(|holder| holder.owned_by_run()) {
480        crate::process::kill_tree(holder.pid);
481    }
482    holders
483}
484
485/// How long [`dispose`] waits for the helpers of a stopped app to leave its home.
486const HOLDER_WAIT: Duration = Duration::from_secs(20);
487
488/// Remove the workspace (or only its volatile data dir), retrying through the
489/// window where an exiting app still holds files.
490///
491/// On Windows it first waits (up to 20 seconds) for every process that still uses the home to
492/// exit. Of those that remain it ends only the run's own: a process whose executable is inside
493/// the home, or that descends from an app the run launched into it ([`record_launched`]). A
494/// process that merely names the home on its command line is never ended. A home that still
495/// cannot be removed is reported with the processes that hold it.
496pub fn dispose(
497    ws: &QaWorkspace,
498    keep_evidence: bool,
499    attempts: u32,
500    retry_ms: u64,
501) -> DisposeReport {
502    dispose_waiting(ws, keep_evidence, attempts, retry_ms, HOLDER_WAIT)
503}
504
505fn dispose_waiting(
506    ws: &QaWorkspace,
507    keep_evidence: bool,
508    attempts: u32,
509    retry_ms: u64,
510    holder_wait: Duration,
511) -> DisposeReport {
512    let mut report = DisposeReport::default();
513    if let Err(e) = ws.finish() {
514        report.retained.push((ws.root.clone(), e.to_string()));
515        return report;
516    }
517    let mut targets = vec![ws.home.clone()];
518    if !keep_evidence {
519        targets.push(ws.root.clone());
520    }
521    for target in targets {
522        if !target.exists() {
523            report.removed.push(target);
524            continue;
525        }
526        // Deletion happens only inside the QA root this runner owns, never by a caller's path.
527        if target == ws.home && !storage::is_owned_home(&target) {
528            report.retained.push((
529                target,
530                "not a QA home created by rightkit-qa; not deleted".into(),
531            ));
532            continue;
533        }
534        if target == ws.home {
535            settle_home_holders(ws, holder_wait);
536        }
537        let mut last = String::new();
538        for attempt in 1..=attempts.max(1) {
539            // The home goes through the link-safe removal, which keeps the binding file until
540            // everything else is gone. The run root keeps `remove_dir_all`: it holds only files
541            // this crate wrote and no app profile lives in it, so no app-made link can be there.
542            // If a profile is ever placed in the run root, it must go through the same removal.
543            let removal = if target == ws.home {
544                match target.parent() {
545                    Some(qa_root) => storage::remove_home_tree(qa_root, &target),
546                    None => Err("home has no parent directory".to_string()),
547                }
548            } else {
549                fs::remove_dir_all(&target).map_err(|e| e.to_string())
550            };
551            match removal {
552                Ok(()) => {
553                    last.clear();
554                    break;
555                }
556                Err(e) => {
557                    last = e;
558                    if attempt < attempts {
559                        crate::util::sleep_ms(retry_ms);
560                    }
561                }
562            }
563        }
564        if last.is_empty() && !target.exists() {
565            report.removed.push(target);
566        } else {
567            let mut why = if last.is_empty() {
568                "still present".to_string()
569            } else {
570                last
571            };
572            if target == ws.home {
573                // Name what holds it, so the report says which program to look at.
574                match home_holders(&target) {
575                    Some(holders) if !holders.is_empty() => {
576                        why.push_str(&format!("; held by {}", describe_holders(&holders)));
577                    }
578                    Some(_) if cfg!(windows) => {
579                        why.push_str("; no running process names this home");
580                    }
581                    _ => {}
582                }
583            }
584            report.retained.push((target.clone(), why));
585            if target == ws.home {
586                if !keep_evidence {
587                    report
588                        .retained
589                        .push((ws.root.clone(), "app temp cleanup pending".into()));
590                }
591                break;
592            }
593        }
594    }
595    report
596}
597
598/// Sweep run directories left by crashed runs. Bounded to `<root>/runs`, to
599/// entries older than `older_than`, and never touches `keep`.
600pub fn prune_abandoned(cache_root: &Path, older_than: Duration, keep: &[String]) -> DisposeReport {
601    let mut report = DisposeReport::default();
602    let runs_root = cache_root.join("runs");
603    let Ok(entries) = fs::read_dir(&runs_root) else {
604        return report;
605    };
606    let entries: Vec<_> = entries.flatten().collect();
607    for entry in &entries {
608        let Ok(ft) = entry.file_type() else { continue };
609        if ft.is_dir() && !ft.is_symlink() {
610            let app = entry.file_name().to_string_lossy().to_string();
611            if let Ok(r) = storage::prune_with_age_override(cache_root, &app, keep, older_than) {
612                report.removed.extend(r.removed);
613                report.retained.extend(r.retained);
614            }
615        }
616    }
617    report
618}
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623
624    #[test]
625    fn launcher_home_passes_portable_and_app_dirs_decisions() {
626        use rightkit_fs::app_dirs::{AppDirs, AppId, Options, Os};
627        use rightkit_fs::test_mode::{decide, Decision, Refusal, Request, BINDING_FILE};
628        let cache = storage::app_temp_dir().join(format!("rkqa-decision-{}", new_id()));
629        let ws = create(&cache, Some("decision"), "genright").unwrap();
630        let protected = vec![cache.join("real-user-data")];
631        let root = Path::new(&ws.env["RIGHTKIT_QA_DATA_DIR"]);
632        let request = Request {
633            app_id: "genright",
634            activated: ws.env["RIGHTKIT_UNDER_TEST"] == "1",
635            root: Some(root),
636            protected_paths: &protected,
637        };
638        let Decision::Test(validated) = decide(&request).unwrap() else {
639            panic!("launcher must activate test mode")
640        };
641        assert_eq!(validated.path(), fs::canonicalize(&ws.home).unwrap());
642        assert_eq!(
643            validated.child(Path::new("data")).unwrap(),
644            fs::canonicalize(&ws.data_dir).unwrap()
645        );
646        let app = AppId::new("GenRight").with_qa_app_id("genright");
647        let opts = Options::new(Os::current())
648            .qa_overrides(true)
649            .protected_paths(protected.clone());
650        let dirs = AppDirs::resolve_with(&app, &opts, &|key| {
651            ws.env.get(key).map(std::ffi::OsString::from)
652        })
653        .unwrap();
654        assert_eq!(dirs.home, validated.path());
655        assert_eq!(dirs.data, validated.child(Path::new("data")).unwrap());
656        // Native trust context must ignore HOME/APPDATA/XDG values redirected by this launcher.
657        let native = AppDirs::resolve_with(
658            &app,
659            &Options::new(Os::current()).qa_overrides(true),
660            &|key| ws.env.get(key).map(std::ffi::OsString::from),
661        )
662        .unwrap();
663        assert_eq!(native.home, validated.path());
664        assert_eq!(
665            decide(&Request {
666                activated: false,
667                ..request.clone()
668            }),
669            Err(Refusal::RootWithoutActivation)
670        );
671        assert_eq!(
672            decide(&Request {
673                root: None,
674                ..request.clone()
675            }),
676            Err(Refusal::ActivationWithoutRoot)
677        );
678        let binding_path = ws.home.join(BINDING_FILE);
679        let original = fs::read(&binding_path).unwrap();
680        for invalid in [
681            r#"{"nested":{"app":"genright"}}"#,
682            r#"{"app":"genright""#,
683            r#"{"app":"genright"}junk"#,
684        ] {
685            fs::write(&binding_path, invalid).unwrap();
686            assert_eq!(decide(&request), Err(Refusal::RootNotCreatedByRunner));
687        }
688        fs::write(&binding_path, original).unwrap();
689        let cloud = ws.home.join("OneDrive").join("bound");
690        rightkit_fs::private::ensure_private_dir(&cloud).unwrap();
691        fs::write(cloud.join(BINDING_FILE), r#"{"app":"genright"}"#).unwrap();
692        assert_eq!(
693            decide(&Request {
694                root: Some(&cloud),
695                ..request.clone()
696            }),
697            Err(Refusal::RootOnRemovableOrCloud)
698        );
699        #[cfg(unix)]
700        {
701            use std::os::unix::fs::PermissionsExt;
702            fs::set_permissions(&ws.home, fs::Permissions::from_mode(0o755)).unwrap();
703            assert_eq!(decide(&request), Err(Refusal::RootNotPrivate));
704            rightkit_fs::private::ensure_private_dir(&ws.home).unwrap();
705            let link = cache.join("home-alias");
706            std::os::unix::fs::symlink(&ws.home, &link).unwrap();
707            assert_eq!(
708                decide(&Request {
709                    root: Some(&link),
710                    ..request.clone()
711                }),
712                Err(Refusal::RootHasSymlink)
713            );
714            let aliases = vec![link.join("protected")];
715            assert_eq!(
716                decide(&Request {
717                    protected_paths: &aliases,
718                    ..request
719                }),
720                Err(Refusal::RootOverlapsUserData)
721            );
722        }
723    }
724
725    #[test]
726    fn isolates_and_disposes() {
727        let root = storage::app_temp_dir().join(format!("rkqa-ws-{}", new_id()));
728        let ws = create(&root, Some("run1"), "genright").unwrap();
729        assert!(ws.env.contains_key("GENRIGHT_DATA_DIR"));
730        // Activation & bound root travel together; the root is the bound home, not home/data.
731        assert_eq!(ws.env["RIGHTKIT_UNDER_TEST"], "1");
732        assert_eq!(
733            Path::new(&ws.env["RIGHTKIT_QA_DATA_DIR"]),
734            ws.home.as_path()
735        );
736        assert_ne!(
737            Path::new(&ws.env["RIGHTKIT_QA_DATA_DIR"]),
738            ws.data_dir.as_path()
739        );
740        assert!(
741            Path::new(&ws.env["RIGHTKIT_QA_DATA_DIR"])
742                .join(".rightkit-app.json")
743                .is_file(),
744            "the bound root carries the runner binding"
745        );
746        assert!(ws.data_dir.exists());
747        assert!(!ws.root.join("data").exists());
748        // The home is created under the canonical temp directory, which on Windows differs in
749        // form from the one the environment names (short names, no extended-length prefix).
750        let temp = crate::util::canonical_plain(&storage::app_temp_dir()).unwrap();
751        assert!(ws.home.starts_with(temp.join("rightkit-qa")));
752        assert!(!ws.home.starts_with(&ws.root));
753        assert!(ws.evidence_dir.starts_with(&ws.root));
754        assert!(ws.capture_dir.starts_with(&ws.root));
755        for key in [
756            "RIGHTKIT_RUN_ROOT",
757            "RIGHTKIT_SUITE_ROOT",
758            "HOME",
759            "USERPROFILE",
760            "APPDATA",
761            "LOCALAPPDATA",
762            "XDG_CACHE_HOME",
763            "XDG_DATA_HOME",
764            "XDG_CONFIG_HOME",
765            "WEBVIEW2_USER_DATA_FOLDER",
766            "GENRIGHT_DATA_DIR",
767            "RIGHTKIT_QA_DATA_DIR",
768            "RIGHTKIT_QA_EVIDENCE_DIR",
769            "RIGHTKIT_QA_CAPTURE_DIR",
770            "TMPDIR",
771            "TMP",
772            "TEMP",
773        ] {
774            assert!(Path::new(&ws.env[key]).starts_with(&ws.home), "{key}");
775        }
776        for (key, value) in &ws.env {
777            let path = Path::new(value);
778            if path.starts_with(&ws.home) {
779                assert!(path.is_dir(), "{key} -> {value} must exist as a directory");
780            }
781        }
782        let known: &[&str] = if cfg!(windows) {
783            &["AppData/Local", "AppData/Roaming", "AppData/LocalLow"]
784        } else if cfg!(target_os = "macos") {
785            &["Library/Application Support", "Library/Caches"]
786        } else {
787            &[".local/share", ".config", ".cache"]
788        };
789        for dir in known {
790            assert!(ws.home.join(dir).is_dir(), "{dir}");
791        }
792        fs::write(ws.home.join("app-launch.log"), "live log").unwrap();
793        fs::write(ws.home.join("evidence/from-app.json"), "app evidence").unwrap();
794        fs::write(ws.home.join("captures/frame.png"), "frame").unwrap();
795        fs::write(ws.data_dir.join("result.txt"), "result").unwrap();
796        let artifact = ws
797            .preserve_artifact(&ws.data_dir.join("result.txt"))
798            .unwrap();
799        assert_eq!(artifact, ws.evidence_dir.join("app/data/result.txt"));
800        assert!(create(&root, Some("../x"), "a").is_err());
801        let r = dispose(&ws, true, 2, 1);
802        assert!(
803            r.retained.is_empty()
804                && !ws.data_dir.exists()
805                && ws.root.exists()
806                && ws.evidence_dir.exists()
807        );
808        assert!(!ws.home.exists());
809        assert_eq!(fs::read_to_string(artifact).unwrap(), "result");
810        assert_eq!(
811            fs::read_to_string(ws.evidence_dir.join("app-launch.log")).unwrap(),
812            "live log"
813        );
814        assert_eq!(
815            fs::read_to_string(ws.evidence_dir.join("app/from-app.json")).unwrap(),
816            "app evidence"
817        );
818        assert_eq!(
819            fs::read_to_string(ws.capture_dir.join("frame.png")).unwrap(),
820            "frame"
821        );
822        let r = dispose(&ws, false, 2, 1);
823        assert!(r.retained.is_empty() && !ws.root.exists());
824        let _ = fs::remove_dir_all(root);
825    }
826
827    #[test]
828    fn orphan_sweep_removes_registered_temp_home_but_spares_active_runs() {
829        // Canonical from the start, so the paths the sweep reports and the workspace's own
830        // root have the same form on Windows.
831        let root = fs::canonicalize(storage::app_temp_dir())
832            .unwrap()
833            .join(format!("rkqa-orphan-{}", new_id()));
834        let ws = create(&root, Some("orphan"), "app").unwrap();
835        assert!(prune_abandoned(&root, Duration::ZERO, &[])
836            .removed
837            .is_empty());
838        assert!(ws.home.exists());
839        ws.finish().unwrap();
840        let report = prune_abandoned(&root, Duration::ZERO, &[]);
841        assert!(report.retained.is_empty());
842        let removed: Vec<PathBuf> = report
843            .removed
844            .iter()
845            .map(|p| crate::util::plain_path(p))
846            .collect();
847        assert_eq!(removed, vec![ws.root.clone()]);
848        assert!(!ws.home.exists());
849        fs::remove_dir_all(root).unwrap();
850    }
851
852    /// Orthic-Labs/rightkit#185: apps and tests get ordinary drive paths, never `\\?\C:\...`.
853    #[test]
854    fn workspace_paths_and_env_are_plain_drive_paths() {
855        let root = storage::app_temp_dir().join(format!("rkqa-plain-{}", new_id()));
856        let ws = create(&root, Some("plain1"), "plainapp").unwrap();
857        let paths = [
858            &ws.root,
859            &ws.home,
860            &ws.data_dir,
861            &ws.evidence_dir,
862            &ws.capture_dir,
863        ];
864        for p in paths {
865            assert!(!p.to_string_lossy().starts_with(r"\\?\"), "{}", p.display());
866            assert!(p.exists(), "{}", p.display());
867        }
868        for (key, value) in &ws.env {
869            assert!(!value.starts_with(r"\\?\"), "{key}={value}");
870        }
871        // Plain does not break the safety checks: the finished run is still disposed.
872        let report = dispose(&ws, false, 2, 1);
873        assert!(report.retained.is_empty(), "{report:?}");
874        assert!(!ws.home.exists() && !ws.root.exists());
875        let _ = fs::remove_dir_all(root);
876    }
877
878    #[test]
879    fn prune_spares_young_and_kept_runs() {
880        let root = storage::app_temp_dir().join(format!("rkqa-prune-{}", new_id()));
881        let a = storage::create(Some(&root), "x", Some("a"), Duration::from_secs(3600)).unwrap();
882        let _b = storage::create(Some(&root), "x", Some("b"), Duration::from_secs(3600)).unwrap();
883        storage::finish(&a).unwrap();
884        let r = prune_abandoned(&root, Duration::from_secs(3600), &["b".into()]);
885        assert!(r.removed.is_empty());
886        let r = prune_abandoned(&root, Duration::ZERO, &["b".into()]);
887        assert_eq!(r.removed.len(), 1);
888        assert!(root.join("runs/x/b").exists() && !root.join("runs/x/a").exists());
889        let _ = fs::remove_dir_all(root);
890    }
891
892    #[test]
893    fn dispose_deletes_only_a_home_the_runner_created() {
894        let root = storage::app_temp_dir().join(format!("rkqa-ws-own-{}", new_id()));
895        let mut ws = create(&root, Some("own1"), "ownapp").unwrap();
896        // A caller-supplied path that is not a QA home is never deleted.
897        let foreign = root.join("precious");
898        fs::create_dir_all(&foreign).unwrap();
899        fs::write(foreign.join("keep"), b"keep").unwrap();
900        let real_home = ws.home.clone();
901        ws.home = foreign.clone();
902        let report = dispose(&ws, true, 1, 0);
903        assert!(foreign.join("keep").exists(), "{report:?}");
904        assert!(report.retained.iter().any(|(p, _)| p == &foreign));
905        // The genuine home is removed.
906        ws.home = real_home.clone();
907        let report = dispose(&ws, false, 1, 0);
908        assert!(report.retained.is_empty(), "{report:?}");
909        assert!(!real_home.exists());
910        let _ = fs::remove_dir_all(root);
911    }
912
913    #[cfg(windows)]
914    #[test]
915    fn dispose_removes_a_home_that_holds_the_wininet_junction_and_a_link_out() {
916        // Gap ledger 216: every home of an app that used WinINet stayed behind.
917        let root = storage::app_temp_dir().join(format!("rkqa-ws-links-{}", new_id()));
918        let ws = create(&root, Some("links1"), "linkapp").unwrap();
919        let models = root.join("models-cache");
920        fs::create_dir_all(&models).unwrap();
921        fs::write(models.join("model.bin"), b"model bytes").unwrap();
922        storage::test_links::wininet_cache(&ws.home);
923        fs::create_dir_all(ws.home.join("data").join("shared")).unwrap();
924        storage::test_links::junction(&ws.home.join("data").join("shared").join("models"), &models);
925        let report = dispose(&ws, true, 1, 0);
926        assert!(report.retained.is_empty(), "{report:?}");
927        assert!(!ws.home.exists());
928        assert_eq!(fs::read(models.join("model.bin")).unwrap(), b"model bytes");
929        let _ = fs::remove_dir_all(root);
930    }
931
932    fn scanned(pid: u32, parent: u32, flags: &str, name: &str) -> ScannedProcess {
933        ScannedProcess {
934            pid,
935            parent,
936            names_home: flags.contains('C'),
937            runs_from_home: flags.contains('E'),
938            name: name.into(),
939        }
940    }
941
942    #[test]
943    fn the_process_scan_is_read_row_by_row() {
944        let scan = parse_scan(
945            "  412\t98\t-C\tmsedgewebview2.exe\r\n98\t4\t-\tCutRight.exe\n\nnot a row\n7\t1\t-CE\ttool.exe\n",
946        );
947        assert_eq!(
948            scan,
949            vec![
950                scanned(412, 98, "C", "msedgewebview2.exe"),
951                scanned(98, 4, "", "CutRight.exe"),
952                scanned(7, 1, "CE", "tool.exe"),
953            ]
954        );
955        assert!(parse_scan("").is_empty());
956    }
957
958    #[test]
959    fn only_the_runs_own_processes_may_be_ended_and_every_holder_is_named() {
960        let me = std::process::id();
961        let scan = vec![
962            // The launched app (already recorded), its WebView2 browser and that one's GPU child.
963            scanned(98, 4, "", "CutRight.exe"),
964            scanned(412, 98, "C", "msedgewebview2.exe"),
965            scanned(413, 412, "C", "msedgewebview2.exe"),
966            // A tool that runs from inside the home.
967            scanned(7, 1, "CE", "tool.exe"),
968            // Someone's editor or shell that merely names the home: a holder, never ended.
969            scanned(900, 1, "C", "notepad.exe"),
970            // Unrelated, and this process itself even if its command line named the home.
971            scanned(5000, 1, "", "explorer.exe"),
972            scanned(me, 1, "C", "rightkit-qa.exe"),
973        ];
974        let mut launched = std::collections::BTreeSet::from([98]);
975        extend_descendants(&mut launched, &scan);
976        assert_eq!(launched, std::collections::BTreeSet::from([98, 412, 413]));
977
978        let holders = holders_in(&scan, &launched);
979        assert_eq!(
980            holders
981                .iter()
982                .map(|h| (h.pid, h.owned_by_run()))
983                .collect::<Vec<_>>(),
984            vec![(7, true), (412, true), (413, true), (900, false)]
985        );
986        assert_eq!(
987            describe_holders(&holders),
988            "tool.exe (pid 7), msedgewebview2.exe (pid 412), msedgewebview2.exe (pid 413), notepad.exe (pid 900)"
989        );
990
991        // A later scan, after the browser process has exited: its child is still known.
992        let later = vec![scanned(413, 412, "C", "msedgewebview2.exe")];
993        extend_descendants(&mut launched, &later);
994        assert!(holders_in(&later, &launched)[0].launched_by_run);
995        // Without a recorded launch nothing that only names the home may be ended.
996        assert!(!holders_in(&later, &Default::default())[0].owned_by_run());
997    }
998
999    #[test]
1000    fn launched_pids_are_recorded_in_the_run_root() {
1001        let root = storage::app_temp_dir().join(format!("rkqa-ws-pids-{}", new_id()));
1002        let ws = create(&root, Some("pids1"), "pidsapp").unwrap();
1003        assert!(launched_pids(&ws).is_empty());
1004        record_launched(&ws, 4242);
1005        record_launched(&ws, 4343);
1006        record_launched(&ws, std::process::id());
1007        assert_eq!(
1008            launched_pids(&ws),
1009            std::collections::BTreeSet::from([4242, 4343])
1010        );
1011        let report = dispose(&ws, false, 2, 1);
1012        assert!(report.retained.is_empty(), "{report:?}");
1013        let _ = fs::remove_dir_all(root);
1014    }
1015
1016    /// Ledger 174. A helper that the launched app started outlives it, sits in the home (its
1017    /// working directory) and names it on its command line, as WebView2 does. Removing the home
1018    /// at once fails; disposal finds the helper, waits, ends it and then removes the home.
1019    #[cfg(windows)]
1020    #[test]
1021    fn dispose_waits_for_a_helper_the_app_left_in_the_home_then_removes_it() {
1022        use std::os::windows::process::CommandExt;
1023        let root = storage::app_temp_dir().join(format!("rkqa-ws-held-{}", new_id()));
1024        let ws = create(&root, Some("held1"), "heldapp").unwrap();
1025        // The "app": a shell the run launched. Its child keeps running in the home.
1026        let mut app = std::process::Command::new("cmd.exe")
1027            .args(["/d", "/c", "ping -n 120 127.0.0.1 >nul & rem"])
1028            .arg(format!("--user-data-dir={}", ws.home.display()))
1029            .current_dir(&ws.home)
1030            .creation_flags(0x0800_0000)
1031            .spawn()
1032            .unwrap();
1033        record_launched(&ws, app.id());
1034
1035        // The scan names it, and it is not this process.
1036        let holders = home_holders(&ws.home).expect("the process scan runs");
1037        assert!(
1038            holders.iter().any(|holder| holder.pid == app.id()),
1039            "{holders:?}"
1040        );
1041        assert!(holders
1042            .iter()
1043            .all(|holder| holder.pid != std::process::id()));
1044
1045        let report = dispose_waiting(&ws, false, 20, 250, Duration::from_secs(2));
1046        assert!(report.retained.is_empty(), "{report:?}");
1047        assert!(!ws.home.exists());
1048        let _ = app.kill();
1049        let _ = app.wait();
1050        let _ = fs::remove_dir_all(root);
1051    }
1052
1053    /// A process that only names the home on its command line, and that the run did not launch,
1054    /// is left running and named in the report.
1055    #[cfg(windows)]
1056    #[test]
1057    fn dispose_leaves_a_foreign_process_that_names_the_home_and_reports_it() {
1058        use std::os::windows::process::CommandExt;
1059        let root = storage::app_temp_dir().join(format!("rkqa-ws-foreign-{}", new_id()));
1060        let ws = create(&root, Some("foreign1"), "foreignapp").unwrap();
1061        let mut foreign = std::process::Command::new("cmd.exe")
1062            .args(["/d", "/c", "ping -n 120 127.0.0.1 >nul & rem"])
1063            .arg(format!("--watch={}", ws.home.display()))
1064            .current_dir(&ws.home)
1065            .creation_flags(0x0800_0000)
1066            .spawn()
1067            .unwrap();
1068
1069        let report = dispose_waiting(&ws, false, 2, 50, Duration::from_millis(500));
1070        assert!(matches!(foreign.try_wait(), Ok(None)), "it must still run");
1071        let (_, why) = report
1072            .retained
1073            .iter()
1074            .find(|(path, _)| path == &ws.home)
1075            .unwrap_or_else(|| panic!("the home is held: {report:?}"));
1076        assert!(
1077            why.contains(&format!("held by cmd.exe (pid {})", foreign.id())),
1078            "{why}"
1079        );
1080
1081        crate::process::kill_tree(foreign.id());
1082        let _ = foreign.wait();
1083        // With the holder gone the home can be removed.
1084        crate::util::sleep_ms(300);
1085        assert!(fs::remove_dir_all(&ws.home).is_ok());
1086        let _ = fs::remove_dir_all(root);
1087    }
1088}