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