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