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_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 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
177fn 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 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 pub fn set_kind(&self, kind: storage::RunKind) -> Result<()> {
248 storage::set_run_kind(&self.root, kind)
249 }
250
251 pub fn set_outcome(&self, outcome: storage::RunOutcome) -> Result<()> {
254 storage::set_run_outcome(&self.root, outcome)
255 }
256
257 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 pub fn scenario_dir(&self, name: &str) -> Result<PathBuf> {
269 let safe: String = name
270 .chars()
271 .map(|c| {
272 if c.is_ascii_alphanumeric() || c == '-' {
273 c
274 } else {
275 '_'
276 }
277 })
278 .collect();
279 let dir = self.data_dir.join(format!("{safe}-{}", &new_id()[..8]));
280 fs::create_dir_all(&dir)?;
281 Ok(dir)
282 }
283}
284
285#[derive(Debug, Clone, PartialEq, Eq)]
287pub struct HomeHolder {
288 pub pid: u32,
289 pub name: String,
290 pub runs_from_home: bool,
292 pub launched_by_run: bool,
294}
295
296impl HomeHolder {
297 pub fn owned_by_run(&self) -> bool {
300 self.runs_from_home || self.launched_by_run
301 }
302}
303
304#[derive(Debug, Clone, PartialEq, Eq)]
306struct ScannedProcess {
307 pid: u32,
308 parent: u32,
309 names_home: bool,
310 runs_from_home: bool,
311 name: String,
312}
313
314#[cfg_attr(not(windows), allow(dead_code))]
317fn parse_scan(output: &str) -> Vec<ScannedProcess> {
318 output
319 .lines()
320 .filter_map(|line| {
321 let mut fields = line.trim().splitn(4, '\t');
322 let pid = fields.next()?.trim().parse::<u32>().ok()?;
323 let parent = fields.next()?.trim().parse::<u32>().ok()?;
324 let flags = fields.next()?;
325 let name = fields.next()?.trim().to_string();
326 Some(ScannedProcess {
327 pid,
328 parent,
329 names_home: flags.contains('C'),
330 runs_from_home: flags.contains('E'),
331 name,
332 })
333 })
334 .collect()
335}
336
337fn extend_descendants(launched: &mut std::collections::BTreeSet<u32>, scan: &[ScannedProcess]) {
341 loop {
342 let before = launched.len();
343 for process in scan {
344 if process.pid != 0 && launched.contains(&process.parent) {
345 launched.insert(process.pid);
346 }
347 }
348 if launched.len() == before {
349 return;
350 }
351 }
352}
353
354fn holders_in(
356 scan: &[ScannedProcess],
357 launched: &std::collections::BTreeSet<u32>,
358) -> Vec<HomeHolder> {
359 let mut holders: Vec<HomeHolder> = scan
360 .iter()
361 .filter(|process| process.pid != std::process::id())
362 .filter(|process| process.names_home || process.runs_from_home)
363 .map(|process| HomeHolder {
364 pid: process.pid,
365 name: process.name.clone(),
366 runs_from_home: process.runs_from_home,
367 launched_by_run: launched.contains(&process.pid),
368 })
369 .collect();
370 holders.sort_by_key(|holder| holder.pid);
371 holders.dedup();
372 holders
373}
374
375fn describe_holders(holders: &[HomeHolder]) -> String {
376 holders
377 .iter()
378 .map(|holder| format!("{} (pid {})", holder.name, holder.pid))
379 .collect::<Vec<_>>()
380 .join(", ")
381}
382
383#[cfg(windows)]
386fn scan_processes(home: &Path) -> Option<Vec<ScannedProcess>> {
387 use std::os::windows::process::CommandExt;
388 use std::process::Command;
389 const SCAN: &str = "$h = $env:RIGHTKIT_QA_SCAN_HOME; \
390 Get-CimInstance Win32_Process | ForEach-Object { \
391 $f = '-'; \
392 if ($_.CommandLine -and $_.CommandLine.IndexOf($h, [StringComparison]::OrdinalIgnoreCase) -ge 0) { $f += 'C' }; \
393 if ($_.ExecutablePath -and $_.ExecutablePath.StartsWith($h + [char]92, [StringComparison]::OrdinalIgnoreCase)) { $f += 'E' }; \
394 [string]$_.ProcessId + [char]9 + [string]$_.ParentProcessId + [char]9 + $f + [char]9 + $_.Name }";
395 let system_root = std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into());
396 let powershell = Path::new(&system_root)
397 .join("System32")
398 .join("WindowsPowerShell")
399 .join("v1.0")
400 .join("powershell.exe");
401 let output = crate::process::output_bounded(
403 Command::new(powershell)
404 .args(["-NoProfile", "-NonInteractive", "-Command", SCAN])
405 .env("RIGHTKIT_QA_SCAN_HOME", home)
407 .creation_flags(0x0800_0000),
408 Duration::from_secs(60),
409 )
410 .ok()??;
411 output
412 .status
413 .success()
414 .then(|| parse_scan(&String::from_utf8_lossy(&output.stdout)))
415}
416
417#[cfg(not(windows))]
419fn scan_processes(_home: &Path) -> Option<Vec<ScannedProcess>> {
420 Some(Vec::new())
421}
422
423pub fn home_holders(home: &Path) -> Option<Vec<HomeHolder>> {
431 let scan = scan_processes(home)?;
432 Some(holders_in(&scan, &Default::default()))
433}
434
435const LAUNCHED_PIDS: &str = ".launched-pids";
437
438pub fn record_launched(ws: &QaWorkspace, pid: u32) {
442 use std::io::Write;
443 if let Ok(mut file) = fs::OpenOptions::new()
444 .create(true)
445 .append(true)
446 .open(ws.root.join(LAUNCHED_PIDS))
447 {
448 let _ = writeln!(file, "{pid}");
449 }
450}
451
452fn launched_pids(ws: &QaWorkspace) -> std::collections::BTreeSet<u32> {
453 fs::read_to_string(ws.root.join(LAUNCHED_PIDS))
454 .unwrap_or_default()
455 .lines()
456 .filter_map(|line| line.trim().parse().ok())
457 .filter(|pid| *pid != 0 && *pid != std::process::id())
458 .collect()
459}
460
461fn settle_home_holders(ws: &QaWorkspace, wait: Duration) -> Vec<HomeHolder> {
465 let deadline = std::time::Instant::now() + wait;
466 let mut launched = launched_pids(ws);
467 let mut holders;
468 loop {
469 let Some(scan) = scan_processes(&ws.home) else {
470 return Vec::new();
471 };
472 extend_descendants(&mut launched, &scan);
473 holders = holders_in(&scan, &launched);
474 if holders.is_empty() || std::time::Instant::now() >= deadline {
475 break;
476 }
477 crate::util::sleep_ms(250);
478 }
479 for holder in holders.iter().filter(|holder| holder.owned_by_run()) {
480 crate::process::kill_tree(holder.pid);
481 }
482 holders
483}
484
485const HOLDER_WAIT: Duration = Duration::from_secs(20);
487
488pub fn dispose(
497 ws: &QaWorkspace,
498 keep_evidence: bool,
499 attempts: u32,
500 retry_ms: u64,
501) -> DisposeReport {
502 dispose_waiting(ws, keep_evidence, attempts, retry_ms, HOLDER_WAIT)
503}
504
505fn dispose_waiting(
506 ws: &QaWorkspace,
507 keep_evidence: bool,
508 attempts: u32,
509 retry_ms: u64,
510 holder_wait: Duration,
511) -> DisposeReport {
512 let mut report = DisposeReport::default();
513 if let Err(e) = ws.finish() {
514 report.retained.push((ws.root.clone(), e.to_string()));
515 return report;
516 }
517 let mut targets = vec![ws.home.clone()];
518 if !keep_evidence {
519 targets.push(ws.root.clone());
520 }
521 for target in targets {
522 if !target.exists() {
523 report.removed.push(target);
524 continue;
525 }
526 if target == ws.home && !storage::is_owned_home(&target) {
528 report.retained.push((
529 target,
530 "not a QA home created by rightkit-qa; not deleted".into(),
531 ));
532 continue;
533 }
534 if target == ws.home {
535 settle_home_holders(ws, holder_wait);
536 }
537 let mut last = String::new();
538 for attempt in 1..=attempts.max(1) {
539 let removal = if target == ws.home {
544 match target.parent() {
545 Some(qa_root) => storage::remove_home_tree(qa_root, &target),
546 None => Err("home has no parent directory".to_string()),
547 }
548 } else {
549 fs::remove_dir_all(&target).map_err(|e| e.to_string())
550 };
551 match removal {
552 Ok(()) => {
553 last.clear();
554 break;
555 }
556 Err(e) => {
557 last = e;
558 if attempt < attempts {
559 crate::util::sleep_ms(retry_ms);
560 }
561 }
562 }
563 }
564 if last.is_empty() && !target.exists() {
565 report.removed.push(target);
566 } else {
567 let mut why = if last.is_empty() {
568 "still present".to_string()
569 } else {
570 last
571 };
572 if target == ws.home {
573 match home_holders(&target) {
575 Some(holders) if !holders.is_empty() => {
576 why.push_str(&format!("; held by {}", describe_holders(&holders)));
577 }
578 Some(_) if cfg!(windows) => {
579 why.push_str("; no running process names this home");
580 }
581 _ => {}
582 }
583 }
584 report.retained.push((target.clone(), why));
585 if target == ws.home {
586 if !keep_evidence {
587 report
588 .retained
589 .push((ws.root.clone(), "app temp cleanup pending".into()));
590 }
591 break;
592 }
593 }
594 }
595 report
596}
597
598pub fn prune_abandoned(cache_root: &Path, older_than: Duration, keep: &[String]) -> DisposeReport {
601 let mut report = DisposeReport::default();
602 let runs_root = cache_root.join("runs");
603 let Ok(entries) = fs::read_dir(&runs_root) else {
604 return report;
605 };
606 let entries: Vec<_> = entries.flatten().collect();
607 for entry in &entries {
608 let Ok(ft) = entry.file_type() else { continue };
609 if ft.is_dir() && !ft.is_symlink() {
610 let app = entry.file_name().to_string_lossy().to_string();
611 if let Ok(r) = storage::prune_with_age_override(cache_root, &app, keep, older_than) {
612 report.removed.extend(r.removed);
613 report.retained.extend(r.retained);
614 }
615 }
616 }
617 report
618}
619
620#[cfg(test)]
621mod tests {
622 use super::*;
623
624 #[test]
625 fn launcher_home_passes_portable_and_app_dirs_decisions() {
626 use rightkit_fs::app_dirs::{AppDirs, AppId, Options, Os};
627 use rightkit_fs::test_mode::{decide, Decision, Refusal, Request, BINDING_FILE};
628 let cache = storage::app_temp_dir().join(format!("rkqa-decision-{}", new_id()));
629 let ws = create(&cache, Some("decision"), "genright").unwrap();
630 let protected = vec![cache.join("real-user-data")];
631 let root = Path::new(&ws.env["RIGHTKIT_QA_DATA_DIR"]);
632 let request = Request {
633 app_id: "genright",
634 activated: ws.env["RIGHTKIT_UNDER_TEST"] == "1",
635 root: Some(root),
636 protected_paths: &protected,
637 };
638 let Decision::Test(validated) = decide(&request).unwrap() else {
639 panic!("launcher must activate test mode")
640 };
641 assert_eq!(validated.path(), fs::canonicalize(&ws.home).unwrap());
642 assert_eq!(
643 validated.child(Path::new("data")).unwrap(),
644 fs::canonicalize(&ws.data_dir).unwrap()
645 );
646 let app = AppId::new("GenRight").with_qa_app_id("genright");
647 let opts = Options::new(Os::current())
648 .qa_overrides(true)
649 .protected_paths(protected.clone());
650 let dirs = AppDirs::resolve_with(&app, &opts, &|key| {
651 ws.env.get(key).map(std::ffi::OsString::from)
652 })
653 .unwrap();
654 assert_eq!(dirs.home, validated.path());
655 assert_eq!(dirs.data, validated.child(Path::new("data")).unwrap());
656 let native = AppDirs::resolve_with(
658 &app,
659 &Options::new(Os::current()).qa_overrides(true),
660 &|key| ws.env.get(key).map(std::ffi::OsString::from),
661 )
662 .unwrap();
663 assert_eq!(native.home, validated.path());
664 assert_eq!(
665 decide(&Request {
666 activated: false,
667 ..request.clone()
668 }),
669 Err(Refusal::RootWithoutActivation)
670 );
671 assert_eq!(
672 decide(&Request {
673 root: None,
674 ..request.clone()
675 }),
676 Err(Refusal::ActivationWithoutRoot)
677 );
678 let binding_path = ws.home.join(BINDING_FILE);
679 let original = fs::read(&binding_path).unwrap();
680 for invalid in [
681 r#"{"nested":{"app":"genright"}}"#,
682 r#"{"app":"genright""#,
683 r#"{"app":"genright"}junk"#,
684 ] {
685 fs::write(&binding_path, invalid).unwrap();
686 assert_eq!(decide(&request), Err(Refusal::RootNotCreatedByRunner));
687 }
688 fs::write(&binding_path, original).unwrap();
689 let cloud = ws.home.join("OneDrive").join("bound");
690 rightkit_fs::private::ensure_private_dir(&cloud).unwrap();
691 fs::write(cloud.join(BINDING_FILE), r#"{"app":"genright"}"#).unwrap();
692 assert_eq!(
693 decide(&Request {
694 root: Some(&cloud),
695 ..request.clone()
696 }),
697 Err(Refusal::RootOnRemovableOrCloud)
698 );
699 #[cfg(unix)]
700 {
701 use std::os::unix::fs::PermissionsExt;
702 fs::set_permissions(&ws.home, fs::Permissions::from_mode(0o755)).unwrap();
703 assert_eq!(decide(&request), Err(Refusal::RootNotPrivate));
704 rightkit_fs::private::ensure_private_dir(&ws.home).unwrap();
705 let link = cache.join("home-alias");
706 std::os::unix::fs::symlink(&ws.home, &link).unwrap();
707 assert_eq!(
708 decide(&Request {
709 root: Some(&link),
710 ..request.clone()
711 }),
712 Err(Refusal::RootHasSymlink)
713 );
714 let aliases = vec![link.join("protected")];
715 assert_eq!(
716 decide(&Request {
717 protected_paths: &aliases,
718 ..request
719 }),
720 Err(Refusal::RootOverlapsUserData)
721 );
722 }
723 }
724
725 #[test]
726 fn isolates_and_disposes() {
727 let root = storage::app_temp_dir().join(format!("rkqa-ws-{}", new_id()));
728 let ws = create(&root, Some("run1"), "genright").unwrap();
729 assert!(ws.env.contains_key("GENRIGHT_DATA_DIR"));
730 assert_eq!(ws.env["RIGHTKIT_UNDER_TEST"], "1");
732 assert_eq!(
733 Path::new(&ws.env["RIGHTKIT_QA_DATA_DIR"]),
734 ws.home.as_path()
735 );
736 assert_ne!(
737 Path::new(&ws.env["RIGHTKIT_QA_DATA_DIR"]),
738 ws.data_dir.as_path()
739 );
740 assert!(
741 Path::new(&ws.env["RIGHTKIT_QA_DATA_DIR"])
742 .join(".rightkit-app.json")
743 .is_file(),
744 "the bound root carries the runner binding"
745 );
746 assert!(ws.data_dir.exists());
747 assert!(!ws.root.join("data").exists());
748 let temp = crate::util::canonical_plain(&storage::app_temp_dir()).unwrap();
751 assert!(ws.home.starts_with(temp.join("rightkit-qa")));
752 assert!(!ws.home.starts_with(&ws.root));
753 assert!(ws.evidence_dir.starts_with(&ws.root));
754 assert!(ws.capture_dir.starts_with(&ws.root));
755 for key in [
756 "RIGHTKIT_RUN_ROOT",
757 "RIGHTKIT_SUITE_ROOT",
758 "HOME",
759 "USERPROFILE",
760 "APPDATA",
761 "LOCALAPPDATA",
762 "XDG_CACHE_HOME",
763 "XDG_DATA_HOME",
764 "XDG_CONFIG_HOME",
765 "WEBVIEW2_USER_DATA_FOLDER",
766 "GENRIGHT_DATA_DIR",
767 "RIGHTKIT_QA_DATA_DIR",
768 "RIGHTKIT_QA_EVIDENCE_DIR",
769 "RIGHTKIT_QA_CAPTURE_DIR",
770 "TMPDIR",
771 "TMP",
772 "TEMP",
773 ] {
774 assert!(Path::new(&ws.env[key]).starts_with(&ws.home), "{key}");
775 }
776 for (key, value) in &ws.env {
777 let path = Path::new(value);
778 if path.starts_with(&ws.home) {
779 assert!(path.is_dir(), "{key} -> {value} must exist as a directory");
780 }
781 }
782 let known: &[&str] = if cfg!(windows) {
783 &["AppData/Local", "AppData/Roaming", "AppData/LocalLow"]
784 } else if cfg!(target_os = "macos") {
785 &["Library/Application Support", "Library/Caches"]
786 } else {
787 &[".local/share", ".config", ".cache"]
788 };
789 for dir in known {
790 assert!(ws.home.join(dir).is_dir(), "{dir}");
791 }
792 fs::write(ws.home.join("app-launch.log"), "live log").unwrap();
793 fs::write(ws.home.join("evidence/from-app.json"), "app evidence").unwrap();
794 fs::write(ws.home.join("captures/frame.png"), "frame").unwrap();
795 fs::write(ws.data_dir.join("result.txt"), "result").unwrap();
796 let artifact = ws
797 .preserve_artifact(&ws.data_dir.join("result.txt"))
798 .unwrap();
799 assert_eq!(artifact, ws.evidence_dir.join("app/data/result.txt"));
800 assert!(create(&root, Some("../x"), "a").is_err());
801 let r = dispose(&ws, true, 2, 1);
802 assert!(
803 r.retained.is_empty()
804 && !ws.data_dir.exists()
805 && ws.root.exists()
806 && ws.evidence_dir.exists()
807 );
808 assert!(!ws.home.exists());
809 assert_eq!(fs::read_to_string(artifact).unwrap(), "result");
810 assert_eq!(
811 fs::read_to_string(ws.evidence_dir.join("app-launch.log")).unwrap(),
812 "live log"
813 );
814 assert_eq!(
815 fs::read_to_string(ws.evidence_dir.join("app/from-app.json")).unwrap(),
816 "app evidence"
817 );
818 assert_eq!(
819 fs::read_to_string(ws.capture_dir.join("frame.png")).unwrap(),
820 "frame"
821 );
822 let r = dispose(&ws, false, 2, 1);
823 assert!(r.retained.is_empty() && !ws.root.exists());
824 let _ = fs::remove_dir_all(root);
825 }
826
827 #[test]
828 fn orphan_sweep_removes_registered_temp_home_but_spares_active_runs() {
829 let root = fs::canonicalize(storage::app_temp_dir())
832 .unwrap()
833 .join(format!("rkqa-orphan-{}", new_id()));
834 let ws = create(&root, Some("orphan"), "app").unwrap();
835 assert!(prune_abandoned(&root, Duration::ZERO, &[])
836 .removed
837 .is_empty());
838 assert!(ws.home.exists());
839 ws.finish().unwrap();
840 let report = prune_abandoned(&root, Duration::ZERO, &[]);
841 assert!(report.retained.is_empty());
842 let removed: Vec<PathBuf> = report
843 .removed
844 .iter()
845 .map(|p| crate::util::plain_path(p))
846 .collect();
847 assert_eq!(removed, vec![ws.root.clone()]);
848 assert!(!ws.home.exists());
849 fs::remove_dir_all(root).unwrap();
850 }
851
852 #[test]
854 fn workspace_paths_and_env_are_plain_drive_paths() {
855 let root = storage::app_temp_dir().join(format!("rkqa-plain-{}", new_id()));
856 let ws = create(&root, Some("plain1"), "plainapp").unwrap();
857 let paths = [
858 &ws.root,
859 &ws.home,
860 &ws.data_dir,
861 &ws.evidence_dir,
862 &ws.capture_dir,
863 ];
864 for p in paths {
865 assert!(!p.to_string_lossy().starts_with(r"\\?\"), "{}", p.display());
866 assert!(p.exists(), "{}", p.display());
867 }
868 for (key, value) in &ws.env {
869 assert!(!value.starts_with(r"\\?\"), "{key}={value}");
870 }
871 let report = dispose(&ws, false, 2, 1);
873 assert!(report.retained.is_empty(), "{report:?}");
874 assert!(!ws.home.exists() && !ws.root.exists());
875 let _ = fs::remove_dir_all(root);
876 }
877
878 #[test]
879 fn prune_spares_young_and_kept_runs() {
880 let root = storage::app_temp_dir().join(format!("rkqa-prune-{}", new_id()));
881 let a = storage::create(Some(&root), "x", Some("a"), Duration::from_secs(3600)).unwrap();
882 let _b = storage::create(Some(&root), "x", Some("b"), Duration::from_secs(3600)).unwrap();
883 storage::finish(&a).unwrap();
884 let r = prune_abandoned(&root, Duration::from_secs(3600), &["b".into()]);
885 assert!(r.removed.is_empty());
886 let r = prune_abandoned(&root, Duration::ZERO, &["b".into()]);
887 assert_eq!(r.removed.len(), 1);
888 assert!(root.join("runs/x/b").exists() && !root.join("runs/x/a").exists());
889 let _ = fs::remove_dir_all(root);
890 }
891
892 #[test]
893 fn dispose_deletes_only_a_home_the_runner_created() {
894 let root = storage::app_temp_dir().join(format!("rkqa-ws-own-{}", new_id()));
895 let mut ws = create(&root, Some("own1"), "ownapp").unwrap();
896 let foreign = root.join("precious");
898 fs::create_dir_all(&foreign).unwrap();
899 fs::write(foreign.join("keep"), b"keep").unwrap();
900 let real_home = ws.home.clone();
901 ws.home = foreign.clone();
902 let report = dispose(&ws, true, 1, 0);
903 assert!(foreign.join("keep").exists(), "{report:?}");
904 assert!(report.retained.iter().any(|(p, _)| p == &foreign));
905 ws.home = real_home.clone();
907 let report = dispose(&ws, false, 1, 0);
908 assert!(report.retained.is_empty(), "{report:?}");
909 assert!(!real_home.exists());
910 let _ = fs::remove_dir_all(root);
911 }
912
913 #[cfg(windows)]
914 #[test]
915 fn dispose_removes_a_home_that_holds_the_wininet_junction_and_a_link_out() {
916 let root = storage::app_temp_dir().join(format!("rkqa-ws-links-{}", new_id()));
918 let ws = create(&root, Some("links1"), "linkapp").unwrap();
919 let models = root.join("models-cache");
920 fs::create_dir_all(&models).unwrap();
921 fs::write(models.join("model.bin"), b"model bytes").unwrap();
922 storage::test_links::wininet_cache(&ws.home);
923 fs::create_dir_all(ws.home.join("data").join("shared")).unwrap();
924 storage::test_links::junction(&ws.home.join("data").join("shared").join("models"), &models);
925 let report = dispose(&ws, true, 1, 0);
926 assert!(report.retained.is_empty(), "{report:?}");
927 assert!(!ws.home.exists());
928 assert_eq!(fs::read(models.join("model.bin")).unwrap(), b"model bytes");
929 let _ = fs::remove_dir_all(root);
930 }
931
932 fn scanned(pid: u32, parent: u32, flags: &str, name: &str) -> ScannedProcess {
933 ScannedProcess {
934 pid,
935 parent,
936 names_home: flags.contains('C'),
937 runs_from_home: flags.contains('E'),
938 name: name.into(),
939 }
940 }
941
942 #[test]
943 fn the_process_scan_is_read_row_by_row() {
944 let scan = parse_scan(
945 " 412\t98\t-C\tmsedgewebview2.exe\r\n98\t4\t-\tCutRight.exe\n\nnot a row\n7\t1\t-CE\ttool.exe\n",
946 );
947 assert_eq!(
948 scan,
949 vec![
950 scanned(412, 98, "C", "msedgewebview2.exe"),
951 scanned(98, 4, "", "CutRight.exe"),
952 scanned(7, 1, "CE", "tool.exe"),
953 ]
954 );
955 assert!(parse_scan("").is_empty());
956 }
957
958 #[test]
959 fn only_the_runs_own_processes_may_be_ended_and_every_holder_is_named() {
960 let me = std::process::id();
961 let scan = vec![
962 scanned(98, 4, "", "CutRight.exe"),
964 scanned(412, 98, "C", "msedgewebview2.exe"),
965 scanned(413, 412, "C", "msedgewebview2.exe"),
966 scanned(7, 1, "CE", "tool.exe"),
968 scanned(900, 1, "C", "notepad.exe"),
970 scanned(5000, 1, "", "explorer.exe"),
972 scanned(me, 1, "C", "rightkit-qa.exe"),
973 ];
974 let mut launched = std::collections::BTreeSet::from([98]);
975 extend_descendants(&mut launched, &scan);
976 assert_eq!(launched, std::collections::BTreeSet::from([98, 412, 413]));
977
978 let holders = holders_in(&scan, &launched);
979 assert_eq!(
980 holders
981 .iter()
982 .map(|h| (h.pid, h.owned_by_run()))
983 .collect::<Vec<_>>(),
984 vec![(7, true), (412, true), (413, true), (900, false)]
985 );
986 assert_eq!(
987 describe_holders(&holders),
988 "tool.exe (pid 7), msedgewebview2.exe (pid 412), msedgewebview2.exe (pid 413), notepad.exe (pid 900)"
989 );
990
991 let later = vec![scanned(413, 412, "C", "msedgewebview2.exe")];
993 extend_descendants(&mut launched, &later);
994 assert!(holders_in(&later, &launched)[0].launched_by_run);
995 assert!(!holders_in(&later, &Default::default())[0].owned_by_run());
997 }
998
999 #[test]
1000 fn launched_pids_are_recorded_in_the_run_root() {
1001 let root = storage::app_temp_dir().join(format!("rkqa-ws-pids-{}", new_id()));
1002 let ws = create(&root, Some("pids1"), "pidsapp").unwrap();
1003 assert!(launched_pids(&ws).is_empty());
1004 record_launched(&ws, 4242);
1005 record_launched(&ws, 4343);
1006 record_launched(&ws, std::process::id());
1007 assert_eq!(
1008 launched_pids(&ws),
1009 std::collections::BTreeSet::from([4242, 4343])
1010 );
1011 let report = dispose(&ws, false, 2, 1);
1012 assert!(report.retained.is_empty(), "{report:?}");
1013 let _ = fs::remove_dir_all(root);
1014 }
1015
1016 #[cfg(windows)]
1020 #[test]
1021 fn dispose_waits_for_a_helper_the_app_left_in_the_home_then_removes_it() {
1022 use std::os::windows::process::CommandExt;
1023 let root = storage::app_temp_dir().join(format!("rkqa-ws-held-{}", new_id()));
1024 let ws = create(&root, Some("held1"), "heldapp").unwrap();
1025 let mut app = std::process::Command::new("cmd.exe")
1027 .args(["/d", "/c", "ping -n 120 127.0.0.1 >nul & rem"])
1028 .arg(format!("--user-data-dir={}", ws.home.display()))
1029 .current_dir(&ws.home)
1030 .creation_flags(0x0800_0000)
1031 .spawn()
1032 .unwrap();
1033 record_launched(&ws, app.id());
1034
1035 let holders = home_holders(&ws.home).expect("the process scan runs");
1037 assert!(
1038 holders.iter().any(|holder| holder.pid == app.id()),
1039 "{holders:?}"
1040 );
1041 assert!(holders
1042 .iter()
1043 .all(|holder| holder.pid != std::process::id()));
1044
1045 let report = dispose_waiting(&ws, false, 20, 250, Duration::from_secs(2));
1046 assert!(report.retained.is_empty(), "{report:?}");
1047 assert!(!ws.home.exists());
1048 let _ = app.kill();
1049 let _ = app.wait();
1050 let _ = fs::remove_dir_all(root);
1051 }
1052
1053 #[cfg(windows)]
1056 #[test]
1057 fn dispose_leaves_a_foreign_process_that_names_the_home_and_reports_it() {
1058 use std::os::windows::process::CommandExt;
1059 let root = storage::app_temp_dir().join(format!("rkqa-ws-foreign-{}", new_id()));
1060 let ws = create(&root, Some("foreign1"), "foreignapp").unwrap();
1061 let mut foreign = std::process::Command::new("cmd.exe")
1062 .args(["/d", "/c", "ping -n 120 127.0.0.1 >nul & rem"])
1063 .arg(format!("--watch={}", ws.home.display()))
1064 .current_dir(&ws.home)
1065 .creation_flags(0x0800_0000)
1066 .spawn()
1067 .unwrap();
1068
1069 let report = dispose_waiting(&ws, false, 2, 50, Duration::from_millis(500));
1070 assert!(matches!(foreign.try_wait(), Ok(None)), "it must still run");
1071 let (_, why) = report
1072 .retained
1073 .iter()
1074 .find(|(path, _)| path == &ws.home)
1075 .unwrap_or_else(|| panic!("the home is held: {report:?}"));
1076 assert!(
1077 why.contains(&format!("held by cmd.exe (pid {})", foreign.id())),
1078 "{why}"
1079 );
1080
1081 crate::process::kill_tree(foreign.id());
1082 let _ = foreign.wait();
1083 crate::util::sleep_ms(300);
1085 assert!(fs::remove_dir_all(&ws.home).is_ok());
1086 let _ = fs::remove_dir_all(root);
1087 }
1088}