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. It is private to the user, like
268    /// a home, and carries a runner binding (`.rightkit-app.json`) with the home's `app`, `runRoot`
269    /// and `runId` plus the scenario `name`, so a test-capable app can be given it as its bound
270    /// root. The binding is bookkeeping, not proof of the runner: rightkit-fs checks only that its
271    /// `app` equals the app's id.
272    pub fn scenario_dir(&self, name: &str) -> Result<PathBuf> {
273        let safe: String = name
274            .chars()
275            .map(|c| {
276                if c.is_ascii_alphanumeric() || c == '-' {
277                    c
278                } else {
279                    '_'
280                }
281            })
282            .collect();
283        let dir = self.data_dir.join(format!("{safe}-{}", &new_id()[..8]));
284        rightkit_fs::private::ensure_private_dir(&dir)
285            .map_err(|e| crate::util::Error(format!("cannot create private scenario dir: {e}")))?;
286        storage::write_scenario_binding(&self.home, &dir, name)?;
287        Ok(dir)
288    }
289}
290
291/// A process that still uses a QA home.
292#[derive(Debug, Clone, PartialEq, Eq)]
293pub struct HomeHolder {
294    pub pid: u32,
295    pub name: String,
296    /// Its executable is inside the home.
297    pub runs_from_home: bool,
298    /// It descends from an app this run launched into the home (see [`record_launched`]).
299    pub launched_by_run: bool,
300}
301
302impl HomeHolder {
303    /// Whether disposal may end it. A process is never ended merely because its command line
304    /// names the home: only one that runs from the home, or that the run's own app started.
305    pub fn owned_by_run(&self) -> bool {
306        self.runs_from_home || self.launched_by_run
307    }
308}
309
310/// One row of the process scan.
311#[derive(Debug, Clone, PartialEq, Eq)]
312struct ScannedProcess {
313    pid: u32,
314    parent: u32,
315    names_home: bool,
316    runs_from_home: bool,
317    name: String,
318}
319
320/// The process scan's output: `pid<TAB>parent pid<TAB>flags<TAB>name` per process, where flags
321/// holds `C` (the command line names the home) and `E` (the executable is inside it).
322#[cfg_attr(not(windows), allow(dead_code))]
323fn parse_scan(output: &str) -> Vec<ScannedProcess> {
324    output
325        .lines()
326        .filter_map(|line| {
327            let mut fields = line.trim().splitn(4, '\t');
328            let pid = fields.next()?.trim().parse::<u32>().ok()?;
329            let parent = fields.next()?.trim().parse::<u32>().ok()?;
330            let flags = fields.next()?;
331            let name = fields.next()?.trim().to_string();
332            Some(ScannedProcess {
333                pid,
334                parent,
335                names_home: flags.contains('C'),
336                runs_from_home: flags.contains('E'),
337                name,
338            })
339        })
340        .collect()
341}
342
343/// Add to `launched` every scanned process whose parent is already in it, until nothing is
344/// added: the apps the run launched and everything they started that is still listed. The set
345/// is kept across scans, so a helper stays known after its parent has exited.
346fn extend_descendants(launched: &mut std::collections::BTreeSet<u32>, scan: &[ScannedProcess]) {
347    loop {
348        let before = launched.len();
349        for process in scan {
350            if process.pid != 0 && launched.contains(&process.parent) {
351                launched.insert(process.pid);
352            }
353        }
354        if launched.len() == before {
355            return;
356        }
357    }
358}
359
360/// The scanned processes that use the home, without this process.
361fn holders_in(
362    scan: &[ScannedProcess],
363    launched: &std::collections::BTreeSet<u32>,
364) -> Vec<HomeHolder> {
365    let mut holders: Vec<HomeHolder> = scan
366        .iter()
367        .filter(|process| process.pid != std::process::id())
368        .filter(|process| process.names_home || process.runs_from_home)
369        .map(|process| HomeHolder {
370            pid: process.pid,
371            name: process.name.clone(),
372            runs_from_home: process.runs_from_home,
373            launched_by_run: launched.contains(&process.pid),
374        })
375        .collect();
376    holders.sort_by_key(|holder| holder.pid);
377    holders.dedup();
378    holders
379}
380
381fn describe_holders(holders: &[HomeHolder]) -> String {
382    holders
383        .iter()
384        .map(|holder| format!("{} (pid {})", holder.name, holder.pid))
385        .collect::<Vec<_>>()
386        .join(", ")
387}
388
389/// Windows: every process, marked when its command line names `home` or its executable lives in
390/// it. `None` when the scan itself failed.
391#[cfg(windows)]
392fn scan_processes(home: &Path) -> Option<Vec<ScannedProcess>> {
393    use std::os::windows::process::CommandExt;
394    use std::process::Command;
395    const SCAN: &str = "$h = $env:RIGHTKIT_QA_SCAN_HOME; \
396        Get-CimInstance Win32_Process | ForEach-Object { \
397        $f = '-'; \
398        if ($_.CommandLine -and $_.CommandLine.IndexOf($h, [StringComparison]::OrdinalIgnoreCase) -ge 0) { $f += 'C' }; \
399        if ($_.ExecutablePath -and $_.ExecutablePath.StartsWith($h + [char]92, [StringComparison]::OrdinalIgnoreCase)) { $f += 'E' }; \
400        [string]$_.ProcessId + [char]9 + [string]$_.ParentProcessId + [char]9 + $f + [char]9 + $_.Name }";
401    let system_root = std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into());
402    let powershell = Path::new(&system_root)
403        .join("System32")
404        .join("WindowsPowerShell")
405        .join("v1.0")
406        .join("powershell.exe");
407    // Bounded: a stalled WMI query must not hang the run; no answer reads as "scan unavailable".
408    let output = crate::process::output_bounded(
409        Command::new(powershell)
410            .args(["-NoProfile", "-NonInteractive", "-Command", SCAN])
411            // The home travels in the environment, so the scanner's own command line never names it.
412            .env("RIGHTKIT_QA_SCAN_HOME", home)
413            .creation_flags(0x0800_0000),
414        Duration::from_secs(60),
415    )
416    .ok()??;
417    output
418        .status
419        .success()
420        .then(|| parse_scan(&String::from_utf8_lossy(&output.stdout)))
421}
422
423/// Other systems remove a directory whatever holds its files open: nothing to scan.
424#[cfg(not(windows))]
425fn scan_processes(_home: &Path) -> Option<Vec<ScannedProcess>> {
426    Some(Vec::new())
427}
428
429/// Windows: the processes that use `home`: their command line names it, or their executable is
430/// inside it. Empty on other systems. `None` when the scan itself failed.
431///
432/// An app launched into a QA home starts helpers that outlive it for a moment after it is
433/// stopped: WebView2 and GPU processes carry the home in `--user-data-dir=` and keep its
434/// `EBWebView`, shader cache and log files open while they exit, so removing the home at once
435/// fails with "Access is denied".
436pub fn home_holders(home: &Path) -> Option<Vec<HomeHolder>> {
437    let scan = scan_processes(home)?;
438    Some(holders_in(&scan, &Default::default()))
439}
440
441/// The file in a run root that lists the pids of the apps launched into its home.
442const LAUNCHED_PIDS: &str = ".launched-pids";
443
444/// Record that this run launched process `pid` into the workspace's home. Disposal may end
445/// what that process started and left behind; see [`dispose`]. Called by `control::launch`;
446/// call it for an app started by hand.
447pub fn record_launched(ws: &QaWorkspace, pid: u32) {
448    use std::io::Write;
449    if let Ok(mut file) = fs::OpenOptions::new()
450        .create(true)
451        .append(true)
452        .open(ws.root.join(LAUNCHED_PIDS))
453    {
454        let _ = writeln!(file, "{pid}");
455    }
456}
457
458fn launched_pids(ws: &QaWorkspace) -> std::collections::BTreeSet<u32> {
459    fs::read_to_string(ws.root.join(LAUNCHED_PIDS))
460        .unwrap_or_default()
461        .lines()
462        .filter_map(|line| line.trim().parse().ok())
463        .filter(|pid| *pid != 0 && *pid != std::process::id())
464        .collect()
465}
466
467/// Wait until no process uses the home, at most `wait`. Then end the holders that belong to the
468/// run (they run from the home, or descend from an app the run launched) and leave the others.
469/// Returns the holders that were still there when the wait ran out.
470fn settle_home_holders(ws: &QaWorkspace, wait: Duration) -> Vec<HomeHolder> {
471    let deadline = std::time::Instant::now() + wait;
472    let mut launched = launched_pids(ws);
473    let mut holders;
474    loop {
475        let Some(scan) = scan_processes(&ws.home) else {
476            return Vec::new();
477        };
478        extend_descendants(&mut launched, &scan);
479        holders = holders_in(&scan, &launched);
480        if holders.is_empty() || std::time::Instant::now() >= deadline {
481            break;
482        }
483        crate::util::sleep_ms(250);
484    }
485    for holder in holders.iter().filter(|holder| holder.owned_by_run()) {
486        crate::process::kill_tree(holder.pid);
487    }
488    holders
489}
490
491/// How long [`dispose`] waits for the helpers of a stopped app to leave its home.
492const HOLDER_WAIT: Duration = Duration::from_secs(20);
493
494/// Remove the workspace (or only its volatile data dir), retrying through the
495/// window where an exiting app still holds files.
496///
497/// On Windows it first waits (up to 20 seconds) for every process that still uses the home to
498/// exit. Of those that remain it ends only the run's own: a process whose executable is inside
499/// the home, or that descends from an app the run launched into it ([`record_launched`]). A
500/// process that merely names the home on its command line is never ended. A home that still
501/// cannot be removed is reported with the processes that hold it.
502pub fn dispose(
503    ws: &QaWorkspace,
504    keep_evidence: bool,
505    attempts: u32,
506    retry_ms: u64,
507) -> DisposeReport {
508    dispose_waiting(ws, keep_evidence, attempts, retry_ms, HOLDER_WAIT)
509}
510
511fn dispose_waiting(
512    ws: &QaWorkspace,
513    keep_evidence: bool,
514    attempts: u32,
515    retry_ms: u64,
516    holder_wait: Duration,
517) -> DisposeReport {
518    let mut report = DisposeReport::default();
519    if let Err(e) = ws.finish() {
520        report.retained.push((ws.root.clone(), e.to_string()));
521        return report;
522    }
523    let mut targets = vec![ws.home.clone()];
524    if !keep_evidence {
525        targets.push(ws.root.clone());
526    }
527    for target in targets {
528        if !target.exists() {
529            report.removed.push(target);
530            continue;
531        }
532        // Deletion happens only inside the QA root this runner owns, never by a caller's path.
533        if target == ws.home && !storage::is_owned_home(&target) {
534            report.retained.push((
535                target,
536                "not a QA home created by rightkit-qa; not deleted".into(),
537            ));
538            continue;
539        }
540        if target == ws.home {
541            settle_home_holders(ws, holder_wait);
542        }
543        let mut last = String::new();
544        for attempt in 1..=attempts.max(1) {
545            // The home goes through the link-safe removal, which keeps the binding file until
546            // everything else is gone. The run root keeps `remove_dir_all`: it holds only files
547            // this crate wrote and no app profile lives in it, so no app-made link can be there.
548            // If a profile is ever placed in the run root, it must go through the same removal.
549            let removal = if target == ws.home {
550                match target.parent() {
551                    Some(qa_root) => storage::remove_home_tree(qa_root, &target),
552                    None => Err("home has no parent directory".to_string()),
553                }
554            } else {
555                fs::remove_dir_all(&target).map_err(|e| e.to_string())
556            };
557            match removal {
558                Ok(()) => {
559                    last.clear();
560                    break;
561                }
562                Err(e) => {
563                    last = e;
564                    if attempt < attempts {
565                        crate::util::sleep_ms(retry_ms);
566                    }
567                }
568            }
569        }
570        if last.is_empty() && !target.exists() {
571            report.removed.push(target);
572        } else {
573            let mut why = if last.is_empty() {
574                "still present".to_string()
575            } else {
576                last
577            };
578            if target == ws.home {
579                // Name what holds it, so the report says which program to look at.
580                match home_holders(&target) {
581                    Some(holders) if !holders.is_empty() => {
582                        why.push_str(&format!("; held by {}", describe_holders(&holders)));
583                    }
584                    Some(_) if cfg!(windows) => {
585                        why.push_str("; no running process names this home");
586                    }
587                    _ => {}
588                }
589            }
590            report.retained.push((target.clone(), why));
591            if target == ws.home {
592                if !keep_evidence {
593                    report
594                        .retained
595                        .push((ws.root.clone(), "app temp cleanup pending".into()));
596                }
597                break;
598            }
599        }
600    }
601    report
602}
603
604/// Sweep run directories left by crashed runs. Bounded to `<root>/runs`, to
605/// entries older than `older_than`, and never touches `keep`.
606pub fn prune_abandoned(cache_root: &Path, older_than: Duration, keep: &[String]) -> DisposeReport {
607    let mut report = DisposeReport::default();
608    let runs_root = cache_root.join("runs");
609    let Ok(entries) = fs::read_dir(&runs_root) else {
610        return report;
611    };
612    let entries: Vec<_> = entries.flatten().collect();
613    for entry in &entries {
614        let Ok(ft) = entry.file_type() else { continue };
615        if ft.is_dir() && !ft.is_symlink() {
616            let app = entry.file_name().to_string_lossy().to_string();
617            if let Ok(r) = storage::prune_with_age_override(cache_root, &app, keep, older_than) {
618                report.removed.extend(r.removed);
619                report.retained.extend(r.retained);
620            }
621        }
622    }
623    report
624}
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629
630    #[test]
631    fn launcher_home_passes_portable_and_app_dirs_decisions() {
632        use rightkit_fs::app_dirs::{AppDirs, AppId, Options, Os};
633        use rightkit_fs::test_mode::{decide, Decision, Refusal, Request, BINDING_FILE};
634        let cache = storage::app_temp_dir().join(format!("rkqa-decision-{}", new_id()));
635        let ws = create(&cache, Some("decision"), "genright").unwrap();
636        let protected = vec![cache.join("real-user-data")];
637        let root = Path::new(&ws.env["RIGHTKIT_QA_DATA_DIR"]);
638        let request = Request {
639            app_id: "genright",
640            activated: ws.env["RIGHTKIT_UNDER_TEST"] == "1",
641            root: Some(root),
642            protected_paths: &protected,
643        };
644        let Decision::Test(validated) = decide(&request).unwrap() else {
645            panic!("launcher must activate test mode")
646        };
647        assert_eq!(validated.path(), fs::canonicalize(&ws.home).unwrap());
648        assert_eq!(
649            validated.child(Path::new("data")).unwrap(),
650            fs::canonicalize(&ws.data_dir).unwrap()
651        );
652        let app = AppId::new("GenRight").with_qa_app_id("genright");
653        let opts = Options::new(Os::current())
654            .qa_overrides(true)
655            .protected_paths(protected.clone());
656        let dirs = AppDirs::resolve_with(&app, &opts, &|key| {
657            ws.env.get(key).map(std::ffi::OsString::from)
658        })
659        .unwrap();
660        assert_eq!(dirs.home, validated.path());
661        assert_eq!(dirs.data, validated.child(Path::new("data")).unwrap());
662        // Native trust context must ignore HOME/APPDATA/XDG values redirected by this launcher.
663        let native = AppDirs::resolve_with(
664            &app,
665            &Options::new(Os::current()).qa_overrides(true),
666            &|key| ws.env.get(key).map(std::ffi::OsString::from),
667        )
668        .unwrap();
669        assert_eq!(native.home, validated.path());
670        assert_eq!(
671            decide(&Request {
672                activated: false,
673                ..request.clone()
674            }),
675            Err(Refusal::RootWithoutActivation)
676        );
677        assert_eq!(
678            decide(&Request {
679                root: None,
680                ..request.clone()
681            }),
682            Err(Refusal::ActivationWithoutRoot)
683        );
684        let binding_path = ws.home.join(BINDING_FILE);
685        let original = fs::read(&binding_path).unwrap();
686        for invalid in [
687            r#"{"nested":{"app":"genright"}}"#,
688            r#"{"app":"genright""#,
689            r#"{"app":"genright"}junk"#,
690        ] {
691            fs::write(&binding_path, invalid).unwrap();
692            assert_eq!(decide(&request), Err(Refusal::RootNotCreatedByRunner));
693        }
694        fs::write(&binding_path, original).unwrap();
695        let cloud = ws.home.join("OneDrive").join("bound");
696        rightkit_fs::private::ensure_private_dir(&cloud).unwrap();
697        fs::write(cloud.join(BINDING_FILE), r#"{"app":"genright"}"#).unwrap();
698        assert_eq!(
699            decide(&Request {
700                root: Some(&cloud),
701                ..request.clone()
702            }),
703            Err(Refusal::RootOnRemovableOrCloud)
704        );
705        #[cfg(unix)]
706        {
707            use std::os::unix::fs::PermissionsExt;
708            fs::set_permissions(&ws.home, fs::Permissions::from_mode(0o755)).unwrap();
709            assert_eq!(decide(&request), Err(Refusal::RootNotPrivate));
710            rightkit_fs::private::ensure_private_dir(&ws.home).unwrap();
711            let link = cache.join("home-alias");
712            std::os::unix::fs::symlink(&ws.home, &link).unwrap();
713            assert_eq!(
714                decide(&Request {
715                    root: Some(&link),
716                    ..request.clone()
717                }),
718                Err(Refusal::RootHasSymlink)
719            );
720            let aliases = vec![link.join("protected")];
721            assert_eq!(
722                decide(&Request {
723                    protected_paths: &aliases,
724                    ..request
725                }),
726                Err(Refusal::RootOverlapsUserData)
727            );
728        }
729    }
730
731    #[test]
732    fn isolates_and_disposes() {
733        let root = storage::app_temp_dir().join(format!("rkqa-ws-{}", new_id()));
734        let ws = create(&root, Some("run1"), "genright").unwrap();
735        assert!(ws.env.contains_key("GENRIGHT_DATA_DIR"));
736        // Activation & bound root travel together; the root is the bound home, not home/data.
737        assert_eq!(ws.env["RIGHTKIT_UNDER_TEST"], "1");
738        assert_eq!(
739            Path::new(&ws.env["RIGHTKIT_QA_DATA_DIR"]),
740            ws.home.as_path()
741        );
742        assert_ne!(
743            Path::new(&ws.env["RIGHTKIT_QA_DATA_DIR"]),
744            ws.data_dir.as_path()
745        );
746        assert!(
747            Path::new(&ws.env["RIGHTKIT_QA_DATA_DIR"])
748                .join(".rightkit-app.json")
749                .is_file(),
750            "the bound root carries the runner binding"
751        );
752        assert!(ws.data_dir.exists());
753        assert!(!ws.root.join("data").exists());
754        // The home is created under the canonical temp directory, which on Windows differs in
755        // form from the one the environment names (short names, no extended-length prefix).
756        let temp = crate::util::canonical_plain(&storage::app_temp_dir()).unwrap();
757        assert!(ws.home.starts_with(temp.join("rightkit-qa")));
758        assert!(!ws.home.starts_with(&ws.root));
759        assert!(ws.evidence_dir.starts_with(&ws.root));
760        assert!(ws.capture_dir.starts_with(&ws.root));
761        for key in [
762            "RIGHTKIT_RUN_ROOT",
763            "RIGHTKIT_SUITE_ROOT",
764            "HOME",
765            "USERPROFILE",
766            "APPDATA",
767            "LOCALAPPDATA",
768            "XDG_CACHE_HOME",
769            "XDG_DATA_HOME",
770            "XDG_CONFIG_HOME",
771            "WEBVIEW2_USER_DATA_FOLDER",
772            "GENRIGHT_DATA_DIR",
773            "RIGHTKIT_QA_DATA_DIR",
774            "RIGHTKIT_QA_EVIDENCE_DIR",
775            "RIGHTKIT_QA_CAPTURE_DIR",
776            "TMPDIR",
777            "TMP",
778            "TEMP",
779        ] {
780            assert!(Path::new(&ws.env[key]).starts_with(&ws.home), "{key}");
781        }
782        for (key, value) in &ws.env {
783            let path = Path::new(value);
784            if path.starts_with(&ws.home) {
785                assert!(path.is_dir(), "{key} -> {value} must exist as a directory");
786            }
787        }
788        let known: &[&str] = if cfg!(windows) {
789            &["AppData/Local", "AppData/Roaming", "AppData/LocalLow"]
790        } else if cfg!(target_os = "macos") {
791            &["Library/Application Support", "Library/Caches"]
792        } else {
793            &[".local/share", ".config", ".cache"]
794        };
795        for dir in known {
796            assert!(ws.home.join(dir).is_dir(), "{dir}");
797        }
798        fs::write(ws.home.join("app-launch.log"), "live log").unwrap();
799        fs::write(ws.home.join("evidence/from-app.json"), "app evidence").unwrap();
800        fs::write(ws.home.join("captures/frame.png"), "frame").unwrap();
801        fs::write(ws.data_dir.join("result.txt"), "result").unwrap();
802        let artifact = ws
803            .preserve_artifact(&ws.data_dir.join("result.txt"))
804            .unwrap();
805        assert_eq!(artifact, ws.evidence_dir.join("app/data/result.txt"));
806        assert!(create(&root, Some("../x"), "a").is_err());
807        let r = dispose(&ws, true, 2, 1);
808        assert!(
809            r.retained.is_empty()
810                && !ws.data_dir.exists()
811                && ws.root.exists()
812                && ws.evidence_dir.exists()
813        );
814        assert!(!ws.home.exists());
815        assert_eq!(fs::read_to_string(artifact).unwrap(), "result");
816        assert_eq!(
817            fs::read_to_string(ws.evidence_dir.join("app-launch.log")).unwrap(),
818            "live log"
819        );
820        assert_eq!(
821            fs::read_to_string(ws.evidence_dir.join("app/from-app.json")).unwrap(),
822            "app evidence"
823        );
824        assert_eq!(
825            fs::read_to_string(ws.capture_dir.join("frame.png")).unwrap(),
826            "frame"
827        );
828        let r = dispose(&ws, false, 2, 1);
829        assert!(r.retained.is_empty() && !ws.root.exists());
830        let _ = fs::remove_dir_all(root);
831    }
832
833    #[test]
834    fn orphan_sweep_removes_registered_temp_home_but_spares_active_runs() {
835        // Canonical from the start, so the paths the sweep reports and the workspace's own
836        // root have the same form on Windows.
837        let root = fs::canonicalize(storage::app_temp_dir())
838            .unwrap()
839            .join(format!("rkqa-orphan-{}", new_id()));
840        let ws = create(&root, Some("orphan"), "app").unwrap();
841        assert!(prune_abandoned(&root, Duration::ZERO, &[])
842            .removed
843            .is_empty());
844        assert!(ws.home.exists());
845        ws.finish().unwrap();
846        let report = prune_abandoned(&root, Duration::ZERO, &[]);
847        assert!(report.retained.is_empty());
848        let removed: Vec<PathBuf> = report
849            .removed
850            .iter()
851            .map(|p| crate::util::plain_path(p))
852            .collect();
853        assert_eq!(removed, vec![ws.root.clone()]);
854        assert!(!ws.home.exists());
855        fs::remove_dir_all(root).unwrap();
856    }
857
858    /// Orthic-Labs/rightkit#185: apps and tests get ordinary drive paths, never `\\?\C:\...`.
859    #[test]
860    fn workspace_paths_and_env_are_plain_drive_paths() {
861        let root = storage::app_temp_dir().join(format!("rkqa-plain-{}", new_id()));
862        let ws = create(&root, Some("plain1"), "plainapp").unwrap();
863        let paths = [
864            &ws.root,
865            &ws.home,
866            &ws.data_dir,
867            &ws.evidence_dir,
868            &ws.capture_dir,
869        ];
870        for p in paths {
871            assert!(!p.to_string_lossy().starts_with(r"\\?\"), "{}", p.display());
872            assert!(p.exists(), "{}", p.display());
873        }
874        for (key, value) in &ws.env {
875            assert!(!value.starts_with(r"\\?\"), "{key}={value}");
876        }
877        // Plain does not break the safety checks: the finished run is still disposed.
878        let report = dispose(&ws, false, 2, 1);
879        assert!(report.retained.is_empty(), "{report:?}");
880        assert!(!ws.home.exists() && !ws.root.exists());
881        let _ = fs::remove_dir_all(root);
882    }
883
884    #[test]
885    fn prune_spares_young_and_kept_runs() {
886        let root = storage::app_temp_dir().join(format!("rkqa-prune-{}", new_id()));
887        let a = storage::create(Some(&root), "x", Some("a"), Duration::from_secs(3600)).unwrap();
888        let _b = storage::create(Some(&root), "x", Some("b"), Duration::from_secs(3600)).unwrap();
889        storage::finish(&a).unwrap();
890        let r = prune_abandoned(&root, Duration::from_secs(3600), &["b".into()]);
891        assert!(r.removed.is_empty());
892        let r = prune_abandoned(&root, Duration::ZERO, &["b".into()]);
893        assert_eq!(r.removed.len(), 1);
894        assert!(root.join("runs/x/b").exists() && !root.join("runs/x/a").exists());
895        let _ = fs::remove_dir_all(root);
896    }
897
898    #[test]
899    fn dispose_deletes_only_a_home_the_runner_created() {
900        let root = storage::app_temp_dir().join(format!("rkqa-ws-own-{}", new_id()));
901        let mut ws = create(&root, Some("own1"), "ownapp").unwrap();
902        // A caller-supplied path that is not a QA home is never deleted.
903        let foreign = root.join("precious");
904        fs::create_dir_all(&foreign).unwrap();
905        fs::write(foreign.join("keep"), b"keep").unwrap();
906        let real_home = ws.home.clone();
907        ws.home = foreign.clone();
908        let report = dispose(&ws, true, 1, 0);
909        assert!(foreign.join("keep").exists(), "{report:?}");
910        assert!(report.retained.iter().any(|(p, _)| p == &foreign));
911        // The genuine home is removed.
912        ws.home = real_home.clone();
913        let report = dispose(&ws, false, 1, 0);
914        assert!(report.retained.is_empty(), "{report:?}");
915        assert!(!real_home.exists());
916        let _ = fs::remove_dir_all(root);
917    }
918
919    #[cfg(windows)]
920    #[test]
921    fn dispose_removes_a_home_that_holds_the_wininet_junction_and_a_link_out() {
922        // Gap ledger 216: every home of an app that used WinINet stayed behind.
923        let root = storage::app_temp_dir().join(format!("rkqa-ws-links-{}", new_id()));
924        let ws = create(&root, Some("links1"), "linkapp").unwrap();
925        let models = root.join("models-cache");
926        fs::create_dir_all(&models).unwrap();
927        fs::write(models.join("model.bin"), b"model bytes").unwrap();
928        storage::test_links::wininet_cache(&ws.home);
929        fs::create_dir_all(ws.home.join("data").join("shared")).unwrap();
930        storage::test_links::junction(&ws.home.join("data").join("shared").join("models"), &models);
931        let report = dispose(&ws, true, 1, 0);
932        assert!(report.retained.is_empty(), "{report:?}");
933        assert!(!ws.home.exists());
934        assert_eq!(fs::read(models.join("model.bin")).unwrap(), b"model bytes");
935        let _ = fs::remove_dir_all(root);
936    }
937
938    fn scanned(pid: u32, parent: u32, flags: &str, name: &str) -> ScannedProcess {
939        ScannedProcess {
940            pid,
941            parent,
942            names_home: flags.contains('C'),
943            runs_from_home: flags.contains('E'),
944            name: name.into(),
945        }
946    }
947
948    #[test]
949    fn the_process_scan_is_read_row_by_row() {
950        let scan = parse_scan(
951            "  412\t98\t-C\tmsedgewebview2.exe\r\n98\t4\t-\tCutRight.exe\n\nnot a row\n7\t1\t-CE\ttool.exe\n",
952        );
953        assert_eq!(
954            scan,
955            vec![
956                scanned(412, 98, "C", "msedgewebview2.exe"),
957                scanned(98, 4, "", "CutRight.exe"),
958                scanned(7, 1, "CE", "tool.exe"),
959            ]
960        );
961        assert!(parse_scan("").is_empty());
962    }
963
964    #[test]
965    fn only_the_runs_own_processes_may_be_ended_and_every_holder_is_named() {
966        let me = std::process::id();
967        let scan = vec![
968            // The launched app (already recorded), its WebView2 browser and that one's GPU child.
969            scanned(98, 4, "", "CutRight.exe"),
970            scanned(412, 98, "C", "msedgewebview2.exe"),
971            scanned(413, 412, "C", "msedgewebview2.exe"),
972            // A tool that runs from inside the home.
973            scanned(7, 1, "CE", "tool.exe"),
974            // Someone's editor or shell that merely names the home: a holder, never ended.
975            scanned(900, 1, "C", "notepad.exe"),
976            // Unrelated, and this process itself even if its command line named the home.
977            scanned(5000, 1, "", "explorer.exe"),
978            scanned(me, 1, "C", "rightkit-qa.exe"),
979        ];
980        let mut launched = std::collections::BTreeSet::from([98]);
981        extend_descendants(&mut launched, &scan);
982        assert_eq!(launched, std::collections::BTreeSet::from([98, 412, 413]));
983
984        let holders = holders_in(&scan, &launched);
985        assert_eq!(
986            holders
987                .iter()
988                .map(|h| (h.pid, h.owned_by_run()))
989                .collect::<Vec<_>>(),
990            vec![(7, true), (412, true), (413, true), (900, false)]
991        );
992        assert_eq!(
993            describe_holders(&holders),
994            "tool.exe (pid 7), msedgewebview2.exe (pid 412), msedgewebview2.exe (pid 413), notepad.exe (pid 900)"
995        );
996
997        // A later scan, after the browser process has exited: its child is still known.
998        let later = vec![scanned(413, 412, "C", "msedgewebview2.exe")];
999        extend_descendants(&mut launched, &later);
1000        assert!(holders_in(&later, &launched)[0].launched_by_run);
1001        // Without a recorded launch nothing that only names the home may be ended.
1002        assert!(!holders_in(&later, &Default::default())[0].owned_by_run());
1003    }
1004
1005    #[test]
1006    fn launched_pids_are_recorded_in_the_run_root() {
1007        let root = storage::app_temp_dir().join(format!("rkqa-ws-pids-{}", new_id()));
1008        let ws = create(&root, Some("pids1"), "pidsapp").unwrap();
1009        assert!(launched_pids(&ws).is_empty());
1010        record_launched(&ws, 4242);
1011        record_launched(&ws, 4343);
1012        record_launched(&ws, std::process::id());
1013        assert_eq!(
1014            launched_pids(&ws),
1015            std::collections::BTreeSet::from([4242, 4343])
1016        );
1017        let report = dispose(&ws, false, 2, 1);
1018        assert!(report.retained.is_empty(), "{report:?}");
1019        let _ = fs::remove_dir_all(root);
1020    }
1021
1022    /// Ledger 174. A helper that the launched app started outlives it, sits in the home (its
1023    /// working directory) and names it on its command line, as WebView2 does. Removing the home
1024    /// at once fails; disposal finds the helper, waits, ends it and then removes the home.
1025    #[cfg(windows)]
1026    #[test]
1027    fn dispose_waits_for_a_helper_the_app_left_in_the_home_then_removes_it() {
1028        use std::os::windows::process::CommandExt;
1029        let root = storage::app_temp_dir().join(format!("rkqa-ws-held-{}", new_id()));
1030        let ws = create(&root, Some("held1"), "heldapp").unwrap();
1031        // The "app": a shell the run launched. Its child keeps running in the home.
1032        let mut app = std::process::Command::new("cmd.exe")
1033            .args(["/d", "/c", "ping -n 120 127.0.0.1 >nul & rem"])
1034            .arg(format!("--user-data-dir={}", ws.home.display()))
1035            .current_dir(&ws.home)
1036            .creation_flags(0x0800_0000)
1037            .spawn()
1038            .unwrap();
1039        record_launched(&ws, app.id());
1040
1041        // The scan names it, and it is not this process.
1042        let holders = home_holders(&ws.home).expect("the process scan runs");
1043        assert!(
1044            holders.iter().any(|holder| holder.pid == app.id()),
1045            "{holders:?}"
1046        );
1047        assert!(holders
1048            .iter()
1049            .all(|holder| holder.pid != std::process::id()));
1050
1051        let report = dispose_waiting(&ws, false, 20, 250, Duration::from_secs(2));
1052        assert!(report.retained.is_empty(), "{report:?}");
1053        assert!(!ws.home.exists());
1054        let _ = app.kill();
1055        let _ = app.wait();
1056        let _ = fs::remove_dir_all(root);
1057    }
1058
1059    /// A process that only names the home on its command line, and that the run did not launch,
1060    /// is left running and named in the report.
1061    #[cfg(windows)]
1062    #[test]
1063    fn dispose_leaves_a_foreign_process_that_names_the_home_and_reports_it() {
1064        use std::os::windows::process::CommandExt;
1065        let root = storage::app_temp_dir().join(format!("rkqa-ws-foreign-{}", new_id()));
1066        let ws = create(&root, Some("foreign1"), "foreignapp").unwrap();
1067        let mut foreign = std::process::Command::new("cmd.exe")
1068            .args(["/d", "/c", "ping -n 120 127.0.0.1 >nul & rem"])
1069            .arg(format!("--watch={}", ws.home.display()))
1070            .current_dir(&ws.home)
1071            .creation_flags(0x0800_0000)
1072            .spawn()
1073            .unwrap();
1074
1075        let report = dispose_waiting(&ws, false, 2, 50, Duration::from_millis(500));
1076        assert!(matches!(foreign.try_wait(), Ok(None)), "it must still run");
1077        let (_, why) = report
1078            .retained
1079            .iter()
1080            .find(|(path, _)| path == &ws.home)
1081            .unwrap_or_else(|| panic!("the home is held: {report:?}"));
1082        assert!(
1083            why.contains(&format!("held by cmd.exe (pid {})", foreign.id())),
1084            "{why}"
1085        );
1086
1087        crate::process::kill_tree(foreign.id());
1088        let _ = foreign.wait();
1089        // With the holder gone the home can be removed.
1090        crate::util::sleep_ms(300);
1091        assert!(fs::remove_dir_all(&ws.home).is_ok());
1092        let _ = fs::remove_dir_all(root);
1093    }
1094
1095    /// A scenario directory is bound only from the home's own binding; a home without one is
1096    /// refused rather than given an invented identity.
1097    #[test]
1098    fn scenario_dir_binds_from_the_home_binding_and_never_invents_one() {
1099        use rightkit_fs::test_mode::BINDING_FILE;
1100        let root = storage::app_temp_dir().join(format!("rkqa-ws-scn-{}", new_id()));
1101        let ws = create(&root, Some("scn1"), "scnapp").unwrap();
1102        let dir = ws.scenario_dir("a b/c").unwrap();
1103        assert!(dir.starts_with(&ws.data_dir));
1104        let home: serde_json::Value =
1105            serde_json::from_slice(&fs::read(ws.home.join(BINDING_FILE)).unwrap()).unwrap();
1106        let bound: serde_json::Value =
1107            serde_json::from_slice(&fs::read(dir.join(BINDING_FILE)).unwrap()).unwrap();
1108        assert_eq!(
1109            bound,
1110            serde_json::json!({
1111                "app": home["app"], "runRoot": home["runRoot"], "runId": home["runId"],
1112                "scenario": "a b/c",
1113            })
1114        );
1115        // Without the home's binding there is no identity to copy.
1116        let saved = fs::read(ws.home.join(BINDING_FILE)).unwrap();
1117        fs::remove_file(ws.home.join(BINDING_FILE)).unwrap();
1118        let e = ws.scenario_dir("late").unwrap_err();
1119        assert!(e.0.contains("no readable runner binding"), "{e}");
1120        fs::write(ws.home.join(BINDING_FILE), saved).unwrap();
1121        let report = dispose(&ws, false, 1, 0);
1122        assert!(report.retained.is_empty(), "{report:?}");
1123        assert!(!ws.home.exists());
1124        let _ = fs::remove_dir_all(root);
1125    }
1126}