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 set_kind(&self, kind: storage::RunKind) -> Result<()> {
243 storage::set_run_kind(&self.root, kind)
244 }
245
246 pub fn set_outcome(&self, outcome: storage::RunOutcome) -> Result<()> {
249 storage::set_run_outcome(&self.root, outcome)
250 }
251
252 pub fn finish(&self) -> Result<()> {
254 if let Some(marker) = storage::read_marker(&self.root.join(storage::RUN_MARKER)) {
255 storage::finish(&storage::ManagedRun {
256 root: self.root.clone(),
257 marker,
258 })?;
259 }
260 Ok(())
261 }
262 pub fn scenario_dir(&self, name: &str) -> Result<PathBuf> {
264 let safe: String = name
265 .chars()
266 .map(|c| {
267 if c.is_ascii_alphanumeric() || c == '-' {
268 c
269 } else {
270 '_'
271 }
272 })
273 .collect();
274 let dir = self.data_dir.join(format!("{safe}-{}", &new_id()[..8]));
275 fs::create_dir_all(&dir)?;
276 Ok(dir)
277 }
278}
279
280#[derive(Debug, Clone, PartialEq, Eq)]
282pub struct HomeHolder {
283 pub pid: u32,
284 pub name: String,
285 pub runs_from_home: bool,
287 pub launched_by_run: bool,
289}
290
291impl HomeHolder {
292 pub fn owned_by_run(&self) -> bool {
295 self.runs_from_home || self.launched_by_run
296 }
297}
298
299#[derive(Debug, Clone, PartialEq, Eq)]
301struct ScannedProcess {
302 pid: u32,
303 parent: u32,
304 names_home: bool,
305 runs_from_home: bool,
306 name: String,
307}
308
309#[cfg_attr(not(windows), allow(dead_code))]
312fn parse_scan(output: &str) -> Vec<ScannedProcess> {
313 output
314 .lines()
315 .filter_map(|line| {
316 let mut fields = line.trim().splitn(4, '\t');
317 let pid = fields.next()?.trim().parse::<u32>().ok()?;
318 let parent = fields.next()?.trim().parse::<u32>().ok()?;
319 let flags = fields.next()?;
320 let name = fields.next()?.trim().to_string();
321 Some(ScannedProcess {
322 pid,
323 parent,
324 names_home: flags.contains('C'),
325 runs_from_home: flags.contains('E'),
326 name,
327 })
328 })
329 .collect()
330}
331
332fn extend_descendants(launched: &mut std::collections::BTreeSet<u32>, scan: &[ScannedProcess]) {
336 loop {
337 let before = launched.len();
338 for process in scan {
339 if process.pid != 0 && launched.contains(&process.parent) {
340 launched.insert(process.pid);
341 }
342 }
343 if launched.len() == before {
344 return;
345 }
346 }
347}
348
349fn holders_in(
351 scan: &[ScannedProcess],
352 launched: &std::collections::BTreeSet<u32>,
353) -> Vec<HomeHolder> {
354 let mut holders: Vec<HomeHolder> = scan
355 .iter()
356 .filter(|process| process.pid != std::process::id())
357 .filter(|process| process.names_home || process.runs_from_home)
358 .map(|process| HomeHolder {
359 pid: process.pid,
360 name: process.name.clone(),
361 runs_from_home: process.runs_from_home,
362 launched_by_run: launched.contains(&process.pid),
363 })
364 .collect();
365 holders.sort_by_key(|holder| holder.pid);
366 holders.dedup();
367 holders
368}
369
370fn describe_holders(holders: &[HomeHolder]) -> String {
371 holders
372 .iter()
373 .map(|holder| format!("{} (pid {})", holder.name, holder.pid))
374 .collect::<Vec<_>>()
375 .join(", ")
376}
377
378#[cfg(windows)]
381fn scan_processes(home: &Path) -> Option<Vec<ScannedProcess>> {
382 use std::os::windows::process::CommandExt;
383 use std::process::{Command, Stdio};
384 const SCAN: &str = "$h = $env:RIGHTKIT_QA_SCAN_HOME; \
385 Get-CimInstance Win32_Process | ForEach-Object { \
386 $f = '-'; \
387 if ($_.CommandLine -and $_.CommandLine.IndexOf($h, [StringComparison]::OrdinalIgnoreCase) -ge 0) { $f += 'C' }; \
388 if ($_.ExecutablePath -and $_.ExecutablePath.StartsWith($h + [char]92, [StringComparison]::OrdinalIgnoreCase)) { $f += 'E' }; \
389 [string]$_.ProcessId + [char]9 + [string]$_.ParentProcessId + [char]9 + $f + [char]9 + $_.Name }";
390 let system_root = std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into());
391 let powershell = Path::new(&system_root)
392 .join("System32")
393 .join("WindowsPowerShell")
394 .join("v1.0")
395 .join("powershell.exe");
396 let output = Command::new(powershell)
397 .args(["-NoProfile", "-NonInteractive", "-Command", SCAN])
398 .env("RIGHTKIT_QA_SCAN_HOME", home)
400 .stdin(Stdio::null())
401 .stderr(Stdio::null())
402 .creation_flags(0x0800_0000)
403 .output()
404 .ok()?;
405 output
406 .status
407 .success()
408 .then(|| parse_scan(&String::from_utf8_lossy(&output.stdout)))
409}
410
411#[cfg(not(windows))]
413fn scan_processes(_home: &Path) -> Option<Vec<ScannedProcess>> {
414 Some(Vec::new())
415}
416
417pub fn home_holders(home: &Path) -> Option<Vec<HomeHolder>> {
425 let scan = scan_processes(home)?;
426 Some(holders_in(&scan, &Default::default()))
427}
428
429const LAUNCHED_PIDS: &str = ".launched-pids";
431
432pub fn record_launched(ws: &QaWorkspace, pid: u32) {
436 use std::io::Write;
437 if let Ok(mut file) = fs::OpenOptions::new()
438 .create(true)
439 .append(true)
440 .open(ws.root.join(LAUNCHED_PIDS))
441 {
442 let _ = writeln!(file, "{pid}");
443 }
444}
445
446fn launched_pids(ws: &QaWorkspace) -> std::collections::BTreeSet<u32> {
447 fs::read_to_string(ws.root.join(LAUNCHED_PIDS))
448 .unwrap_or_default()
449 .lines()
450 .filter_map(|line| line.trim().parse().ok())
451 .filter(|pid| *pid != 0 && *pid != std::process::id())
452 .collect()
453}
454
455fn settle_home_holders(ws: &QaWorkspace, wait: Duration) -> Vec<HomeHolder> {
459 let deadline = std::time::Instant::now() + wait;
460 let mut launched = launched_pids(ws);
461 let mut holders;
462 loop {
463 let Some(scan) = scan_processes(&ws.home) else {
464 return Vec::new();
465 };
466 extend_descendants(&mut launched, &scan);
467 holders = holders_in(&scan, &launched);
468 if holders.is_empty() || std::time::Instant::now() >= deadline {
469 break;
470 }
471 crate::util::sleep_ms(250);
472 }
473 for holder in holders.iter().filter(|holder| holder.owned_by_run()) {
474 crate::process::kill_tree(holder.pid);
475 }
476 holders
477}
478
479const HOLDER_WAIT: Duration = Duration::from_secs(20);
481
482pub fn dispose(
491 ws: &QaWorkspace,
492 keep_evidence: bool,
493 attempts: u32,
494 retry_ms: u64,
495) -> DisposeReport {
496 dispose_waiting(ws, keep_evidence, attempts, retry_ms, HOLDER_WAIT)
497}
498
499fn dispose_waiting(
500 ws: &QaWorkspace,
501 keep_evidence: bool,
502 attempts: u32,
503 retry_ms: u64,
504 holder_wait: Duration,
505) -> DisposeReport {
506 let mut report = DisposeReport::default();
507 if let Err(e) = ws.finish() {
508 report.retained.push((ws.root.clone(), e.to_string()));
509 return report;
510 }
511 let mut targets = vec![ws.home.clone()];
512 if !keep_evidence {
513 targets.push(ws.root.clone());
514 }
515 for target in targets {
516 if !target.exists() {
517 report.removed.push(target);
518 continue;
519 }
520 if target == ws.home && !storage::is_owned_home(&target) {
522 report.retained.push((
523 target,
524 "not a QA home created by rightkit-qa; not deleted".into(),
525 ));
526 continue;
527 }
528 if target == ws.home {
529 settle_home_holders(ws, holder_wait);
530 }
531 let mut last = String::new();
532 for attempt in 1..=attempts.max(1) {
533 let removal = if target == ws.home {
538 match target.parent() {
539 Some(qa_root) => storage::remove_home_tree(qa_root, &target),
540 None => Err("home has no parent directory".to_string()),
541 }
542 } else {
543 fs::remove_dir_all(&target).map_err(|e| e.to_string())
544 };
545 match removal {
546 Ok(()) => {
547 last.clear();
548 break;
549 }
550 Err(e) => {
551 last = e;
552 if attempt < attempts {
553 crate::util::sleep_ms(retry_ms);
554 }
555 }
556 }
557 }
558 if last.is_empty() && !target.exists() {
559 report.removed.push(target);
560 } else {
561 let mut why = if last.is_empty() {
562 "still present".to_string()
563 } else {
564 last
565 };
566 if target == ws.home {
567 match home_holders(&target) {
569 Some(holders) if !holders.is_empty() => {
570 why.push_str(&format!("; held by {}", describe_holders(&holders)));
571 }
572 Some(_) if cfg!(windows) => {
573 why.push_str("; no running process names this home");
574 }
575 _ => {}
576 }
577 }
578 report.retained.push((target.clone(), why));
579 if target == ws.home {
580 if !keep_evidence {
581 report
582 .retained
583 .push((ws.root.clone(), "app temp cleanup pending".into()));
584 }
585 break;
586 }
587 }
588 }
589 report
590}
591
592pub fn prune_abandoned(cache_root: &Path, older_than: Duration, keep: &[String]) -> DisposeReport {
595 let mut report = DisposeReport::default();
596 let runs_root = cache_root.join("runs");
597 let Ok(entries) = fs::read_dir(&runs_root) else {
598 return report;
599 };
600 let entries: Vec<_> = entries.flatten().collect();
601 for entry in &entries {
602 let Ok(ft) = entry.file_type() else { continue };
603 if ft.is_dir() && !ft.is_symlink() {
604 let app = entry.file_name().to_string_lossy().to_string();
605 if let Ok(r) = storage::prune_with_age_override(cache_root, &app, keep, older_than) {
606 report.removed.extend(r.removed);
607 report.retained.extend(r.retained);
608 }
609 }
610 }
611 report
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617
618 #[test]
619 fn isolates_and_disposes() {
620 let root = storage::app_temp_dir().join(format!("rkqa-ws-{}", new_id()));
621 let ws = create(&root, Some("run1"), "genright").unwrap();
622 assert!(ws.env.contains_key("GENRIGHT_DATA_DIR"));
623 assert!(ws.data_dir.exists());
624 assert!(!ws.root.join("data").exists());
625 let temp = fs::canonicalize(storage::app_temp_dir()).unwrap();
628 assert!(ws.home.starts_with(temp.join("rightkit-qa")));
629 assert!(!ws.home.starts_with(&ws.root));
630 assert!(ws.evidence_dir.starts_with(&ws.root));
631 assert!(ws.capture_dir.starts_with(&ws.root));
632 for key in [
633 "RIGHTKIT_RUN_ROOT",
634 "RIGHTKIT_SUITE_ROOT",
635 "HOME",
636 "USERPROFILE",
637 "APPDATA",
638 "LOCALAPPDATA",
639 "XDG_CACHE_HOME",
640 "XDG_DATA_HOME",
641 "XDG_CONFIG_HOME",
642 "WEBVIEW2_USER_DATA_FOLDER",
643 "GENRIGHT_DATA_DIR",
644 "RIGHTKIT_QA_DATA_DIR",
645 "RIGHTKIT_QA_EVIDENCE_DIR",
646 "RIGHTKIT_QA_CAPTURE_DIR",
647 "TMPDIR",
648 "TMP",
649 "TEMP",
650 ] {
651 assert!(Path::new(&ws.env[key]).starts_with(&ws.home), "{key}");
652 }
653 for (key, value) in &ws.env {
654 let path = Path::new(value);
655 if path.starts_with(&ws.home) {
656 assert!(path.is_dir(), "{key} -> {value} must exist as a directory");
657 }
658 }
659 let known: &[&str] = if cfg!(windows) {
660 &["AppData/Local", "AppData/Roaming", "AppData/LocalLow"]
661 } else if cfg!(target_os = "macos") {
662 &["Library/Application Support", "Library/Caches"]
663 } else {
664 &[".local/share", ".config", ".cache"]
665 };
666 for dir in known {
667 assert!(ws.home.join(dir).is_dir(), "{dir}");
668 }
669 fs::write(ws.home.join("app-launch.log"), "live log").unwrap();
670 fs::write(ws.home.join("evidence/from-app.json"), "app evidence").unwrap();
671 fs::write(ws.home.join("captures/frame.png"), "frame").unwrap();
672 fs::write(ws.data_dir.join("result.txt"), "result").unwrap();
673 let artifact = ws
674 .preserve_artifact(&ws.data_dir.join("result.txt"))
675 .unwrap();
676 assert_eq!(artifact, ws.evidence_dir.join("app/data/result.txt"));
677 assert!(create(&root, Some("../x"), "a").is_err());
678 let r = dispose(&ws, true, 2, 1);
679 assert!(
680 r.retained.is_empty()
681 && !ws.data_dir.exists()
682 && ws.root.exists()
683 && ws.evidence_dir.exists()
684 );
685 assert!(!ws.home.exists());
686 assert_eq!(fs::read_to_string(artifact).unwrap(), "result");
687 assert_eq!(
688 fs::read_to_string(ws.evidence_dir.join("app-launch.log")).unwrap(),
689 "live log"
690 );
691 assert_eq!(
692 fs::read_to_string(ws.evidence_dir.join("app/from-app.json")).unwrap(),
693 "app evidence"
694 );
695 assert_eq!(
696 fs::read_to_string(ws.capture_dir.join("frame.png")).unwrap(),
697 "frame"
698 );
699 let r = dispose(&ws, false, 2, 1);
700 assert!(r.retained.is_empty() && !ws.root.exists());
701 let _ = fs::remove_dir_all(root);
702 }
703
704 #[test]
705 fn orphan_sweep_removes_registered_temp_home_but_spares_active_runs() {
706 let root = fs::canonicalize(storage::app_temp_dir())
709 .unwrap()
710 .join(format!("rkqa-orphan-{}", new_id()));
711 let ws = create(&root, Some("orphan"), "app").unwrap();
712 assert!(prune_abandoned(&root, Duration::ZERO, &[])
713 .removed
714 .is_empty());
715 assert!(ws.home.exists());
716 ws.finish().unwrap();
717 let report = prune_abandoned(&root, Duration::ZERO, &[]);
718 assert!(report.retained.is_empty());
719 assert_eq!(report.removed, vec![ws.root]);
720 assert!(!ws.home.exists());
721 fs::remove_dir_all(root).unwrap();
722 }
723
724 #[test]
725 fn prune_spares_young_and_kept_runs() {
726 let root = storage::app_temp_dir().join(format!("rkqa-prune-{}", new_id()));
727 let a = storage::create(Some(&root), "x", Some("a"), Duration::from_secs(3600)).unwrap();
728 let _b = storage::create(Some(&root), "x", Some("b"), Duration::from_secs(3600)).unwrap();
729 storage::finish(&a).unwrap();
730 let r = prune_abandoned(&root, Duration::from_secs(3600), &["b".into()]);
731 assert!(r.removed.is_empty());
732 let r = prune_abandoned(&root, Duration::ZERO, &["b".into()]);
733 assert_eq!(r.removed.len(), 1);
734 assert!(root.join("runs/x/b").exists() && !root.join("runs/x/a").exists());
735 let _ = fs::remove_dir_all(root);
736 }
737
738 #[test]
739 fn dispose_deletes_only_a_home_the_runner_created() {
740 let root = storage::app_temp_dir().join(format!("rkqa-ws-own-{}", new_id()));
741 let mut ws = create(&root, Some("own1"), "ownapp").unwrap();
742 let foreign = root.join("precious");
744 fs::create_dir_all(&foreign).unwrap();
745 fs::write(foreign.join("keep"), b"keep").unwrap();
746 let real_home = ws.home.clone();
747 ws.home = foreign.clone();
748 let report = dispose(&ws, true, 1, 0);
749 assert!(foreign.join("keep").exists(), "{report:?}");
750 assert!(report.retained.iter().any(|(p, _)| p == &foreign));
751 ws.home = real_home.clone();
753 let report = dispose(&ws, false, 1, 0);
754 assert!(report.retained.is_empty(), "{report:?}");
755 assert!(!real_home.exists());
756 let _ = fs::remove_dir_all(root);
757 }
758
759 #[cfg(windows)]
760 #[test]
761 fn dispose_removes_a_home_that_holds_the_wininet_junction_and_a_link_out() {
762 let root = storage::app_temp_dir().join(format!("rkqa-ws-links-{}", new_id()));
764 let ws = create(&root, Some("links1"), "linkapp").unwrap();
765 let models = root.join("models-cache");
766 fs::create_dir_all(&models).unwrap();
767 fs::write(models.join("model.bin"), b"model bytes").unwrap();
768 storage::test_links::wininet_cache(&ws.home);
769 fs::create_dir_all(ws.home.join("data").join("shared")).unwrap();
770 storage::test_links::junction(&ws.home.join("data").join("shared").join("models"), &models);
771 let report = dispose(&ws, true, 1, 0);
772 assert!(report.retained.is_empty(), "{report:?}");
773 assert!(!ws.home.exists());
774 assert_eq!(fs::read(models.join("model.bin")).unwrap(), b"model bytes");
775 let _ = fs::remove_dir_all(root);
776 }
777
778 fn scanned(pid: u32, parent: u32, flags: &str, name: &str) -> ScannedProcess {
779 ScannedProcess {
780 pid,
781 parent,
782 names_home: flags.contains('C'),
783 runs_from_home: flags.contains('E'),
784 name: name.into(),
785 }
786 }
787
788 #[test]
789 fn the_process_scan_is_read_row_by_row() {
790 let scan = parse_scan(
791 " 412\t98\t-C\tmsedgewebview2.exe\r\n98\t4\t-\tCutRight.exe\n\nnot a row\n7\t1\t-CE\ttool.exe\n",
792 );
793 assert_eq!(
794 scan,
795 vec![
796 scanned(412, 98, "C", "msedgewebview2.exe"),
797 scanned(98, 4, "", "CutRight.exe"),
798 scanned(7, 1, "CE", "tool.exe"),
799 ]
800 );
801 assert!(parse_scan("").is_empty());
802 }
803
804 #[test]
805 fn only_the_runs_own_processes_may_be_ended_and_every_holder_is_named() {
806 let me = std::process::id();
807 let scan = vec![
808 scanned(98, 4, "", "CutRight.exe"),
810 scanned(412, 98, "C", "msedgewebview2.exe"),
811 scanned(413, 412, "C", "msedgewebview2.exe"),
812 scanned(7, 1, "CE", "tool.exe"),
814 scanned(900, 1, "C", "notepad.exe"),
816 scanned(5000, 1, "", "explorer.exe"),
818 scanned(me, 1, "C", "rightkit-qa.exe"),
819 ];
820 let mut launched = std::collections::BTreeSet::from([98]);
821 extend_descendants(&mut launched, &scan);
822 assert_eq!(launched, std::collections::BTreeSet::from([98, 412, 413]));
823
824 let holders = holders_in(&scan, &launched);
825 assert_eq!(
826 holders
827 .iter()
828 .map(|h| (h.pid, h.owned_by_run()))
829 .collect::<Vec<_>>(),
830 vec![(7, true), (412, true), (413, true), (900, false)]
831 );
832 assert_eq!(
833 describe_holders(&holders),
834 "tool.exe (pid 7), msedgewebview2.exe (pid 412), msedgewebview2.exe (pid 413), notepad.exe (pid 900)"
835 );
836
837 let later = vec![scanned(413, 412, "C", "msedgewebview2.exe")];
839 extend_descendants(&mut launched, &later);
840 assert!(holders_in(&later, &launched)[0].launched_by_run);
841 assert!(!holders_in(&later, &Default::default())[0].owned_by_run());
843 }
844
845 #[test]
846 fn launched_pids_are_recorded_in_the_run_root() {
847 let root = storage::app_temp_dir().join(format!("rkqa-ws-pids-{}", new_id()));
848 let ws = create(&root, Some("pids1"), "pidsapp").unwrap();
849 assert!(launched_pids(&ws).is_empty());
850 record_launched(&ws, 4242);
851 record_launched(&ws, 4343);
852 record_launched(&ws, std::process::id());
853 assert_eq!(
854 launched_pids(&ws),
855 std::collections::BTreeSet::from([4242, 4343])
856 );
857 let report = dispose(&ws, false, 2, 1);
858 assert!(report.retained.is_empty(), "{report:?}");
859 let _ = fs::remove_dir_all(root);
860 }
861
862 #[cfg(windows)]
866 #[test]
867 fn dispose_waits_for_a_helper_the_app_left_in_the_home_then_removes_it() {
868 use std::os::windows::process::CommandExt;
869 let root = storage::app_temp_dir().join(format!("rkqa-ws-held-{}", new_id()));
870 let ws = create(&root, Some("held1"), "heldapp").unwrap();
871 let mut app = std::process::Command::new("cmd.exe")
873 .args(["/d", "/c", "ping -n 120 127.0.0.1 >nul & rem"])
874 .arg(format!("--user-data-dir={}", ws.home.display()))
875 .current_dir(&ws.home)
876 .creation_flags(0x0800_0000)
877 .spawn()
878 .unwrap();
879 record_launched(&ws, app.id());
880
881 let holders = home_holders(&ws.home).expect("the process scan runs");
883 assert!(
884 holders.iter().any(|holder| holder.pid == app.id()),
885 "{holders:?}"
886 );
887 assert!(holders
888 .iter()
889 .all(|holder| holder.pid != std::process::id()));
890
891 let report = dispose_waiting(&ws, false, 20, 250, Duration::from_secs(2));
892 assert!(report.retained.is_empty(), "{report:?}");
893 assert!(!ws.home.exists());
894 let _ = app.kill();
895 let _ = app.wait();
896 let _ = fs::remove_dir_all(root);
897 }
898
899 #[cfg(windows)]
902 #[test]
903 fn dispose_leaves_a_foreign_process_that_names_the_home_and_reports_it() {
904 use std::os::windows::process::CommandExt;
905 let root = storage::app_temp_dir().join(format!("rkqa-ws-foreign-{}", new_id()));
906 let ws = create(&root, Some("foreign1"), "foreignapp").unwrap();
907 let mut foreign = std::process::Command::new("cmd.exe")
908 .args(["/d", "/c", "ping -n 120 127.0.0.1 >nul & rem"])
909 .arg(format!("--watch={}", ws.home.display()))
910 .current_dir(&ws.home)
911 .creation_flags(0x0800_0000)
912 .spawn()
913 .unwrap();
914
915 let report = dispose_waiting(&ws, false, 2, 50, Duration::from_millis(500));
916 assert!(matches!(foreign.try_wait(), Ok(None)), "it must still run");
917 let (_, why) = report
918 .retained
919 .iter()
920 .find(|(path, _)| path == &ws.home)
921 .unwrap_or_else(|| panic!("the home is held: {report:?}"));
922 assert!(
923 why.contains(&format!("held by cmd.exe (pid {})", foreign.id())),
924 "{why}"
925 );
926
927 crate::process::kill_tree(foreign.id());
928 let _ = foreign.wait();
929 crate::util::sleep_ms(300);
931 assert!(fs::remove_dir_all(&ws.home).is_ok());
932 let _ = fs::remove_dir_all(root);
933 }
934}