1use 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 pub root: PathBuf,
16 pub home: PathBuf,
18 pub data_dir: PathBuf,
19 pub evidence_dir: PathBuf,
20 pub capture_dir: PathBuf,
21 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#[derive(Clone, Copy)]
35enum Preflight {
36 Full(storage::RootLimits),
37 SweepOnly,
38}
39
40pub 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
47pub 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
60pub 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
70pub(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 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 let home = storage::create_app_root(&mut managed)?;
95 let run_id = managed.marker.run_id.clone();
96 let root = managed.root;
97 fs::remove_dir(root.join("data"))?;
99 let data_dir = home.join("data");
100 let evidence_dir = root.join("evidence");
101 let capture_dir = root.join("captures");
102 let tmp_dir = home.join("tmp");
103 let cache_dir = home.join("cache");
104 fs::create_dir_all(&data_dir)?;
105 fs::create_dir_all(&evidence_dir)?;
106 fs::create_dir_all(&capture_dir)?;
107 fs::create_dir_all(&tmp_dir)?;
108 fs::create_dir_all(&cache_dir)?;
109 fs::create_dir_all(home.join("evidence"))?;
110 fs::create_dir_all(home.join("captures"))?;
111 create_profile_dirs(&home)?;
112 let mut env = BTreeMap::new();
113 env.insert("RIGHTKIT_QA_NATIVE".into(), "1".into());
114 env.insert("RIGHTKIT_QA_RUN_ID".into(), run_id.clone());
115 env.insert("RIGHTKIT_RUN_ROOT".into(), home.to_string_lossy().into());
116 env.insert(
117 "RIGHTKIT_SUITE_ROOT".into(),
118 home.join("suite").to_string_lossy().into(),
119 );
120 env.insert("HOME".into(), home.to_string_lossy().into());
121 env.insert("USERPROFILE".into(), home.to_string_lossy().into());
122 env.insert("APPDATA".into(), data_dir.to_string_lossy().into());
123 env.insert("LOCALAPPDATA".into(), data_dir.to_string_lossy().into());
124 env.insert("XDG_CACHE_HOME".into(), cache_dir.to_string_lossy().into());
125 env.insert(
126 "RIGHTKIT_QA_DATA_DIR".into(),
127 data_dir.to_string_lossy().into(),
128 );
129 env.insert(
130 "RIGHTKIT_QA_EVIDENCE_DIR".into(),
131 home.join("evidence").to_string_lossy().into(),
132 );
133 env.insert(
134 "RIGHTKIT_QA_CAPTURE_DIR".into(),
135 home.join("captures").to_string_lossy().into(),
136 );
137 env.insert("TMPDIR".into(), tmp_dir.to_string_lossy().into());
138 env.insert("TMP".into(), tmp_dir.to_string_lossy().into());
139 env.insert("TEMP".into(), tmp_dir.to_string_lossy().into());
140 env.insert("RIGHTKIT_QA_MUTE".into(), "1".into());
141 env.insert("XDG_DATA_HOME".into(), data_dir.to_string_lossy().into());
142 env.insert("XDG_CONFIG_HOME".into(), data_dir.to_string_lossy().into());
143 env.insert(
144 "WEBVIEW2_USER_DATA_FOLDER".into(),
145 data_dir.join("webview2").to_string_lossy().into(),
146 );
147 let key: String = app_key
148 .to_uppercase()
149 .chars()
150 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
151 .collect();
152 env.insert(format!("{key}_DATA_DIR"), data_dir.to_string_lossy().into());
153 for value in env.values() {
156 let path = Path::new(value);
157 if path.starts_with(&home) {
158 fs::create_dir_all(path)?;
159 }
160 }
161 Ok(QaWorkspace {
162 run_id,
163 root,
164 home,
165 data_dir,
166 evidence_dir,
167 capture_dir,
168 env,
169 })
170}
171
172fn create_profile_dirs(home: &Path) -> Result<()> {
178 let relative: &[&str] = if cfg!(windows) {
179 &[
180 "AppData/Local",
181 "AppData/Local/Temp",
182 "AppData/LocalLow",
183 "AppData/Roaming",
184 "Desktop",
185 "Documents",
186 "Downloads",
187 "Music",
188 "Pictures",
189 "Videos",
190 ]
191 } else if cfg!(target_os = "macos") {
192 &[
193 "Library/Application Support",
194 "Library/Caches",
195 "Library/Preferences",
196 "Library/Logs",
197 "Desktop",
198 "Documents",
199 "Downloads",
200 ]
201 } else {
202 &[
203 ".local/share",
204 ".local/state",
205 ".config",
206 ".cache",
207 "Desktop",
208 "Documents",
209 "Downloads",
210 ]
211 };
212 for dir in relative {
213 fs::create_dir_all(home.join(dir))?;
214 }
215 Ok(())
216}
217
218impl QaWorkspace {
219 pub(crate) fn preserve_artifact(&self, path: &Path) -> Result<PathBuf> {
221 let Ok(relative) = path.strip_prefix(&self.home) else {
222 return Ok(path.to_path_buf());
223 };
224 if relative
225 .components()
226 .any(|c| c == std::path::Component::ParentDir)
227 {
228 return crate::util::err("app artifact escapes QA app home");
229 }
230 let target = if let Ok(p) = relative.strip_prefix("captures") {
231 self.capture_dir.join(p)
232 } else {
233 self.evidence_dir
234 .join("app")
235 .join(relative.strip_prefix("evidence").unwrap_or(relative))
236 };
237 storage::copy_tree(path, &target)?;
238 Ok(target)
239 }
240
241 pub fn finish(&self) -> Result<()> {
243 if let Some(marker) = storage::read_marker(&self.root.join(storage::RUN_MARKER)) {
244 storage::finish(&storage::ManagedRun {
245 root: self.root.clone(),
246 marker,
247 })?;
248 }
249 Ok(())
250 }
251 pub fn scenario_dir(&self, name: &str) -> Result<PathBuf> {
253 let safe: String = name
254 .chars()
255 .map(|c| {
256 if c.is_ascii_alphanumeric() || c == '-' {
257 c
258 } else {
259 '_'
260 }
261 })
262 .collect();
263 let dir = self.data_dir.join(format!("{safe}-{}", &new_id()[..8]));
264 fs::create_dir_all(&dir)?;
265 Ok(dir)
266 }
267}
268
269#[derive(Debug, Clone, PartialEq, Eq)]
271pub struct HomeHolder {
272 pub pid: u32,
273 pub name: String,
274 pub runs_from_home: bool,
276 pub launched_by_run: bool,
278}
279
280impl HomeHolder {
281 pub fn owned_by_run(&self) -> bool {
284 self.runs_from_home || self.launched_by_run
285 }
286}
287
288#[derive(Debug, Clone, PartialEq, Eq)]
290struct ScannedProcess {
291 pid: u32,
292 parent: u32,
293 names_home: bool,
294 runs_from_home: bool,
295 name: String,
296}
297
298#[cfg_attr(not(windows), allow(dead_code))]
301fn parse_scan(output: &str) -> Vec<ScannedProcess> {
302 output
303 .lines()
304 .filter_map(|line| {
305 let mut fields = line.trim().splitn(4, '\t');
306 let pid = fields.next()?.trim().parse::<u32>().ok()?;
307 let parent = fields.next()?.trim().parse::<u32>().ok()?;
308 let flags = fields.next()?;
309 let name = fields.next()?.trim().to_string();
310 Some(ScannedProcess {
311 pid,
312 parent,
313 names_home: flags.contains('C'),
314 runs_from_home: flags.contains('E'),
315 name,
316 })
317 })
318 .collect()
319}
320
321fn extend_descendants(launched: &mut std::collections::BTreeSet<u32>, scan: &[ScannedProcess]) {
325 loop {
326 let before = launched.len();
327 for process in scan {
328 if process.pid != 0 && launched.contains(&process.parent) {
329 launched.insert(process.pid);
330 }
331 }
332 if launched.len() == before {
333 return;
334 }
335 }
336}
337
338fn holders_in(
340 scan: &[ScannedProcess],
341 launched: &std::collections::BTreeSet<u32>,
342) -> Vec<HomeHolder> {
343 let mut holders: Vec<HomeHolder> = scan
344 .iter()
345 .filter(|process| process.pid != std::process::id())
346 .filter(|process| process.names_home || process.runs_from_home)
347 .map(|process| HomeHolder {
348 pid: process.pid,
349 name: process.name.clone(),
350 runs_from_home: process.runs_from_home,
351 launched_by_run: launched.contains(&process.pid),
352 })
353 .collect();
354 holders.sort_by_key(|holder| holder.pid);
355 holders.dedup();
356 holders
357}
358
359fn describe_holders(holders: &[HomeHolder]) -> String {
360 holders
361 .iter()
362 .map(|holder| format!("{} (pid {})", holder.name, holder.pid))
363 .collect::<Vec<_>>()
364 .join(", ")
365}
366
367#[cfg(windows)]
370fn scan_processes(home: &Path) -> Option<Vec<ScannedProcess>> {
371 use std::os::windows::process::CommandExt;
372 use std::process::{Command, Stdio};
373 const SCAN: &str = "$h = $env:RIGHTKIT_QA_SCAN_HOME; \
374 Get-CimInstance Win32_Process | ForEach-Object { \
375 $f = '-'; \
376 if ($_.CommandLine -and $_.CommandLine.IndexOf($h, [StringComparison]::OrdinalIgnoreCase) -ge 0) { $f += 'C' }; \
377 if ($_.ExecutablePath -and $_.ExecutablePath.StartsWith($h + [char]92, [StringComparison]::OrdinalIgnoreCase)) { $f += 'E' }; \
378 [string]$_.ProcessId + [char]9 + [string]$_.ParentProcessId + [char]9 + $f + [char]9 + $_.Name }";
379 let system_root = std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into());
380 let powershell = Path::new(&system_root)
381 .join("System32")
382 .join("WindowsPowerShell")
383 .join("v1.0")
384 .join("powershell.exe");
385 let output = Command::new(powershell)
386 .args(["-NoProfile", "-NonInteractive", "-Command", SCAN])
387 .env("RIGHTKIT_QA_SCAN_HOME", home)
389 .stdin(Stdio::null())
390 .stderr(Stdio::null())
391 .creation_flags(0x0800_0000)
392 .output()
393 .ok()?;
394 output
395 .status
396 .success()
397 .then(|| parse_scan(&String::from_utf8_lossy(&output.stdout)))
398}
399
400#[cfg(not(windows))]
402fn scan_processes(_home: &Path) -> Option<Vec<ScannedProcess>> {
403 Some(Vec::new())
404}
405
406pub fn home_holders(home: &Path) -> Option<Vec<HomeHolder>> {
414 let scan = scan_processes(home)?;
415 Some(holders_in(&scan, &Default::default()))
416}
417
418const LAUNCHED_PIDS: &str = ".launched-pids";
420
421pub fn record_launched(ws: &QaWorkspace, pid: u32) {
425 use std::io::Write;
426 if let Ok(mut file) = fs::OpenOptions::new()
427 .create(true)
428 .append(true)
429 .open(ws.root.join(LAUNCHED_PIDS))
430 {
431 let _ = writeln!(file, "{pid}");
432 }
433}
434
435fn launched_pids(ws: &QaWorkspace) -> std::collections::BTreeSet<u32> {
436 fs::read_to_string(ws.root.join(LAUNCHED_PIDS))
437 .unwrap_or_default()
438 .lines()
439 .filter_map(|line| line.trim().parse().ok())
440 .filter(|pid| *pid != 0 && *pid != std::process::id())
441 .collect()
442}
443
444fn settle_home_holders(ws: &QaWorkspace, wait: Duration) -> Vec<HomeHolder> {
448 let deadline = std::time::Instant::now() + wait;
449 let mut launched = launched_pids(ws);
450 let mut holders;
451 loop {
452 let Some(scan) = scan_processes(&ws.home) else {
453 return Vec::new();
454 };
455 extend_descendants(&mut launched, &scan);
456 holders = holders_in(&scan, &launched);
457 if holders.is_empty() || std::time::Instant::now() >= deadline {
458 break;
459 }
460 crate::util::sleep_ms(250);
461 }
462 for holder in holders.iter().filter(|holder| holder.owned_by_run()) {
463 crate::process::kill_tree(holder.pid);
464 }
465 holders
466}
467
468const HOLDER_WAIT: Duration = Duration::from_secs(20);
470
471pub fn dispose(
480 ws: &QaWorkspace,
481 keep_evidence: bool,
482 attempts: u32,
483 retry_ms: u64,
484) -> DisposeReport {
485 dispose_waiting(ws, keep_evidence, attempts, retry_ms, HOLDER_WAIT)
486}
487
488fn dispose_waiting(
489 ws: &QaWorkspace,
490 keep_evidence: bool,
491 attempts: u32,
492 retry_ms: u64,
493 holder_wait: Duration,
494) -> DisposeReport {
495 let mut report = DisposeReport::default();
496 if let Err(e) = ws.finish() {
497 report.retained.push((ws.root.clone(), e.to_string()));
498 return report;
499 }
500 let mut targets = vec![ws.home.clone()];
501 if !keep_evidence {
502 targets.push(ws.root.clone());
503 }
504 for target in targets {
505 if !target.exists() {
506 report.removed.push(target);
507 continue;
508 }
509 if target == ws.home && !storage::is_owned_home(&target) {
511 report.retained.push((
512 target,
513 "not a QA home created by rightkit-qa; not deleted".into(),
514 ));
515 continue;
516 }
517 if target == ws.home {
518 settle_home_holders(ws, holder_wait);
519 }
520 let mut last = String::new();
521 for attempt in 1..=attempts.max(1) {
522 let removal = if target == ws.home {
527 match target.parent() {
528 Some(qa_root) => storage::remove_home_tree(qa_root, &target),
529 None => Err("home has no parent directory".to_string()),
530 }
531 } else {
532 fs::remove_dir_all(&target).map_err(|e| e.to_string())
533 };
534 match removal {
535 Ok(()) => {
536 last.clear();
537 break;
538 }
539 Err(e) => {
540 last = e;
541 if attempt < attempts {
542 crate::util::sleep_ms(retry_ms);
543 }
544 }
545 }
546 }
547 if last.is_empty() && !target.exists() {
548 report.removed.push(target);
549 } else {
550 let mut why = if last.is_empty() {
551 "still present".to_string()
552 } else {
553 last
554 };
555 if target == ws.home {
556 match home_holders(&target) {
558 Some(holders) if !holders.is_empty() => {
559 why.push_str(&format!("; held by {}", describe_holders(&holders)));
560 }
561 Some(_) if cfg!(windows) => {
562 why.push_str("; no running process names this home");
563 }
564 _ => {}
565 }
566 }
567 report.retained.push((target.clone(), why));
568 if target == ws.home {
569 if !keep_evidence {
570 report
571 .retained
572 .push((ws.root.clone(), "app temp cleanup pending".into()));
573 }
574 break;
575 }
576 }
577 }
578 report
579}
580
581pub fn prune_abandoned(cache_root: &Path, older_than: Duration, keep: &[String]) -> DisposeReport {
584 let mut report = DisposeReport::default();
585 let runs_root = cache_root.join("runs");
586 let Ok(entries) = fs::read_dir(&runs_root) else {
587 return report;
588 };
589 let entries: Vec<_> = entries.flatten().collect();
590 for entry in &entries {
591 let Ok(ft) = entry.file_type() else { continue };
592 if ft.is_dir() && !ft.is_symlink() {
593 let app = entry.file_name().to_string_lossy().to_string();
594 if let Ok(r) = storage::prune_with_age_override(cache_root, &app, keep, older_than) {
595 report.removed.extend(r.removed);
596 report.retained.extend(r.retained);
597 }
598 }
599 }
600 report
601}
602
603#[cfg(test)]
604mod tests {
605 use super::*;
606
607 #[test]
608 fn isolates_and_disposes() {
609 let root = storage::app_temp_dir().join(format!("rkqa-ws-{}", new_id()));
610 let ws = create(&root, Some("run1"), "genright").unwrap();
611 assert!(ws.env.contains_key("GENRIGHT_DATA_DIR"));
612 assert!(ws.data_dir.exists());
613 assert!(!ws.root.join("data").exists());
614 let temp = fs::canonicalize(storage::app_temp_dir()).unwrap();
617 assert!(ws.home.starts_with(temp.join("rightkit-qa")));
618 assert!(!ws.home.starts_with(&ws.root));
619 assert!(ws.evidence_dir.starts_with(&ws.root));
620 assert!(ws.capture_dir.starts_with(&ws.root));
621 for key in [
622 "RIGHTKIT_RUN_ROOT",
623 "RIGHTKIT_SUITE_ROOT",
624 "HOME",
625 "USERPROFILE",
626 "APPDATA",
627 "LOCALAPPDATA",
628 "XDG_CACHE_HOME",
629 "XDG_DATA_HOME",
630 "XDG_CONFIG_HOME",
631 "WEBVIEW2_USER_DATA_FOLDER",
632 "GENRIGHT_DATA_DIR",
633 "RIGHTKIT_QA_DATA_DIR",
634 "RIGHTKIT_QA_EVIDENCE_DIR",
635 "RIGHTKIT_QA_CAPTURE_DIR",
636 "TMPDIR",
637 "TMP",
638 "TEMP",
639 ] {
640 assert!(Path::new(&ws.env[key]).starts_with(&ws.home), "{key}");
641 }
642 for (key, value) in &ws.env {
643 let path = Path::new(value);
644 if path.starts_with(&ws.home) {
645 assert!(path.is_dir(), "{key} -> {value} must exist as a directory");
646 }
647 }
648 let known: &[&str] = if cfg!(windows) {
649 &["AppData/Local", "AppData/Roaming", "AppData/LocalLow"]
650 } else if cfg!(target_os = "macos") {
651 &["Library/Application Support", "Library/Caches"]
652 } else {
653 &[".local/share", ".config", ".cache"]
654 };
655 for dir in known {
656 assert!(ws.home.join(dir).is_dir(), "{dir}");
657 }
658 fs::write(ws.home.join("app-launch.log"), "live log").unwrap();
659 fs::write(ws.home.join("evidence/from-app.json"), "app evidence").unwrap();
660 fs::write(ws.home.join("captures/frame.png"), "frame").unwrap();
661 fs::write(ws.data_dir.join("result.txt"), "result").unwrap();
662 let artifact = ws
663 .preserve_artifact(&ws.data_dir.join("result.txt"))
664 .unwrap();
665 assert_eq!(artifact, ws.evidence_dir.join("app/data/result.txt"));
666 assert!(create(&root, Some("../x"), "a").is_err());
667 let r = dispose(&ws, true, 2, 1);
668 assert!(
669 r.retained.is_empty()
670 && !ws.data_dir.exists()
671 && ws.root.exists()
672 && ws.evidence_dir.exists()
673 );
674 assert!(!ws.home.exists());
675 assert_eq!(fs::read_to_string(artifact).unwrap(), "result");
676 assert_eq!(
677 fs::read_to_string(ws.evidence_dir.join("app-launch.log")).unwrap(),
678 "live log"
679 );
680 assert_eq!(
681 fs::read_to_string(ws.evidence_dir.join("app/from-app.json")).unwrap(),
682 "app evidence"
683 );
684 assert_eq!(
685 fs::read_to_string(ws.capture_dir.join("frame.png")).unwrap(),
686 "frame"
687 );
688 let r = dispose(&ws, false, 2, 1);
689 assert!(r.retained.is_empty() && !ws.root.exists());
690 let _ = fs::remove_dir_all(root);
691 }
692
693 #[test]
694 fn orphan_sweep_removes_registered_temp_home_but_spares_active_runs() {
695 let root = fs::canonicalize(storage::app_temp_dir())
698 .unwrap()
699 .join(format!("rkqa-orphan-{}", new_id()));
700 let ws = create(&root, Some("orphan"), "app").unwrap();
701 assert!(prune_abandoned(&root, Duration::ZERO, &[])
702 .removed
703 .is_empty());
704 assert!(ws.home.exists());
705 ws.finish().unwrap();
706 let report = prune_abandoned(&root, Duration::ZERO, &[]);
707 assert!(report.retained.is_empty());
708 assert_eq!(report.removed, vec![ws.root]);
709 assert!(!ws.home.exists());
710 fs::remove_dir_all(root).unwrap();
711 }
712
713 #[test]
714 fn prune_spares_young_and_kept_runs() {
715 let root = storage::app_temp_dir().join(format!("rkqa-prune-{}", new_id()));
716 let a = storage::create(Some(&root), "x", Some("a"), Duration::from_secs(3600)).unwrap();
717 let _b = storage::create(Some(&root), "x", Some("b"), Duration::from_secs(3600)).unwrap();
718 storage::finish(&a).unwrap();
719 let r = prune_abandoned(&root, Duration::from_secs(3600), &["b".into()]);
720 assert!(r.removed.is_empty());
721 let r = prune_abandoned(&root, Duration::ZERO, &["b".into()]);
722 assert_eq!(r.removed.len(), 1);
723 assert!(root.join("runs/x/b").exists() && !root.join("runs/x/a").exists());
724 let _ = fs::remove_dir_all(root);
725 }
726
727 #[test]
728 fn dispose_deletes_only_a_home_the_runner_created() {
729 let root = storage::app_temp_dir().join(format!("rkqa-ws-own-{}", new_id()));
730 let mut ws = create(&root, Some("own1"), "ownapp").unwrap();
731 let foreign = root.join("precious");
733 fs::create_dir_all(&foreign).unwrap();
734 fs::write(foreign.join("keep"), b"keep").unwrap();
735 let real_home = ws.home.clone();
736 ws.home = foreign.clone();
737 let report = dispose(&ws, true, 1, 0);
738 assert!(foreign.join("keep").exists(), "{report:?}");
739 assert!(report.retained.iter().any(|(p, _)| p == &foreign));
740 ws.home = real_home.clone();
742 let report = dispose(&ws, false, 1, 0);
743 assert!(report.retained.is_empty(), "{report:?}");
744 assert!(!real_home.exists());
745 let _ = fs::remove_dir_all(root);
746 }
747
748 #[cfg(windows)]
749 #[test]
750 fn dispose_removes_a_home_that_holds_the_wininet_junction_and_a_link_out() {
751 let root = storage::app_temp_dir().join(format!("rkqa-ws-links-{}", new_id()));
753 let ws = create(&root, Some("links1"), "linkapp").unwrap();
754 let models = root.join("models-cache");
755 fs::create_dir_all(&models).unwrap();
756 fs::write(models.join("model.bin"), b"model bytes").unwrap();
757 storage::test_links::wininet_cache(&ws.home);
758 fs::create_dir_all(ws.home.join("data").join("shared")).unwrap();
759 storage::test_links::junction(&ws.home.join("data").join("shared").join("models"), &models);
760 let report = dispose(&ws, true, 1, 0);
761 assert!(report.retained.is_empty(), "{report:?}");
762 assert!(!ws.home.exists());
763 assert_eq!(fs::read(models.join("model.bin")).unwrap(), b"model bytes");
764 let _ = fs::remove_dir_all(root);
765 }
766
767 fn scanned(pid: u32, parent: u32, flags: &str, name: &str) -> ScannedProcess {
768 ScannedProcess {
769 pid,
770 parent,
771 names_home: flags.contains('C'),
772 runs_from_home: flags.contains('E'),
773 name: name.into(),
774 }
775 }
776
777 #[test]
778 fn the_process_scan_is_read_row_by_row() {
779 let scan = parse_scan(
780 " 412\t98\t-C\tmsedgewebview2.exe\r\n98\t4\t-\tCutRight.exe\n\nnot a row\n7\t1\t-CE\ttool.exe\n",
781 );
782 assert_eq!(
783 scan,
784 vec![
785 scanned(412, 98, "C", "msedgewebview2.exe"),
786 scanned(98, 4, "", "CutRight.exe"),
787 scanned(7, 1, "CE", "tool.exe"),
788 ]
789 );
790 assert!(parse_scan("").is_empty());
791 }
792
793 #[test]
794 fn only_the_runs_own_processes_may_be_ended_and_every_holder_is_named() {
795 let me = std::process::id();
796 let scan = vec![
797 scanned(98, 4, "", "CutRight.exe"),
799 scanned(412, 98, "C", "msedgewebview2.exe"),
800 scanned(413, 412, "C", "msedgewebview2.exe"),
801 scanned(7, 1, "CE", "tool.exe"),
803 scanned(900, 1, "C", "notepad.exe"),
805 scanned(5000, 1, "", "explorer.exe"),
807 scanned(me, 1, "C", "rightkit-qa.exe"),
808 ];
809 let mut launched = std::collections::BTreeSet::from([98]);
810 extend_descendants(&mut launched, &scan);
811 assert_eq!(launched, std::collections::BTreeSet::from([98, 412, 413]));
812
813 let holders = holders_in(&scan, &launched);
814 assert_eq!(
815 holders
816 .iter()
817 .map(|h| (h.pid, h.owned_by_run()))
818 .collect::<Vec<_>>(),
819 vec![(7, true), (412, true), (413, true), (900, false)]
820 );
821 assert_eq!(
822 describe_holders(&holders),
823 "tool.exe (pid 7), msedgewebview2.exe (pid 412), msedgewebview2.exe (pid 413), notepad.exe (pid 900)"
824 );
825
826 let later = vec![scanned(413, 412, "C", "msedgewebview2.exe")];
828 extend_descendants(&mut launched, &later);
829 assert!(holders_in(&later, &launched)[0].launched_by_run);
830 assert!(!holders_in(&later, &Default::default())[0].owned_by_run());
832 }
833
834 #[test]
835 fn launched_pids_are_recorded_in_the_run_root() {
836 let root = storage::app_temp_dir().join(format!("rkqa-ws-pids-{}", new_id()));
837 let ws = create(&root, Some("pids1"), "pidsapp").unwrap();
838 assert!(launched_pids(&ws).is_empty());
839 record_launched(&ws, 4242);
840 record_launched(&ws, 4343);
841 record_launched(&ws, std::process::id());
842 assert_eq!(
843 launched_pids(&ws),
844 std::collections::BTreeSet::from([4242, 4343])
845 );
846 let report = dispose(&ws, false, 2, 1);
847 assert!(report.retained.is_empty(), "{report:?}");
848 let _ = fs::remove_dir_all(root);
849 }
850
851 #[cfg(windows)]
855 #[test]
856 fn dispose_waits_for_a_helper_the_app_left_in_the_home_then_removes_it() {
857 use std::os::windows::process::CommandExt;
858 let root = storage::app_temp_dir().join(format!("rkqa-ws-held-{}", new_id()));
859 let ws = create(&root, Some("held1"), "heldapp").unwrap();
860 let mut app = std::process::Command::new("cmd.exe")
862 .args(["/d", "/c", "ping -n 120 127.0.0.1 >nul & rem"])
863 .arg(format!("--user-data-dir={}", ws.home.display()))
864 .current_dir(&ws.home)
865 .creation_flags(0x0800_0000)
866 .spawn()
867 .unwrap();
868 record_launched(&ws, app.id());
869
870 let holders = home_holders(&ws.home).expect("the process scan runs");
872 assert!(
873 holders.iter().any(|holder| holder.pid == app.id()),
874 "{holders:?}"
875 );
876 assert!(holders
877 .iter()
878 .all(|holder| holder.pid != std::process::id()));
879
880 let report = dispose_waiting(&ws, false, 20, 250, Duration::from_secs(2));
881 assert!(report.retained.is_empty(), "{report:?}");
882 assert!(!ws.home.exists());
883 let _ = app.kill();
884 let _ = app.wait();
885 let _ = fs::remove_dir_all(root);
886 }
887
888 #[cfg(windows)]
891 #[test]
892 fn dispose_leaves_a_foreign_process_that_names_the_home_and_reports_it() {
893 use std::os::windows::process::CommandExt;
894 let root = storage::app_temp_dir().join(format!("rkqa-ws-foreign-{}", new_id()));
895 let ws = create(&root, Some("foreign1"), "foreignapp").unwrap();
896 let mut foreign = std::process::Command::new("cmd.exe")
897 .args(["/d", "/c", "ping -n 120 127.0.0.1 >nul & rem"])
898 .arg(format!("--watch={}", ws.home.display()))
899 .current_dir(&ws.home)
900 .creation_flags(0x0800_0000)
901 .spawn()
902 .unwrap();
903
904 let report = dispose_waiting(&ws, false, 2, 50, Duration::from_millis(500));
905 assert!(matches!(foreign.try_wait(), Ok(None)), "it must still run");
906 let (_, why) = report
907 .retained
908 .iter()
909 .find(|(path, _)| path == &ws.home)
910 .unwrap_or_else(|| panic!("the home is held: {report:?}"));
911 assert!(
912 why.contains(&format!("held by cmd.exe (pid {})", foreign.id())),
913 "{why}"
914 );
915
916 crate::process::kill_tree(foreign.id());
917 let _ = foreign.wait();
918 crate::util::sleep_ms(300);
920 assert!(fs::remove_dir_all(&ws.home).is_ok());
921 let _ = fs::remove_dir_all(root);
922 }
923}