1use crate::util::{err, new_id, now_ms, Result};
3use serde::{Deserialize, Serialize};
4use std::fs::{self, File, OpenOptions};
5use std::io::Write;
6use std::path::{Path, PathBuf};
7use std::time::Duration;
8
9pub const RUN_MARKER: &str = ".rightkit-run.json";
10pub const DEFAULT_KEEP_COUNT: usize = 5;
11pub const DEFAULT_YOUNG_MS: u128 = 24 * 60 * 60 * 1000;
12pub const DEFAULT_MAX_BYTES: u64 = 20_000_000_000;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15#[serde(rename_all = "camelCase")]
16pub struct RunMarker {
17 pub schema_version: u8,
18 pub app: String,
19 pub run_id: String,
20 pub created_at: u128,
21 pub owner_pid: u32,
22 pub lease_until: u128,
23 #[serde(skip_serializing_if = "Option::is_none")]
24 pub completed_at: Option<u128>,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub app_root: Option<PathBuf>,
27}
28
29#[derive(Debug, Clone)]
30pub struct ManagedRun {
31 pub root: PathBuf,
32 pub marker: RunMarker,
33}
34
35#[derive(Debug, Default, Clone)]
36pub struct RetentionReport {
37 pub removed: Vec<PathBuf>,
38 pub retained: Vec<(PathBuf, String)>,
39 pub total_bytes: u64,
40 pub cap_bytes: u64,
41 pub over_cap_bytes: u64,
42 pub blocked_bytes: u64,
43 pub unknown_bytes: u64,
44}
45
46pub fn managed_root(explicit: Option<&Path>) -> PathBuf {
47 if let Some(path) = explicit.filter(|p| !p.as_os_str().is_empty()) {
48 return path.to_path_buf();
49 }
50 if let Ok(path) = std::env::var("RIGHTKIT_MANAGED_ROOT") {
51 if !path.trim().is_empty() {
52 return PathBuf::from(path);
53 }
54 }
55 let (workstation, volume) = if cfg!(windows) {
58 (
59 PathBuf::from(r"D:\.rightkit-managed"),
60 PathBuf::from(r"D:\"),
61 )
62 } else {
63 (
64 PathBuf::from("/Volumes/D/.rightkit-managed"),
65 PathBuf::from("/Volumes/D"),
66 )
67 };
68 if volume.is_dir() {
69 return workstation;
70 }
71 let temp = std::env::var_os("RUNNER_TEMP")
72 .map(PathBuf::from)
73 .filter(|p| p.is_dir())
74 .unwrap_or_else(std::env::temp_dir);
75 temp.join("rightkit-managed")
76}
77
78pub fn run_root(root: &Path, app: &str, run_id: &str) -> Result<PathBuf> {
79 let app = safe_key(app, "app")?;
80 let run_id = safe_key(run_id, "run id")?;
81 let runs = root.join("runs").join(&app);
82 let result = runs.join(&run_id);
83 if !is_inside(&runs, &result) {
84 return err("QA run path escapes managed root");
85 }
86 Ok(result)
87}
88
89fn trusted_root(root: &Path) -> Result<PathBuf> {
94 fs::create_dir_all(root)?;
95 Ok(fs::canonicalize(root)?)
96}
97
98pub(crate) fn prepare_app_root(root: &Path, app: &str) -> Result<PathBuf> {
99 let root = trusted_root(&managed_root(Some(root)))?;
100 let app = safe_key(app, "app")?;
101 ensure_dir(&root)?;
102 ensure_dir(&root.join("runs"))?;
103 let app_root = root.join("runs").join(app);
104 ensure_dir(&app_root)?;
105 Ok(app_root)
106}
107
108pub fn create(
109 root: Option<&Path>,
110 app: &str,
111 run_id: Option<&str>,
112 lease: Duration,
113) -> Result<ManagedRun> {
114 let root = trusted_root(&managed_root(root))?;
115 let app = safe_key(app, "app")?;
116 let id = match run_id {
117 Some(id) => safe_key(id, "run id")?,
118 None => new_id(),
119 };
120 let app_root = root.join("runs").join(&app);
121 ensure_dir(&root)?;
122 ensure_dir(&root.join("runs"))?;
123 ensure_dir(&app_root)?;
124 if let Some(guard) = try_lock(&app_root, Duration::ZERO)? {
128 let mut report = RetentionReport::default();
129 prune_unlocked(
130 &app_root,
131 &app,
132 DEFAULT_MAX_BYTES,
133 DEFAULT_KEEP_COUNT,
134 DEFAULT_YOUNG_MS,
135 &[],
136 &mut report,
137 )?;
138 drop(guard);
139 if report.over_cap_bytes > 0 {
140 return err(format!(
141 "QA retention cap is blocked by {} bytes of protected or unknown data",
142 report.over_cap_bytes
143 ));
144 }
145 }
146 let run = run_root(&root, &app, &id)?;
147 match fs::create_dir(&run) {
149 Ok(()) => {}
150 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
151 return err(format!("QA run already exists: {}", run.display()));
152 }
153 Err(e) => return Err(e.into()),
154 }
155 fs::create_dir_all(run.join("data"))?;
156 fs::create_dir_all(run.join("evidence"))?;
157 let now = now_ms();
158 let marker = RunMarker {
159 schema_version: 1,
160 app: app.clone(),
161 run_id: id.clone(),
162 created_at: now,
163 owner_pid: std::process::id(),
164 lease_until: now.saturating_add(lease.as_millis().max(1)),
165 completed_at: None,
166 app_root: None,
167 };
168 write_marker(&run, &marker)?;
169 Ok(ManagedRun { root: run, marker })
170}
171
172pub fn finish(run: &ManagedRun) -> Result<RunMarker> {
173 let mut marker = read_marker(&run.root.join(RUN_MARKER))
174 .ok_or_else(|| crate::util::Error("QA run marker is missing or malformed".into()))?;
175 if let Some(home) = owned_app_root(&run.root, &marker)? {
176 for name in ["evidence", "captures"] {
177 let target = if name == "evidence" {
178 run.root.join("evidence/app")
179 } else {
180 run.root.join(name)
181 };
182 copy_tree(&home.join(name), &target)?;
183 }
184 for entry in fs::read_dir(home)? {
185 let entry = entry?;
186 if entry.file_name().to_string_lossy().ends_with(".log") {
187 copy_tree(
188 &entry.path(),
189 &run.root.join("evidence").join(entry.file_name()),
190 )?;
191 }
192 }
193 }
194 marker.owner_pid = 0;
195 marker.lease_until = 0;
196 marker.completed_at = Some(now_ms());
197 write_marker(&run.root, &marker)?;
198 Ok(marker)
199}
200
201pub(crate) fn app_temp_dir() -> PathBuf {
204 #[cfg(target_os = "macos")]
205 {
206 PathBuf::from("/private/tmp")
207 }
208 #[cfg(not(target_os = "macos"))]
209 std::env::temp_dir()
210}
211
212pub const HOME_ROOT_ENV: &str = "RIGHTKIT_QA_HOME_ROOT";
220pub const HOME_SWEEP_ENV: &str = "RIGHTKIT_QA_HOME_SWEEP";
222const SWEEP_INTERVAL: Duration = Duration::from_secs(10 * 60);
224pub const DEFAULT_HOME_MAX_BYTES: u64 = 4_000_000_000;
226
227fn home_base_from(override_value: Option<&std::ffi::OsStr>) -> PathBuf {
228 match override_value {
229 Some(v) if !v.is_empty() && Path::new(v).is_absolute() => PathBuf::from(v),
230 _ => app_temp_dir(),
231 }
232}
233
234pub(crate) fn home_base() -> PathBuf {
236 home_base_from(std::env::var_os(HOME_ROOT_ENV).as_deref())
237}
238
239pub(crate) fn create_app_root(run: &mut ManagedRun) -> Result<PathBuf> {
240 let parent = trusted_root(&home_base())?.join("rightkit-qa");
241 ensure_dir(&parent)?;
242 let parent = fs::canonicalize(parent)?;
243 create_app_root_in(run, &parent)
244}
245
246fn create_app_root_in(run: &mut ManagedRun, parent: &Path) -> Result<PathBuf> {
247 let label = product_label(&run.marker.app);
248 let home = parent.join(format!("{}-{}", label, &new_id().replace('-', "")[..16]));
249 fs::create_dir(&home)?;
250 run.marker.app_root = Some(home.clone());
251 write_whole(
253 &home.join(".rightkit-app.json"),
254 &serde_json::to_vec(&serde_json::json!({
255 "runRoot": fs::canonicalize(&run.root)?, "app": run.marker.app,
256 "runId": run.marker.run_id,
257 }))?,
258 )?;
259 write_home_marker(&home, &run.marker.app, &run.marker.run_id)?;
260 write_marker(&run.root, &run.marker)?;
261 Ok(home)
262}
263
264#[derive(Debug, Clone, Copy)]
267pub struct HomePolicy {
268 pub young: Duration,
269 pub max_bytes: u64,
270}
271
272impl Default for HomePolicy {
273 fn default() -> Self {
274 Self {
275 young: Duration::from_millis(DEFAULT_YOUNG_MS as u64),
276 max_bytes: DEFAULT_HOME_MAX_BYTES,
277 }
278 }
279}
280
281#[derive(Debug, Default, Clone)]
282pub struct HomeSweepReport {
283 pub removed: Vec<(PathBuf, u64, String)>,
285 pub retained: Vec<(PathBuf, String)>,
286 pub total_bytes: u64,
288 pub removed_bytes: u64,
289}
290
291struct HomeEntry {
292 dir: PathBuf,
293 bytes: u64,
294 stamp: u128,
296 kind: HomeKind,
297}
298
299enum HomeKind {
300 Active,
302 Finished,
304 Orphan,
306}
307
308fn modified_ms(path: &Path) -> Option<u128> {
309 fs::symlink_metadata(path)
310 .ok()?
311 .modified()
312 .ok()?
313 .duration_since(std::time::UNIX_EPOCH)
314 .ok()
315 .map(|d| d.as_millis())
316}
317
318fn tree_bytes(dir: &Path) -> u64 {
320 let mut total = 0u64;
321 let mut stack = vec![dir.to_path_buf()];
322 while let Some(d) = stack.pop() {
323 let Ok(entries) = fs::read_dir(&d) else {
324 continue;
325 };
326 for e in entries.flatten() {
327 let Ok(ft) = e.file_type() else { continue };
328 if ft.is_dir() {
329 stack.push(e.path());
330 } else if ft.is_file() {
331 total = total.saturating_add(e.metadata().map(|m| m.len()).unwrap_or(0));
332 }
333 }
334 }
335 total
336}
337
338pub fn sweep_homes(parent: &Path, policy: HomePolicy, dry_run: bool) -> HomeSweepReport {
344 let mut report = HomeSweepReport::default();
345 if parent.file_name().is_none_or(|n| n != "rightkit-qa") || !safe_dir(parent) {
346 return report;
347 }
348 let Ok(canonical_parent) = fs::canonicalize(parent) else {
349 return report;
350 };
351 let Ok(entries) = fs::read_dir(&canonical_parent) else {
352 return report;
353 };
354 let now = now_ms();
355 let young_ms = policy.young.as_millis();
356 let mut entries_found: Vec<HomeEntry> = Vec::new();
357 for entry in entries.flatten() {
358 let name = entry.file_name().to_string_lossy().into_owned();
359 let Ok(ft) = entry.file_type() else { continue };
360 if !ft.is_dir() || ft.is_symlink() || name.starts_with('.') {
361 continue;
362 }
363 let dir = entry.path();
364 let binding_path = dir.join(".rightkit-app.json");
365 let Some(binding) = fs::read(&binding_path)
366 .ok()
367 .and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
368 else {
369 report
370 .retained
371 .push((dir, "no readable binding file: not proven ours".into()));
372 continue;
373 };
374 if read_home_marker(&dir).is_some_and(|(pid, _)| pid_live(pid)) {
375 report.retained.push((dir, "owner process alive".into()));
376 continue;
377 }
378 let bytes = tree_bytes(&dir);
379 report.total_bytes = report.total_bytes.saturating_add(bytes);
380 let field = |k: &str| binding.get(k).and_then(|v| v.as_str()).map(str::to_owned);
381 let marker = field("runRoot")
382 .and_then(|root| read_marker(&Path::new(&root).join(RUN_MARKER)))
383 .filter(|m| {
384 Some(&m.app) == field("app").as_ref()
385 && Some(&m.run_id) == field("runId").as_ref()
386 && m.app_root.as_deref().is_some_and(|r| r == dir)
387 });
388 let (kind, stamp) = match marker {
389 Some(m) if marker_active(&m, now) => (HomeKind::Active, m.created_at),
390 Some(m) => (HomeKind::Finished, m.completed_at.unwrap_or(m.created_at)),
391 None => {
392 let stamp = modified_ms(&binding_path)
393 .into_iter()
394 .chain(modified_ms(&dir))
395 .max()
396 .unwrap_or(now);
397 (HomeKind::Orphan, stamp)
398 }
399 };
400 entries_found.push(HomeEntry {
401 dir,
402 bytes,
403 stamp,
404 kind,
405 });
406 }
407 entries_found.sort_by_key(|e| e.stamp);
408
409 let mut remaining = report.total_bytes;
410 let mut doomed: Vec<(usize, &'static str)> = Vec::new();
411 for (i, e) in entries_found.iter().enumerate() {
412 let aged = now.saturating_sub(e.stamp) >= young_ms;
413 match e.kind {
414 HomeKind::Active => report.retained.push((e.dir.clone(), "active run".into())),
415 _ if !aged => {}
416 HomeKind::Finished => doomed.push((i, "run over and older than the window")),
417 HomeKind::Orphan => doomed.push((i, "run record gone and older than the window")),
418 }
419 }
420 for (i, _) in &doomed {
421 remaining = remaining.saturating_sub(entries_found[*i].bytes);
422 }
423 let finished_pool: u64 = entries_found
428 .iter()
429 .enumerate()
430 .filter(|(i, e)| {
431 matches!(e.kind, HomeKind::Finished) && !doomed.iter().any(|(d, _)| d == i)
432 })
433 .map(|(_, e)| e.bytes)
434 .sum();
435 if remaining > policy.max_bytes && remaining.saturating_sub(finished_pool) <= policy.max_bytes {
436 for (i, e) in entries_found.iter().enumerate() {
437 if remaining <= policy.max_bytes {
438 break;
439 }
440 if matches!(e.kind, HomeKind::Finished) && !doomed.iter().any(|(d, _)| *d == i) {
441 doomed.push((i, "run over; homes exceed the size bound"));
442 remaining = remaining.saturating_sub(e.bytes);
443 }
444 }
445 }
446 let doomed_idx: std::collections::HashSet<usize> = doomed.iter().map(|(i, _)| *i).collect();
447 for (i, reason) in doomed {
448 let e = &entries_found[i];
449 let removable = safe_dir(&e.dir)
450 && fs::canonicalize(&e.dir)
451 .is_ok_and(|c| c == e.dir && c.parent() == Some(canonical_parent.as_path()));
452 if !removable {
453 report
454 .retained
455 .push((e.dir.clone(), "path is not a plain home directory".into()));
456 continue;
457 }
458 if !dry_run {
459 if let Err(error) = remove_home_tree(&canonical_parent, &e.dir) {
460 report.retained.push((e.dir.clone(), error));
461 continue;
462 }
463 }
464 report.removed_bytes = report.removed_bytes.saturating_add(e.bytes);
465 report
466 .removed
467 .push((e.dir.clone(), e.bytes, reason.to_string()));
468 }
469 for (i, e) in entries_found.iter().enumerate() {
470 if !doomed_idx.contains(&i) && !matches!(e.kind, HomeKind::Active) {
471 report
472 .retained
473 .push((e.dir.clone(), "younger than the window".into()));
474 }
475 }
476 report
477}
478
479fn sweep_homes_throttled(parent: &Path) {
482 let stamp = parent.join(".last-sweep");
483 let due = modified_ms(&stamp)
484 .is_none_or(|t| now_ms().saturating_sub(t) >= SWEEP_INTERVAL.as_millis());
485 if !due {
486 return;
487 }
488 let _ = fs::write(&stamp, now_ms().to_string());
490 let _ = sweep_homes(parent, HomePolicy::default(), false);
491}
492
493pub const HOME_MARKER: &str = ".rightkit-home.json";
500const BINDING_FILE: &str = ".rightkit-app.json";
502pub const ROOT_CAP_ENV: &str = "RIGHTKIT_QA_ROOT_CAP_BYTES";
506pub const ROOT_CEILING_ENV: &str = "RIGHTKIT_QA_ROOT_CEILING_BYTES";
509pub const HOME_MAX_AGE_ENV: &str = "RIGHTKIT_QA_HOME_MAX_AGE_SECS";
511pub const KEEP_FAILED_HOME_ENV: &str = "RIGHTKIT_QA_KEEP_FAILED_HOME";
513pub const DEFAULT_ROOT_CAP_BYTES: u64 = 4_000_000_000;
514pub const DEFAULT_ROOT_CEILING_BYTES: u64 = 32_000_000_000;
515pub const DEFAULT_HOME_MAX_AGE: Duration = Duration::from_secs(2 * 3600);
516const HOME_LABEL_LEN: usize = 16;
518
519fn bytes_from(raw: Option<&str>, default: u64) -> u64 {
520 raw.and_then(|v| v.trim().parse::<u64>().ok())
521 .unwrap_or(default)
522}
523
524pub fn root_cap_bytes() -> u64 {
526 bytes_from(
527 std::env::var(ROOT_CAP_ENV).ok().as_deref(),
528 DEFAULT_ROOT_CAP_BYTES,
529 )
530}
531
532pub fn root_ceiling_bytes() -> u64 {
534 bytes_from(
535 std::env::var(ROOT_CEILING_ENV).ok().as_deref(),
536 DEFAULT_ROOT_CEILING_BYTES,
537 )
538}
539
540#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
544pub struct RootLimits {
545 pub cap_bytes: Option<u64>,
547 pub ceiling_bytes: Option<u64>,
549}
550
551#[derive(Debug, Clone, Copy, PartialEq, Eq)]
553pub struct ResolvedRootLimits {
554 pub cap_bytes: u64,
555 pub ceiling_bytes: u64,
556}
557
558impl RootLimits {
559 pub fn with_cap_bytes(mut self, bytes: u64) -> Self {
560 self.cap_bytes = Some(bytes);
561 self
562 }
563
564 pub fn with_ceiling_bytes(mut self, bytes: u64) -> Self {
565 self.ceiling_bytes = Some(bytes);
566 self
567 }
568
569 pub fn resolve(&self) -> ResolvedRootLimits {
571 self.resolve_with(&|key: &str| std::env::var(key).ok())
572 }
573
574 fn resolve_with(&self, env: &dyn Fn(&str) -> Option<String>) -> ResolvedRootLimits {
575 ResolvedRootLimits {
576 cap_bytes: self.cap_bytes.unwrap_or_else(|| {
577 bytes_from(env(ROOT_CAP_ENV).as_deref(), DEFAULT_ROOT_CAP_BYTES)
578 }),
579 ceiling_bytes: self.ceiling_bytes.unwrap_or_else(|| {
580 bytes_from(env(ROOT_CEILING_ENV).as_deref(), DEFAULT_ROOT_CEILING_BYTES)
581 }),
582 }
583 }
584}
585
586pub fn home_max_age() -> Duration {
587 std::env::var(HOME_MAX_AGE_ENV)
588 .ok()
589 .and_then(|v| v.trim().parse::<u64>().ok())
590 .map(Duration::from_secs)
591 .unwrap_or(DEFAULT_HOME_MAX_AGE)
592}
593
594pub fn keep_failed_home_requested() -> bool {
595 std::env::var(KEEP_FAILED_HOME_ENV).is_ok_and(|v| matches!(v.trim(), "1" | "true" | "yes"))
596}
597
598fn write_home_marker(home: &Path, app: &str, run_id: &str) -> Result<()> {
599 write_whole(
600 &home.join(HOME_MARKER),
601 &serde_json::to_vec(&serde_json::json!({
602 "schemaVersion": 1, "pid": std::process::id(), "startedAtMs": now_ms() as u64,
603 "app": app, "runId": run_id,
604 }))?,
605 )
606}
607
608fn write_whole(path: &Path, bytes: &[u8]) -> Result<()> {
611 rightkit_fs::atomic_write(path, bytes).map_err(std::io::Error::from)?;
612 Ok(())
613}
614
615fn read_home_marker(home: &Path) -> Option<(u32, u128)> {
617 let v: serde_json::Value =
618 serde_json::from_slice(&fs::read(home.join(HOME_MARKER)).ok()?).ok()?;
619 let pid = u32::try_from(v.get("pid")?.as_u64()?).ok()?;
620 Some((pid, u128::from(v.get("startedAtMs")?.as_u64()?)))
621}
622
623fn read_binding(home: &Path) -> Option<serde_json::Value> {
625 fs::read(home.join(BINDING_FILE))
626 .ok()
627 .and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
628 .filter(serde_json::Value::is_object)
629}
630
631fn product_label(app: &str) -> String {
633 app.chars().take(HOME_LABEL_LEN).collect()
634}
635
636fn parse_home_name(name: &str) -> Option<&str> {
642 let (label, suffix) = name.rsplit_once('-')?;
643 let suffix_ok = (suffix.len() == 16
644 && suffix
645 .bytes()
646 .all(|b| b.is_ascii_digit() || matches!(b, b'a'..=b'f')))
647 || (suffix.len() == 6 && suffix.bytes().all(|b| b.is_ascii_alphanumeric()));
648 let label_ok = !label.is_empty()
649 && label.chars().count() <= HOME_LABEL_LEN
650 && label.starts_with(|c: char| c.is_ascii_alphanumeric())
651 && label
652 .chars()
653 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
654 (suffix_ok && label_ok).then_some(label)
655}
656
657fn home_product(name: &str, binding: Option<&serde_json::Value>) -> String {
660 binding
661 .and_then(|b| b.get("app"))
662 .and_then(|v| v.as_str())
663 .filter(|app| !app.is_empty())
664 .map(str::to_owned)
665 .unwrap_or_else(|| parse_home_name(name).unwrap_or(name).to_owned())
666}
667
668fn same_product(home_product: &str, app: &str) -> bool {
670 product_label(home_product) == product_label(app)
671}
672
673pub(crate) fn is_owned_home(home: &Path) -> bool {
676 safe_dir(home)
677 && home.parent().and_then(Path::file_name) == Some(std::ffi::OsStr::new("rightkit-qa"))
678 && home.join(BINDING_FILE).is_file()
679}
680
681pub fn qa_root() -> PathBuf {
683 home_base().join("rightkit-qa")
684}
685
686fn remove_home_dir(dir: &Path, canonical_parent: &Path) -> std::result::Result<(), String> {
689 let plain = safe_dir(dir)
690 && fs::canonicalize(dir).is_ok_and(|c| c == dir && c.parent() == Some(canonical_parent));
691 if !plain {
692 return Err("path is not a plain home directory".into());
693 }
694 remove_home_tree(canonical_parent, dir)
695}
696
697pub(crate) fn remove_home_tree(qa_root: &Path, home: &Path) -> std::result::Result<(), String> {
715 let (Ok(root), Ok(real)) = (fs::canonicalize(qa_root), fs::canonicalize(home)) else {
716 return Err(format!("{}: cannot be resolved", home.display()));
717 };
718 if root.file_name().is_none_or(|n| n != "rightkit-qa")
719 || !safe_dir(home)
720 || !is_inside(&root, &real)
721 {
722 return Err(format!(
723 "{}: not a directory inside the QA root {}",
724 home.display(),
725 root.display()
726 ));
727 }
728 let binding = std::ffi::OsStr::new(BINDING_FILE);
729 remove_children(&real, Some(binding))?;
730 remove_children(&real, None)?;
731 fs::remove_dir(&real).map_err(|e| removal_error(&real, &e))
732}
733
734fn removal_error(path: &Path, error: &std::io::Error) -> String {
735 format!("{}: {error}", path.display())
736}
737
738fn remove_children(dir: &Path, keep: Option<&std::ffi::OsStr>) -> std::result::Result<(), String> {
740 for entry in fs::read_dir(dir).map_err(|e| removal_error(dir, &e))? {
741 let entry = entry.map_err(|e| removal_error(dir, &e))?;
742 if keep == Some(entry.file_name().as_os_str()) {
743 continue;
744 }
745 let path = entry.path();
746 let kind = entry.file_type().map_err(|e| removal_error(&path, &e))?;
748 let outcome = if kind.is_symlink() {
749 remove_link(&entry)
750 } else if kind.is_dir() {
751 remove_children(&path, None)?;
752 fs::remove_dir(&path)
753 } else {
754 remove_plain_file(&path)
755 };
756 match outcome {
757 Ok(()) => {}
758 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
760 Err(e) => return Err(removal_error(&path, &e)),
761 }
762 }
763 Ok(())
764}
765
766fn remove_link(entry: &fs::DirEntry) -> std::io::Result<()> {
769 #[cfg(windows)]
770 {
771 use std::os::windows::fs::MetadataExt;
772 const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x10;
773 if entry.metadata()?.file_attributes() & FILE_ATTRIBUTE_DIRECTORY != 0 {
774 return fs::remove_dir(entry.path());
775 }
776 }
777 fs::remove_file(entry.path())
778}
779
780fn remove_plain_file(path: &Path) -> std::io::Result<()> {
783 let first = match fs::remove_file(path) {
784 Ok(()) => return Ok(()),
785 Err(e) => e,
786 };
787 #[cfg(windows)]
788 if first.kind() == std::io::ErrorKind::PermissionDenied {
789 if let Ok(meta) = fs::symlink_metadata(path) {
790 let mut permissions = meta.permissions();
791 if permissions.readonly() {
792 #[allow(clippy::permissions_set_readonly_false)]
793 permissions.set_readonly(false);
794 if fs::set_permissions(path, permissions).is_ok() {
795 return fs::remove_file(path);
796 }
797 }
798 }
799 }
800 Err(first)
801}
802
803fn modified_after(dir: &Path, cutoff_ms: u128) -> bool {
807 if modified_ms(dir).is_none_or(|t| t > cutoff_ms) {
808 return true;
809 }
810 let mut stack = vec![dir.to_path_buf()];
811 while let Some(d) = stack.pop() {
812 let Ok(entries) = fs::read_dir(&d) else {
813 return true;
814 };
815 for entry in entries.flatten() {
816 let path = entry.path();
817 if modified_ms(&path).is_none_or(|t| t > cutoff_ms) {
818 return true;
819 }
820 if entry.file_type().is_ok_and(|ft| ft.is_dir()) {
821 stack.push(path);
822 }
823 }
824 }
825 false
826}
827
828fn unmarked_home_verdict(
844 dir: &Path,
845 name: &str,
846 binding: Option<&serde_json::Value>,
847 marker_pid: Option<u32>,
848 now: u128,
849 max_age_ms: u128,
850) -> std::result::Result<&'static str, String> {
851 if parse_home_name(name).is_none() {
852 return Err("no owner marker and the name is not a QA home name: not proven ours".into());
853 }
854 let record_pid = binding
855 .and_then(|b| b.get("runRoot"))
856 .and_then(|v| v.as_str())
857 .and_then(|root| read_marker(&Path::new(root).join(RUN_MARKER)))
858 .map(|m| m.owner_pid);
859 for pid in marker_pid.into_iter().chain(record_pid) {
860 if pid_live(pid) {
861 return Err(format!(
862 "no owner marker; recorded owner process {pid} is alive"
863 ));
864 }
865 }
866 if modified_after(dir, now.saturating_sub(max_age_ms)) {
867 return Err("no owner marker; modified within the window".into());
868 }
869 Ok("no owner marker, no live recorded owner, untouched for longer than the window")
870}
871
872pub fn clean_homes(parent: &Path, max_age: Duration, dry_run: bool) -> HomeSweepReport {
885 let mut report = HomeSweepReport::default();
886 if parent.file_name().is_none_or(|n| n != "rightkit-qa") || !safe_dir(parent) {
887 return report;
888 }
889 let Ok(canonical_parent) = fs::canonicalize(parent) else {
890 return report;
891 };
892 let Ok(entries) = fs::read_dir(&canonical_parent) else {
893 return report;
894 };
895 let now = now_ms();
896 let max_age_ms = max_age.as_millis();
897 for entry in entries.flatten() {
898 let name = entry.file_name().to_string_lossy().into_owned();
899 if name.starts_with('.') {
900 continue;
901 }
902 let Ok(ft) = entry.file_type() else { continue };
903 let dir = entry.path();
904 if ft.is_symlink() {
905 report.retained.push((dir, "symlink skipped".into()));
906 continue;
907 }
908 if !ft.is_dir() {
909 continue;
910 }
911 let binding = read_binding(&dir);
912 let owner = read_home_marker(&dir);
913 let bytes = tree_bytes(&dir);
914 report.total_bytes = report.total_bytes.saturating_add(bytes);
915 let verdict: std::result::Result<&'static str, String> = match (&binding, owner) {
916 (Some(_), Some((pid, started))) => {
917 if pid_live(pid) {
918 Err("owner process alive".into())
919 } else if now.saturating_sub(started) < max_age_ms {
920 Err("owner gone but younger than the window".into())
921 } else {
922 Ok("owner process gone and older than the window")
923 }
924 }
925 _ => unmarked_home_verdict(
926 &dir,
927 &name,
928 binding.as_ref(),
929 owner.map(|(pid, _)| pid),
930 now,
931 max_age_ms,
932 ),
933 };
934 match verdict {
935 Err(why) => report.retained.push((dir, why)),
936 Ok(reason) => {
937 if !dry_run {
938 if let Err(why) = remove_home_dir(&dir, &canonical_parent) {
939 report.retained.push((dir, why));
940 continue;
941 }
942 }
943 report.removed_bytes = report.removed_bytes.saturating_add(bytes);
944 report.removed.push((dir, bytes, reason.to_string()));
945 }
946 }
947 }
948 report
949}
950
951pub fn clean(dry_run: bool) -> HomeSweepReport {
954 clean_homes(&qa_root(), home_max_age(), dry_run)
955}
956
957#[derive(Debug, Clone, Copy, PartialEq, Eq)]
959pub enum RootLimitKind {
960 ProductCap,
962 RootCeiling,
964}
965
966#[derive(Debug, Clone)]
968pub struct RootOverCap {
969 pub kind: RootLimitKind,
970 pub root: PathBuf,
971 pub app: String,
973 pub total_bytes: u64,
975 pub home_count: usize,
977 pub limit_bytes: u64,
979 pub largest: Vec<(PathBuf, u64, String)>,
981}
982
983fn human_bytes(bytes: u64) -> String {
985 if bytes >= 1_000_000_000 {
986 format!("{:.2} GB ({bytes} bytes)", bytes as f64 / 1e9)
987 } else if bytes >= 1_000_000 {
988 format!("{:.1} MB ({bytes} bytes)", bytes as f64 / 1e6)
989 } else {
990 format!("{bytes} bytes")
991 }
992}
993
994impl std::fmt::Display for RootOverCap {
995 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
996 match self.kind {
997 RootLimitKind::ProductCap => write!(
998 f,
999 "QA run for `{app}` refused: the {app} homes in {root} hold {total} in {n} home(s), over the {limit} per-product cap ({env}; `Harness::with_root_cap_bytes` sets it in code, 0 disables). Other products' homes are not counted. Largest {app} homes:",
1000 app = self.app,
1001 root = self.root.display(),
1002 total = human_bytes(self.total_bytes),
1003 n = self.home_count,
1004 limit = human_bytes(self.limit_bytes),
1005 env = ROOT_CAP_ENV,
1006 )?,
1007 RootLimitKind::RootCeiling => write!(
1008 f,
1009 "QA run for `{app}` refused: {root} holds {total} across all products in {n} home(s), over the {limit} root ceiling ({env}; `Harness::with_root_ceiling_bytes` sets it in code, 0 disables), so the disk may be at risk. Largest homes:",
1010 app = self.app,
1011 root = self.root.display(),
1012 total = human_bytes(self.total_bytes),
1013 n = self.home_count,
1014 limit = human_bytes(self.limit_bytes),
1015 env = ROOT_CEILING_ENV,
1016 )?,
1017 }
1018 for (home, bytes, product) in &self.largest {
1019 write!(
1020 f,
1021 " {} [{product}] ({});",
1022 home.display(),
1023 human_bytes(*bytes)
1024 )?;
1025 }
1026 write!(
1027 f,
1028 " Run `rightkit-qa clean` to remove the homes of dead runs (owner process gone and untouched for the home age bound, 2 hours by default, {HOME_MAX_AGE_ENV}); a home it keeps belongs to a live or recent run, so remove it by hand once you know that run is over, then retry"
1029 )
1030 }
1031}
1032impl std::error::Error for RootOverCap {}
1033impl From<RootOverCap> for crate::util::Error {
1034 fn from(e: RootOverCap) -> Self {
1035 crate::util::Error(e.to_string())
1036 }
1037}
1038
1039pub fn check_root_cap(
1042 parent: &Path,
1043 app: &str,
1044 cap_bytes: u64,
1045) -> std::result::Result<(), RootOverCap> {
1046 check_root_limits(parent, app, cap_bytes, 0)
1047}
1048
1049pub fn check_root_limits(
1054 parent: &Path,
1055 app: &str,
1056 cap_bytes: u64,
1057 ceiling_bytes: u64,
1058) -> std::result::Result<(), RootOverCap> {
1059 if (cap_bytes == 0 && ceiling_bytes == 0) || !safe_dir(parent) {
1060 return Ok(());
1061 }
1062 struct SizedHome {
1063 path: PathBuf,
1064 bytes: u64,
1065 product: String,
1066 own: bool,
1067 }
1068 let mut homes: Vec<SizedHome> = Vec::new();
1069 if let Ok(entries) = fs::read_dir(parent) {
1070 for entry in entries.flatten() {
1071 let Ok(ft) = entry.file_type() else { continue };
1072 if !ft.is_dir() || ft.is_symlink() {
1073 continue;
1074 }
1075 let path = entry.path();
1076 let name = entry.file_name().to_string_lossy().into_owned();
1077 let product = home_product(&name, read_binding(&path).as_ref());
1078 let own = same_product(&product, app);
1079 let bytes = if (own && cap_bytes > 0) || ceiling_bytes > 0 {
1081 tree_bytes(&path)
1082 } else {
1083 0
1084 };
1085 homes.push(SizedHome {
1086 path,
1087 bytes,
1088 product,
1089 own,
1090 });
1091 }
1092 }
1093 fn refusal(
1094 kind: RootLimitKind,
1095 limit_bytes: u64,
1096 parent: &Path,
1097 app: &str,
1098 mut counted: Vec<&SizedHome>,
1099 ) -> RootOverCap {
1100 let total_bytes = counted
1101 .iter()
1102 .fold(0u64, |sum, home| sum.saturating_add(home.bytes));
1103 let home_count = counted.len();
1104 counted.sort_by_key(|home| std::cmp::Reverse(home.bytes));
1105 RootOverCap {
1106 kind,
1107 root: parent.to_path_buf(),
1108 app: app.to_string(),
1109 total_bytes,
1110 home_count,
1111 limit_bytes,
1112 largest: counted
1113 .into_iter()
1114 .take(5)
1115 .map(|home| (home.path.clone(), home.bytes, home.product.clone()))
1116 .collect(),
1117 }
1118 }
1119 if cap_bytes > 0 {
1120 let own: Vec<&SizedHome> = homes.iter().filter(|home| home.own).collect();
1121 let own_total = own
1122 .iter()
1123 .fold(0u64, |sum, home| sum.saturating_add(home.bytes));
1124 if own_total > cap_bytes {
1125 return Err(refusal(
1126 RootLimitKind::ProductCap,
1127 cap_bytes,
1128 parent,
1129 app,
1130 own,
1131 ));
1132 }
1133 }
1134 if ceiling_bytes > 0 {
1135 let all: Vec<&SizedHome> = homes.iter().collect();
1136 let total = all
1137 .iter()
1138 .fold(0u64, |sum, home| sum.saturating_add(home.bytes));
1139 if total > ceiling_bytes {
1140 return Err(refusal(
1141 RootLimitKind::RootCeiling,
1142 ceiling_bytes,
1143 parent,
1144 app,
1145 all,
1146 ));
1147 }
1148 }
1149 Ok(())
1150}
1151
1152fn sweep_root_at_run_start(parent: &Path) {
1155 if std::env::var(HOME_SWEEP_ENV).is_ok_and(|v| v.trim() == "0") {
1156 return;
1157 }
1158 let _ = clean_homes(parent, home_max_age(), false);
1159 sweep_homes_throttled(parent);
1160}
1161
1162fn enforce_limits_with(
1165 parent: &Path,
1166 app: &str,
1167 limits: RootLimits,
1168 env: &dyn Fn(&str) -> Option<String>,
1169) -> std::result::Result<(), RootOverCap> {
1170 let limits = limits.resolve_with(env);
1171 check_root_limits(parent, app, limits.cap_bytes, limits.ceiling_bytes)
1172}
1173
1174pub(crate) fn sweep_home_root() {
1178 if cfg!(test) {
1179 return;
1180 }
1181 let parent = qa_root();
1182 if safe_dir(&parent) {
1183 sweep_root_at_run_start(&parent);
1184 }
1185}
1186
1187pub(crate) fn enforce_root_limits(
1190 app: &str,
1191 limits: RootLimits,
1192) -> std::result::Result<(), RootOverCap> {
1193 if cfg!(test) {
1194 return Ok(());
1195 }
1196 let parent = qa_root();
1197 if !safe_dir(&parent) {
1198 return Ok(());
1199 }
1200 enforce_limits_with(&parent, app, limits, &|key: &str| std::env::var(key).ok())
1201}
1202
1203pub(crate) fn preflight_home_root(app: &str, limits: RootLimits) -> Result<()> {
1207 sweep_home_root();
1208 enforce_root_limits(app, limits)?;
1209 Ok(())
1210}
1211
1212pub fn describe_kept_home(home: &Path) -> String {
1214 format!("{} ({} bytes)", home.display(), tree_bytes(home))
1215}
1216
1217fn owned_app_root(root: &Path, marker: &RunMarker) -> Result<Option<PathBuf>> {
1218 let Some(home) = &marker.app_root else {
1219 return Ok(None);
1220 };
1221 if !home.exists() {
1222 return Ok(None);
1223 }
1224 let parent = home
1225 .parent()
1226 .ok_or_else(|| crate::util::Error("invalid QA app temp root".into()))?;
1227 let temp = parent
1228 .parent()
1229 .ok_or_else(|| crate::util::Error("invalid QA app temp root".into()))?;
1230 let allowed_temp = temp == fs::canonicalize(app_temp_dir())?
1231 || fs::canonicalize(home_base()).is_ok_and(|base| temp == base)
1232 || (cfg!(target_os = "macos")
1233 && (temp == Path::new("/private/tmp")
1234 || (temp.starts_with("/private/var/folders")
1235 && temp.file_name().is_some_and(|n| n == "T"))));
1236 if parent.file_name().is_none_or(|n| n != "rightkit-qa")
1237 || !allowed_temp
1238 || fs::canonicalize(home)? != *home
1239 || !safe_dir(home)
1240 {
1241 return err("invalid QA app temp root");
1242 }
1243 let binding: serde_json::Value =
1244 serde_json::from_slice(&fs::read(home.join(".rightkit-app.json"))?)?;
1245 if binding
1246 != serde_json::json!({"runRoot": fs::canonicalize(root)?, "app": marker.app,
1247 "runId": marker.run_id})
1248 {
1249 return err("QA app temp ownership mismatch");
1250 }
1251 Ok(Some(home.clone()))
1252}
1253
1254pub(crate) fn remove_app_root(root: &Path, marker: &RunMarker) -> Result<()> {
1255 if let Some(home) = owned_app_root(root, marker)? {
1256 let Some(qa_root) = home.parent() else {
1257 return err("invalid QA app temp root");
1258 };
1259 if let Err(why) = remove_home_tree(qa_root, &home) {
1260 return err(why);
1261 }
1262 }
1263 Ok(())
1264}
1265
1266pub(crate) fn copy_tree(source: &Path, target: &Path) -> Result<()> {
1267 let meta = fs::symlink_metadata(source)?;
1268 if meta.file_type().is_symlink() {
1269 return err("symlink in QA app evidence");
1270 }
1271 if target
1272 .symlink_metadata()
1273 .is_ok_and(|m| m.file_type().is_symlink())
1274 {
1275 return err("symlink in managed QA evidence");
1276 }
1277 if meta.is_dir() {
1278 ensure_dir(target)?;
1279 for entry in fs::read_dir(source)? {
1280 let entry = entry?;
1281 copy_tree(&entry.path(), &target.join(entry.file_name()))?;
1282 }
1283 } else if meta.is_file() {
1284 ensure_dir(
1285 target
1286 .parent()
1287 .ok_or_else(|| crate::util::Error("missing evidence parent".into()))?,
1288 )?;
1289 fs::copy(source, target)?;
1290 } else {
1291 return err("unsupported QA app evidence file");
1292 }
1293 Ok(())
1294}
1295
1296pub fn prune(root: &Path, app: &str, max_bytes: Option<u64>) -> Result<RetentionReport> {
1297 prune_with_keep(root, app, max_bytes, &[])
1298}
1299
1300pub fn prune_with_keep(
1301 root: &Path,
1302 app: &str,
1303 max_bytes: Option<u64>,
1304 keep_run_ids: &[String],
1305) -> Result<RetentionReport> {
1306 prune_with_policy(
1307 root,
1308 app,
1309 max_bytes,
1310 keep_run_ids,
1311 Duration::from_millis(DEFAULT_YOUNG_MS as u64),
1312 )
1313}
1314
1315pub fn prune_with_policy(
1316 root: &Path,
1317 app: &str,
1318 max_bytes: Option<u64>,
1319 keep_run_ids: &[String],
1320 young_for: Duration,
1321) -> Result<RetentionReport> {
1322 prune_with_policy_count(
1323 root,
1324 app,
1325 max_bytes,
1326 keep_run_ids,
1327 young_for,
1328 DEFAULT_KEEP_COUNT,
1329 )
1330}
1331
1332pub fn prune_with_age_override(
1333 root: &Path,
1334 app: &str,
1335 keep_run_ids: &[String],
1336 young_for: Duration,
1337) -> Result<RetentionReport> {
1338 prune_with_policy_count(root, app, Some(0), keep_run_ids, young_for, 0)
1339}
1340
1341fn prune_with_policy_count(
1342 root: &Path,
1343 app: &str,
1344 max_bytes: Option<u64>,
1345 keep_run_ids: &[String],
1346 young_for: Duration,
1347 keep_count: usize,
1348) -> Result<RetentionReport> {
1349 let app = safe_key(app, "app")?;
1350 let app_root = managed_root(Some(root)).join("runs").join(&app);
1351 let initial = RetentionReport {
1352 cap_bytes: max_bytes.unwrap_or(DEFAULT_MAX_BYTES),
1353 ..Default::default()
1354 };
1355 if !safe_dir(&app_root) {
1356 return Ok(initial);
1357 }
1358 let _guard = lock_or_wait(&app_root, PRUNE_LOCK_WAIT)?;
1359 {
1360 let mut report = RetentionReport::default();
1361 prune_unlocked(
1362 &app_root,
1363 &app,
1364 max_bytes.unwrap_or(DEFAULT_MAX_BYTES),
1365 keep_count,
1366 young_for.as_millis(),
1367 keep_run_ids,
1368 &mut report,
1369 )?;
1370 Ok(report)
1371 }
1372}
1373
1374fn prune_unlocked(
1375 app_root: &Path,
1376 app: &str,
1377 cap: u64,
1378 keep_count: usize,
1379 young_ms: u128,
1380 keep_run_ids: &[String],
1381 report: &mut RetentionReport,
1382) -> Result<()> {
1383 let now = now_ms();
1384 report.cap_bytes = cap;
1385 let mut runs = Vec::new();
1386 let entries = match fs::read_dir(app_root) {
1387 Ok(x) => x,
1388 Err(_) => return Ok(()),
1389 };
1390 for entry in entries.flatten() {
1391 let ft = match entry.file_type() {
1392 Ok(x) => x,
1393 Err(_) => continue,
1394 };
1395 if ft.is_symlink() {
1396 report
1397 .retained
1398 .push((entry.path(), "symlink skipped".into()));
1399 continue;
1400 }
1401 if !ft.is_dir() || entry.file_name().to_string_lossy().starts_with('.') {
1402 continue;
1403 }
1404 let dir = entry.path();
1405 if !is_inside(app_root, &dir) {
1406 continue;
1407 }
1408 let Some(marker) = read_marker(&dir.join(RUN_MARKER)) else {
1409 if let Some(bytes) = directory_bytes(&dir) {
1410 report.unknown_bytes = report.unknown_bytes.saturating_add(bytes);
1411 }
1412 report
1413 .retained
1414 .push((dir, "unknown or malformed run metadata".into()));
1415 continue;
1416 };
1417 if marker.app != app || marker.run_id != entry.file_name().to_string_lossy() {
1418 if let Some(bytes) = directory_bytes(&dir) {
1419 report.unknown_bytes = report.unknown_bytes.saturating_add(bytes);
1420 }
1421 report
1422 .retained
1423 .push((dir, "run marker identity mismatch".into()));
1424 continue;
1425 }
1426 let Some(bytes) = directory_bytes(&dir) else {
1427 report
1428 .retained
1429 .push((dir, "symlink or unreadable content skipped".into()));
1430 continue;
1431 };
1432 let active =
1433 (marker.owner_pid > 0 && pid_live(marker.owner_pid)) || marker.lease_until > now;
1434 runs.push((dir, marker, bytes, active));
1435 }
1436 runs.sort_by(|a, b| {
1437 b.1.created_at
1438 .cmp(&a.1.created_at)
1439 .then_with(|| a.1.run_id.cmp(&b.1.run_id))
1440 });
1441 let count_protected: std::collections::HashSet<String> = runs
1442 .iter()
1443 .take(keep_count)
1444 .map(|x| x.1.run_id.clone())
1445 .collect();
1446 let protected: std::collections::HashSet<String> = runs
1447 .iter()
1448 .filter(|x| now.saturating_sub(x.1.created_at) < young_ms || x.3)
1449 .map(|x| x.1.run_id.clone())
1450 .collect();
1451 let caller_protected: std::collections::HashSet<&str> =
1452 keep_run_ids.iter().map(String::as_str).collect();
1453 let protected: std::collections::HashSet<String> = protected
1454 .into_iter()
1455 .chain(caller_protected.iter().map(|x| (*x).to_string()))
1456 .collect();
1457 let mut total: u64 = runs.iter().map(|x| x.2).sum();
1458 let mut old: Vec<_> = runs
1459 .iter()
1460 .filter(|x| {
1461 !protected.contains(&x.1.run_id)
1462 && (total > cap || !count_protected.contains(&x.1.run_id))
1463 })
1464 .collect();
1465 old.sort_by(|a, b| {
1466 a.1.created_at
1467 .cmp(&b.1.created_at)
1468 .then_with(|| a.1.run_id.cmp(&b.1.run_id))
1469 });
1470 let mut removed = std::collections::HashSet::new();
1471 for item in old {
1472 if total <= cap && count_protected.contains(&item.1.run_id) {
1473 continue;
1474 }
1475 if !safe_dir(&item.0) {
1476 continue;
1477 }
1478 match remove_app_root(&item.0, &item.1)
1479 .and_then(|()| fs::remove_dir_all(&item.0).map_err(Into::into))
1480 {
1481 Ok(()) => {
1482 report.removed.push(item.0.clone());
1483 removed.insert(item.1.run_id.clone());
1484 total = total.saturating_sub(item.2);
1485 }
1486 Err(e) => report.retained.push((item.0.clone(), e.to_string())),
1487 }
1488 }
1489 report.blocked_bytes = runs
1490 .iter()
1491 .filter(|x| {
1492 !removed.contains(&x.1.run_id)
1493 && (protected.contains(&x.1.run_id)
1494 || (total <= cap && count_protected.contains(&x.1.run_id)))
1495 })
1496 .map(|x| x.2)
1497 .sum();
1498 report.total_bytes = total.saturating_add(report.unknown_bytes);
1499 report.over_cap_bytes = report.total_bytes.saturating_sub(report.cap_bytes);
1500 for item in runs.iter().filter(|x| {
1501 !removed.contains(&x.1.run_id)
1502 && (protected.contains(&x.1.run_id)
1503 || (total <= cap && count_protected.contains(&x.1.run_id)))
1504 }) {
1505 report.retained.push((
1506 item.0.clone(),
1507 if item.3 {
1508 "active run".into()
1509 } else if now.saturating_sub(item.1.created_at) < young_ms {
1510 "younger than retention window".into()
1511 } else {
1512 "newest protected run".into()
1513 },
1514 ));
1515 }
1516 Ok(())
1517}
1518
1519const PRUNE_LOCK_WAIT: Duration = Duration::from_secs(120);
1522const OWNERLESS_LOCK_GRACE: Duration = Duration::from_secs(30);
1524pub const LOCK_HELD: &str = "QA retention lock is held";
1526
1527pub fn is_lock_held(error: &crate::util::Error) -> bool {
1529 error.0.starts_with(LOCK_HELD)
1530}
1531
1532struct LockGuard(PathBuf);
1534
1535impl Drop for LockGuard {
1536 fn drop(&mut self) {
1537 let _ = fs::remove_dir_all(&self.0);
1538 }
1539}
1540
1541fn try_lock(app_root: &Path, wait: Duration) -> Result<Option<LockGuard>> {
1544 ensure_dir(app_root)?;
1545 let lock = app_root.join(".retention-lock");
1546 let owner = lock.join("owner.json");
1547 let deadline = std::time::Instant::now() + wait;
1548 loop {
1549 match fs::create_dir(&lock) {
1550 Ok(()) => {
1551 let guard = LockGuard(lock.clone());
1552 let mut file = File::create(&owner)?;
1553 write!(
1554 file,
1555 "{{\"pid\":{},\"createdAt\":{}}}",
1556 std::process::id(),
1557 now_ms()
1558 )?;
1559 return Ok(Some(guard));
1560 }
1561 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
1562 let pid = fs::read_to_string(&owner)
1563 .ok()
1564 .and_then(|x| serde_json::from_str::<serde_json::Value>(&x).ok())
1565 .and_then(|x| x.get("pid").and_then(|v| v.as_u64()))
1566 .and_then(|x| u32::try_from(x).ok())
1567 .filter(|p| *p > 0);
1568 let abandoned = match pid {
1569 Some(pid) => !pid_live(pid),
1570 None => fs::metadata(&lock)
1571 .and_then(|m| m.modified())
1572 .ok()
1573 .and_then(|t| t.elapsed().ok())
1574 .is_some_and(|age| age >= OWNERLESS_LOCK_GRACE),
1575 };
1576 if abandoned {
1577 let _ = fs::remove_dir_all(&lock);
1578 continue;
1579 }
1580 if std::time::Instant::now() >= deadline {
1581 return Ok(None);
1582 }
1583 std::thread::sleep(Duration::from_millis(20));
1584 }
1585 Err(e) if cfg!(windows) && e.kind() == std::io::ErrorKind::PermissionDenied => {
1590 if std::time::Instant::now() >= deadline {
1591 return Ok(None);
1592 }
1593 std::thread::sleep(Duration::from_millis(20));
1594 }
1595 Err(e) => return Err(e.into()),
1596 }
1597 }
1598}
1599
1600fn lock_or_wait(app_root: &Path, wait: Duration) -> Result<LockGuard> {
1601 try_lock(app_root, wait)?.ok_or_else(|| {
1602 crate::util::Error(format!(
1603 "{LOCK_HELD}: {}",
1604 app_root.join(".retention-lock").display()
1605 ))
1606 })
1607}
1608
1609fn write_marker(root: &Path, marker: &RunMarker) -> Result<()> {
1610 let target = root.join(RUN_MARKER);
1611 let temp = root.join(format!(".{RUN_MARKER}.{}.tmp", std::process::id()));
1612 let mut file = OpenOptions::new()
1613 .write(true)
1614 .create_new(true)
1615 .open(&temp)?;
1616 file.write_all(serde_json::to_string(marker)?.as_bytes())?;
1617 file.write_all(b"\n")?;
1618 file.sync_all()?;
1619 fs::rename(temp, target)?;
1620 Ok(())
1621}
1622
1623pub(crate) fn read_marker(path: &Path) -> Option<RunMarker> {
1624 let marker: RunMarker = serde_json::from_slice(&fs::read(path).ok()?).ok()?;
1625 if marker.schema_version != 1
1626 || safe_key(&marker.app, "app").is_err()
1627 || safe_key(&marker.run_id, "run id").is_err()
1628 || marker.created_at == 0
1629 {
1630 return None;
1631 }
1632 Some(marker)
1633}
1634
1635pub(crate) fn marker_active(marker: &RunMarker, now: u128) -> bool {
1636 (marker.owner_pid > 0 && pid_live(marker.owner_pid)) || marker.lease_until > now
1637}
1638
1639fn directory_bytes(root: &Path) -> Option<u64> {
1640 let mut total = 0u64;
1641 fn walk(dir: &Path, total: &mut u64) -> bool {
1642 let entries = match fs::read_dir(dir) {
1643 Ok(x) => x,
1644 Err(_) => return false,
1645 };
1646 for e in entries.flatten() {
1647 let ft = match e.file_type() {
1648 Ok(x) => x,
1649 Err(_) => return false,
1650 };
1651 if ft.is_symlink() {
1652 return false;
1653 }
1654 if ft.is_dir() {
1655 if !walk(&e.path(), total) {
1656 return false;
1657 }
1658 } else if ft.is_file() {
1659 let Some(size) = e.metadata().ok().map(|m| m.len()) else {
1660 return false;
1661 };
1662 let Some(next) = total.checked_add(size) else {
1663 return false;
1664 };
1665 *total = next;
1666 } else {
1667 return false;
1668 }
1669 }
1670 true
1671 }
1672 walk(root, &mut total).then_some(total)
1673}
1674
1675fn ensure_dir(dir: &Path) -> Result<()> {
1676 let mut current = PathBuf::new();
1677 for component in dir.components() {
1678 current.push(component.as_os_str());
1679 if current.exists() && fs::symlink_metadata(¤t)?.file_type().is_symlink() {
1680 return err(format!(
1681 "refusing symlink in managed path: {}",
1682 current.display()
1683 ));
1684 }
1685 }
1686 fs::create_dir_all(dir)?;
1687 Ok(())
1688}
1689fn safe_dir(dir: &Path) -> bool {
1690 fs::symlink_metadata(dir)
1691 .map(|m| m.is_dir() && !m.file_type().is_symlink())
1692 .unwrap_or(false)
1693}
1694fn safe_key(value: &str, name: &str) -> Result<String> {
1695 if !value.is_empty()
1696 && value
1697 .chars()
1698 .next()
1699 .is_some_and(|c| c.is_ascii_alphanumeric())
1700 && value
1701 .chars()
1702 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
1703 {
1704 Ok(value.to_string())
1705 } else {
1706 err(format!("unsafe QA {name}: {value}"))
1707 }
1708}
1709fn is_inside(root: &Path, candidate: &Path) -> bool {
1710 candidate
1711 .strip_prefix(root)
1712 .map(|r| {
1713 !r.as_os_str().is_empty()
1714 && !r
1715 .components()
1716 .any(|c| matches!(c, std::path::Component::ParentDir))
1717 })
1718 .unwrap_or(false)
1719}
1720fn pid_live(pid: u32) -> bool {
1721 let Some(pid) = std::num::NonZeroU32::new(pid) else {
1722 return false;
1723 };
1724 rightkit_child_process::AdoptedProcess::new(pid)
1726 .is_running()
1727 .unwrap_or(true)
1728}
1729
1730#[cfg(all(test, windows))]
1732pub(crate) mod test_links {
1733 use std::ffi::c_void;
1734 use std::path::Path;
1735 use std::process::Command;
1736
1737 fn plain(path: &Path) -> String {
1739 let text = path.to_string_lossy().into_owned();
1740 match text.strip_prefix(r"\\?\") {
1741 Some(rest) => rest.to_owned(),
1742 None => text,
1743 }
1744 }
1745
1746 pub(crate) fn junction(link: &Path, target: &Path) {
1748 let out = Command::new("cmd")
1749 .args(["/C", "mklink", "/J", &plain(link), &plain(target)])
1750 .output()
1751 .unwrap();
1752 assert!(
1753 out.status.success(),
1754 "mklink: {}{}",
1755 String::from_utf8_lossy(&out.stdout),
1756 String::from_utf8_lossy(&out.stderr)
1757 );
1758 }
1759
1760 pub(crate) fn deny_listing(link: &Path) {
1762 let out = Command::new("icacls")
1763 .args([&plain(link), "/L", "/deny", "*S-1-1-0:(RD)"])
1764 .output()
1765 .unwrap();
1766 assert!(
1767 out.status.success(),
1768 "icacls: {}{}",
1769 String::from_utf8_lossy(&out.stdout),
1770 String::from_utf8_lossy(&out.stderr)
1771 );
1772 }
1773
1774 pub(crate) fn wininet_cache(home: &Path) -> std::path::PathBuf {
1777 let cache = home
1778 .join("AppData")
1779 .join("Local")
1780 .join("Microsoft")
1781 .join("Windows")
1782 .join("INetCache");
1783 std::fs::create_dir_all(cache.join("IE")).unwrap();
1784 std::fs::write(cache.join("IE").join("container.dat"), b"cache").unwrap();
1785 let link = cache.join("Content.IE5");
1786 junction(&link, &cache.join("IE"));
1787 deny_listing(&link);
1788 if std::fs::read_dir(&link).is_ok() {
1789 drop_backup_privileges();
1793 eprintln!(
1794 "[qa-home-removal] listing was allowed; backup and restore privileges dropped"
1795 );
1796 } else {
1797 eprintln!("[qa-home-removal] listing is refused by the access list alone");
1798 }
1799 assert!(
1800 std::fs::read_dir(&link).is_err(),
1801 "the junction must refuse listing for this test to mean anything; privileges: {}",
1802 privileges()
1803 );
1804 link
1805 }
1806
1807 fn privileges() -> String {
1809 let out = Command::new("whoami").arg("/priv").output();
1810 let Ok(out) = out else {
1811 return "whoami did not run".into();
1812 };
1813 String::from_utf8_lossy(&out.stdout)
1814 .lines()
1815 .filter(|l| l.contains("SeBackupPrivilege") || l.contains("SeRestorePrivilege"))
1816 .collect::<Vec<_>>()
1817 .join(" | ")
1818 }
1819
1820 #[link(name = "advapi32")]
1823 extern "system" {
1824 fn OpenProcessToken(process: *mut c_void, access: u32, token: *mut *mut c_void) -> i32;
1825 fn LookupPrivilegeValueW(system: *const u16, name: *const u16, luid: *mut u32) -> i32;
1826 fn AdjustTokenPrivileges(
1827 token: *mut c_void,
1828 disable_all: i32,
1829 new_state: *const u32,
1830 length: u32,
1831 previous: *mut c_void,
1832 returned: *mut u32,
1833 ) -> i32;
1834 }
1835 #[link(name = "kernel32")]
1836 extern "system" {
1837 fn GetCurrentProcess() -> *mut c_void;
1838 fn CloseHandle(handle: *mut c_void) -> i32;
1839 }
1840
1841 fn drop_backup_privileges() {
1845 const TOKEN_ADJUST_PRIVILEGES: u32 = 0x0020;
1846 const TOKEN_QUERY: u32 = 0x0008;
1847 let mut token: *mut c_void = std::ptr::null_mut();
1848 let opened = unsafe {
1851 OpenProcessToken(
1852 GetCurrentProcess(),
1853 TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY,
1854 &mut token,
1855 )
1856 };
1857 assert!(opened != 0, "OpenProcessToken failed");
1858 for name in ["SeBackupPrivilege", "SeRestorePrivilege"] {
1859 let wide: Vec<u16> = name.encode_utf16().chain(std::iter::once(0)).collect();
1860 let mut state: [u32; 4] = [1, 0, 0, 0];
1863 let found = unsafe {
1866 LookupPrivilegeValueW(std::ptr::null(), wide.as_ptr(), state[1..].as_mut_ptr())
1867 };
1868 if found == 0 {
1869 continue;
1870 }
1871 unsafe {
1875 AdjustTokenPrivileges(
1876 token,
1877 0,
1878 state.as_ptr(),
1879 16,
1880 std::ptr::null_mut(),
1881 std::ptr::null_mut(),
1882 );
1883 }
1884 }
1885 unsafe { CloseHandle(token) };
1887 }
1888}
1889
1890#[cfg(test)]
1891mod tests {
1892 use super::*;
1893 use std::io::Write;
1894
1895 fn set_modified(path: &Path, time: std::time::SystemTime) {
1898 #[cfg(windows)]
1899 let handle = {
1900 use std::os::windows::fs::OpenOptionsExt;
1901 const FILE_WRITE_ATTRIBUTES: u32 = 0x0100;
1902 const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
1903 fs::OpenOptions::new()
1904 .access_mode(FILE_WRITE_ATTRIBUTES)
1905 .custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
1906 .open(path)
1907 .unwrap()
1908 };
1909 #[cfg(not(windows))]
1910 let handle = File::open(path).unwrap();
1911 handle.set_modified(time).unwrap();
1912 }
1913
1914 fn temp_root(label: &str) -> PathBuf {
1915 let root = app_temp_dir().join(format!(
1918 "rkqa-storage-{label}-{}-{}",
1919 std::process::id(),
1920 new_id()
1921 ));
1922 fs::create_dir_all(&root).unwrap();
1923 root
1924 }
1925
1926 fn age(run: &ManagedRun) {
1927 age_at(run, 1);
1928 }
1929
1930 fn age_at(run: &ManagedRun, created_at: u128) {
1931 let mut marker = read_marker(&run.root.join(RUN_MARKER)).unwrap();
1932 marker.created_at = created_at;
1933 marker.owner_pid = 0;
1934 marker.lease_until = 0;
1935 write_marker(&run.root, &marker).unwrap();
1936 }
1937
1938 #[test]
1939 fn newest_five_are_kept_under_byte_cap() {
1940 let root = temp_root("count");
1941 let mut runs = Vec::new();
1942 for i in 0..7 {
1943 let run = create(
1944 Some(&root),
1945 "count",
1946 Some(&format!("run-{i}")),
1947 Duration::from_secs(1),
1948 )
1949 .unwrap();
1950 runs.push(run);
1951 }
1952 for (i, run) in runs.iter().enumerate() {
1953 finish(run).unwrap();
1954 age_at(run, 100 + i as u128);
1955 }
1956 let report = prune(&root, "count", Some(1024 * 1024)).unwrap();
1957 assert_eq!(report.removed.len(), 2);
1958 assert_eq!(report.over_cap_bytes, 0);
1959 assert!(root.join("runs/count/run-5").exists());
1960 assert!(root.join("runs/count/run-6").exists());
1961 let _ = fs::remove_dir_all(root);
1962 }
1963
1964 #[test]
1965 fn young_run_is_protected_when_cap_requires_old_eviction() {
1966 let root = temp_root("young");
1967 let old = create(Some(&root), "young", Some("old"), Duration::from_secs(1)).unwrap();
1968 let young = create(Some(&root), "young", Some("young"), Duration::from_secs(1)).unwrap();
1969 finish(&old).unwrap();
1970 finish(&young).unwrap();
1971 age(&old);
1972 let report = prune(&root, "young", Some(0)).unwrap();
1973 assert!(report.removed.iter().any(|p| p.ends_with("old")));
1974 assert!(root.join("runs/young/young").exists());
1975 assert!(report.over_cap_bytes > 0);
1976 let _ = fs::remove_dir_all(root);
1977 }
1978
1979 #[test]
1980 fn live_pid_and_future_lease_are_protected() {
1981 let root = temp_root("active");
1982 let live = create(Some(&root), "active", Some("live"), Duration::from_secs(1)).unwrap();
1983 let lease = create(
1984 Some(&root),
1985 "active",
1986 Some("lease"),
1987 Duration::from_secs(3600),
1988 )
1989 .unwrap();
1990 let old = create(Some(&root), "active", Some("old"), Duration::from_secs(1)).unwrap();
1991 finish(&lease).unwrap();
1992 let mut lease_marker = read_marker(&lease.root.join(RUN_MARKER)).unwrap();
1993 lease_marker.created_at = 1;
1994 lease_marker.lease_until = now_ms() + 3600 * 1000;
1995 write_marker(&lease.root, &lease_marker).unwrap();
1996 let mut live_marker = read_marker(&live.root.join(RUN_MARKER)).unwrap();
1997 live_marker.created_at = 1;
1998 write_marker(&live.root, &live_marker).unwrap();
1999 finish(&old).unwrap();
2000 age(&old);
2001 let report = prune(&root, "active", Some(0)).unwrap();
2002 assert!(report.removed.iter().any(|p| p.ends_with("old")));
2003 assert!(root.join("runs/active/live").exists());
2004 assert!(root.join("runs/active/lease").exists());
2005 let _ = fs::remove_dir_all(root);
2006 }
2007
2008 #[test]
2009 fn malformed_and_symlink_roots_are_retained_and_reported() {
2010 let root = temp_root("unknown");
2011 let app_root = prepare_app_root(&root, "unknown").unwrap();
2012 let malformed = app_root.join("bad");
2013 fs::create_dir_all(&malformed).unwrap();
2014 fs::write(malformed.join("payload"), b"unknown").unwrap();
2015 #[cfg(unix)]
2016 std::os::unix::fs::symlink(&malformed, app_root.join("link")).unwrap();
2017 let report = prune(&root, "unknown", Some(0)).unwrap();
2018 assert!(report.removed.is_empty());
2019 assert!(report.unknown_bytes >= 7);
2020 assert!(report.retained.iter().any(|(p, _)| p.ends_with("bad")));
2021 #[cfg(unix)]
2022 assert!(report
2023 .retained
2024 .iter()
2025 .any(|(p, reason)| p.ends_with("link") && reason.contains("symlink")));
2026 let _ = fs::remove_dir_all(root);
2027 }
2028
2029 #[test]
2030 fn caller_keep_id_blocks_eviction_and_reports_overage() {
2031 let root = temp_root("keep");
2032 let keep = create(Some(&root), "keep", Some("keep-me"), Duration::from_secs(1)).unwrap();
2033 let remove = create(
2034 Some(&root),
2035 "keep",
2036 Some("remove-me"),
2037 Duration::from_secs(1),
2038 )
2039 .unwrap();
2040 finish(&keep).unwrap();
2041 finish(&remove).unwrap();
2042 age(&keep);
2043 age(&remove);
2044 let report = prune_with_keep(&root, "keep", Some(0), &["keep-me".into()]).unwrap();
2045 assert!(report.removed.iter().any(|p| p.ends_with("remove-me")));
2046 assert!(root.join("runs/keep/keep-me").exists());
2047 assert!(report.over_cap_bytes > 0);
2048 let _ = fs::remove_dir_all(root);
2049 }
2050
2051 #[test]
2052 fn create_refuses_when_protected_data_exceeds_cap() {
2053 let root = temp_root("blocked");
2054 let run = create(
2055 Some(&root),
2056 "blocked",
2057 Some("live"),
2058 Duration::from_secs(3600),
2059 )
2060 .unwrap();
2061 let mut file = File::create(run.root.join("large")).unwrap();
2062 file.set_len(DEFAULT_MAX_BYTES + 1).unwrap();
2063 file.flush().unwrap();
2064 let result = create(Some(&root), "blocked", Some("next"), Duration::from_secs(1));
2065 assert!(result.is_err());
2066 assert!(!root.join("runs/blocked/next").exists());
2067 let _ = fs::remove_dir_all(root);
2068 }
2069
2070 fn dead_pid() -> u32 {
2073 let mut child = std::process::Command::new(if cfg!(windows) { "cmd" } else { "true" })
2074 .args(if cfg!(windows) {
2075 vec!["/C", "exit"]
2076 } else {
2077 vec![]
2078 })
2079 .spawn()
2080 .unwrap();
2081 let pid = child.id();
2082 child.wait().unwrap();
2083 pid
2084 }
2085
2086 #[test]
2087 fn a_starter_does_not_wait_for_or_fail_on_a_held_prune_lock() {
2088 let root = temp_root("lock-held");
2089 let first = create(Some(&root), "app", Some("one"), Duration::from_secs(60)).unwrap();
2090 let app_root = first.root.parent().unwrap().to_path_buf();
2091 let guard = try_lock(&app_root, Duration::ZERO)
2092 .unwrap()
2093 .expect("lock is free");
2094 let started = std::time::Instant::now();
2095 let second = create(Some(&root), "app", Some("two"), Duration::from_secs(60)).unwrap();
2096 assert!(
2097 started.elapsed() < Duration::from_secs(2),
2098 "starter waited on the lock"
2099 );
2100 assert!(second.root.join(RUN_MARKER).exists());
2101 drop(guard);
2102 let _ = fs::remove_dir_all(&root);
2103 }
2104
2105 #[test]
2106 fn parallel_starters_in_one_process_all_proceed() {
2107 let root = temp_root("lock-threads");
2108 let handles: Vec<_> = (0..8)
2109 .map(|i| {
2110 let root = root.clone();
2111 std::thread::spawn(move || {
2112 create(
2113 Some(&root),
2114 "app",
2115 Some(&format!("run{i}")),
2116 Duration::from_secs(60),
2117 )
2118 .map(|r| r.root)
2119 })
2120 })
2121 .collect();
2122 for h in handles {
2123 let run = h.join().unwrap().expect("every starter proceeds");
2124 assert!(run.join(RUN_MARKER).exists());
2125 }
2126 let _ = fs::remove_dir_all(&root);
2127 }
2128
2129 #[cfg(windows)]
2133 #[test]
2134 fn a_lock_directory_that_is_being_removed_counts_as_held() {
2135 use std::os::windows::fs::OpenOptionsExt;
2136 use std::os::windows::io::AsRawHandle;
2137
2138 #[link(name = "kernel32")]
2139 unsafe extern "system" {
2140 fn SetFileInformationByHandle(
2141 file: *mut core::ffi::c_void,
2142 class: u32,
2143 info: *const core::ffi::c_void,
2144 size: u32,
2145 ) -> i32;
2146 }
2147 const DELETE: u32 = 0x0001_0000;
2148 const FILE_READ_ATTRIBUTES: u32 = 0x80;
2149 const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
2150 const SHARE_ALL: u32 = 7;
2151 const FILE_DISPOSITION_INFO: u32 = 4;
2152
2153 let app_root = temp_root("lock-pending");
2154 let lock = app_root.join(".retention-lock");
2155 fs::create_dir(&lock).unwrap();
2156 let open = |access: u32| {
2157 OpenOptions::new()
2158 .access_mode(access)
2159 .share_mode(SHARE_ALL)
2160 .custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
2161 .open(&lock)
2162 .unwrap()
2163 };
2164 let reader = open(FILE_READ_ATTRIBUTES);
2165 let remover = open(DELETE);
2166 let delete: u8 = 1;
2167 let marked = unsafe {
2170 SetFileInformationByHandle(
2171 remover.as_raw_handle().cast(),
2172 FILE_DISPOSITION_INFO,
2173 (&raw const delete).cast(),
2174 1,
2175 )
2176 };
2177 assert_ne!(marked, 0, "{}", std::io::Error::last_os_error());
2178 drop(remover);
2179
2180 let refused = fs::create_dir(&lock).unwrap_err();
2182 assert_eq!(
2183 refused.kind(),
2184 std::io::ErrorKind::PermissionDenied,
2185 "{refused}"
2186 );
2187 assert!(try_lock(&app_root, Duration::ZERO).unwrap().is_none());
2188 assert!(try_lock(&app_root, Duration::from_millis(100))
2189 .unwrap()
2190 .is_none());
2191
2192 drop(reader);
2194 let guard = try_lock(&app_root, Duration::from_secs(5)).unwrap();
2195 assert!(guard.is_some());
2196 drop(guard);
2197 let _ = fs::remove_dir_all(&app_root);
2198 }
2199
2200 #[test]
2202 fn child_starter_for_the_two_process_test() {
2203 let (Some(root), Some(id)) = (
2204 std::env::var_os("RKQA_TEST_LOCK_ROOT"),
2205 std::env::var_os("RKQA_TEST_LOCK_ID"),
2206 ) else {
2207 return;
2208 };
2209 let started = std::time::Instant::now();
2210 create(
2211 Some(Path::new(&root)),
2212 "app",
2213 id.to_str(),
2214 Duration::from_secs(60),
2215 )
2216 .unwrap();
2217 assert!(started.elapsed() < Duration::from_secs(20));
2218 }
2219
2220 #[test]
2221 fn parallel_starters_in_two_processes_all_proceed() {
2222 let root = temp_root("lock-procs");
2223 let exe = std::env::current_exe().unwrap();
2224 let spawned: Vec<_> = (0..3)
2225 .map(|i| {
2226 std::process::Command::new(&exe)
2227 .args([
2228 "--exact",
2229 "storage::tests::child_starter_for_the_two_process_test",
2230 "--nocapture",
2231 ])
2232 .env("RKQA_TEST_LOCK_ROOT", &root)
2233 .env("RKQA_TEST_LOCK_ID", format!("par{i}"))
2234 .stdout(std::process::Stdio::piped())
2235 .stderr(std::process::Stdio::piped())
2236 .spawn()
2237 .unwrap()
2238 })
2239 .collect();
2240 for child in spawned {
2241 let out = child.wait_with_output().unwrap();
2242 assert!(
2243 out.status.success(),
2244 "{}",
2245 String::from_utf8_lossy(&out.stdout)
2246 );
2247 }
2248 for i in 0..3 {
2249 assert!(root
2250 .join("runs/app")
2251 .join(format!("par{i}"))
2252 .join(RUN_MARKER)
2253 .exists());
2254 }
2255 let _ = fs::remove_dir_all(&root);
2256 }
2257
2258 #[test]
2259 fn a_lock_held_by_a_dead_process_is_recovered() {
2260 let root = temp_root("lock-dead");
2261 let app_root = root.join("runs/app");
2262 fs::create_dir_all(app_root.join(".retention-lock")).unwrap();
2263 fs::write(
2264 app_root.join(".retention-lock/owner.json"),
2265 format!("{{\"pid\":{},\"createdAt\":1}}", dead_pid()),
2266 )
2267 .unwrap();
2268 assert!(try_lock(&app_root, Duration::ZERO).unwrap().is_some());
2269 let _ = fs::remove_dir_all(&root);
2270 }
2271
2272 #[test]
2273 fn an_ownerless_lock_is_recovered_only_after_the_grace_period() {
2274 let root = temp_root("lock-ownerless");
2275 let app_root = root.join("runs/app");
2276 let lock = app_root.join(".retention-lock");
2277 fs::create_dir_all(&lock).unwrap();
2278 assert!(
2279 try_lock(&app_root, Duration::ZERO).unwrap().is_none(),
2280 "fresh: owner may be mid-write"
2281 );
2282 let old = std::time::SystemTime::now() - Duration::from_secs(120);
2283 set_modified(&lock, old);
2284 assert!(try_lock(&app_root, Duration::ZERO).unwrap().is_some());
2285 let _ = fs::remove_dir_all(&root);
2286 }
2287
2288 #[test]
2289 fn an_explicit_prune_that_cannot_get_the_lock_fails_with_the_typed_error() {
2290 let root = temp_root("lock-typed");
2291 let app_root = root.join("runs/app");
2292 fs::create_dir_all(&app_root).unwrap();
2293 let _guard = try_lock(&app_root, Duration::ZERO).unwrap().unwrap();
2294 let error = lock_or_wait(&app_root, Duration::from_millis(60))
2295 .err()
2296 .expect("held");
2297 assert!(is_lock_held(&error), "{error}");
2298 let _ = fs::remove_dir_all(&root);
2299 }
2300
2301 #[cfg(unix)]
2304 #[test]
2305 fn a_symlink_above_the_root_is_resolved_and_a_link_below_it_is_refused() {
2306 let base = fs::canonicalize(temp_root("symlink-guard")).unwrap();
2307 let real = base.join("real");
2308 fs::create_dir_all(&real).unwrap();
2309 let alias = base.join("alias");
2310 std::os::unix::fs::symlink(&real, &alias).unwrap();
2311
2312 let run = create(
2314 Some(&alias.join("managed")),
2315 "app",
2316 Some("alias-run"),
2317 Duration::from_secs(60),
2318 )
2319 .unwrap();
2320 assert!(run.root.starts_with(&real), "{}", run.root.display());
2321
2322 let elsewhere = base.join("elsewhere");
2324 fs::create_dir_all(&elsewhere).unwrap();
2325 let managed = real.join("managed");
2326 std::os::unix::fs::symlink(&elsewhere, managed.join("runs/linked")).unwrap();
2327 let e = ensure_dir(&managed.join("runs/linked/deeper")).unwrap_err();
2328 assert!(e.0.contains("refusing symlink"), "{}", e.0);
2329 let _ = fs::remove_dir_all(&base);
2330 }
2331
2332 fn homes_parent(label: &str) -> (PathBuf, PathBuf) {
2335 let root = temp_root(label);
2336 let parent = fs::canonicalize(&root).unwrap().join("rightkit-qa");
2337 fs::create_dir_all(&parent).unwrap();
2338 (root, parent)
2339 }
2340
2341 fn home_for(root: &Path, parent: &Path, run_id: &str, payload: usize) -> (ManagedRun, PathBuf) {
2342 home_of(root, parent, "app", run_id, payload)
2343 }
2344
2345 fn home_of(
2347 root: &Path,
2348 parent: &Path,
2349 app: &str,
2350 run_id: &str,
2351 payload: usize,
2352 ) -> (ManagedRun, PathBuf) {
2353 let mut run = create(Some(root), app, Some(run_id), Duration::from_secs(60)).unwrap();
2354 let home = create_app_root_in(&mut run, parent).unwrap();
2355 set_home_marker(&home, 0, now_ms());
2357 fs::write(home.join("payload.bin"), vec![0u8; payload]).unwrap();
2358 (run, home)
2359 }
2360
2361 fn unmarked_home(
2364 root: &Path,
2365 parent: &Path,
2366 app: &str,
2367 run_id: &str,
2368 payload: usize,
2369 ) -> (ManagedRun, PathBuf) {
2370 let (run, home) = home_of(root, parent, app, run_id, payload);
2371 fs::remove_file(home.join(HOME_MARKER)).unwrap();
2372 (run, home)
2373 }
2374
2375 fn record_owner(run: &ManagedRun, pid: u32, lease_until: u128) {
2377 let mut marker = read_marker(&run.root.join(RUN_MARKER)).unwrap();
2378 marker.owner_pid = pid;
2379 marker.lease_until = lease_until;
2380 write_marker(&run.root, &marker).unwrap();
2381 }
2382
2383 fn age_tree(dir: &Path, age: Duration) {
2386 let time = std::time::SystemTime::now() - age;
2387 let mut all = vec![dir.to_path_buf()];
2388 let mut stack = vec![dir.to_path_buf()];
2389 while let Some(d) = stack.pop() {
2390 for entry in fs::read_dir(&d).unwrap().flatten() {
2391 let ft = entry.file_type().unwrap();
2392 if ft.is_symlink() {
2393 continue;
2394 }
2395 if ft.is_dir() {
2396 stack.push(entry.path());
2397 }
2398 all.push(entry.path());
2399 }
2400 }
2401 for path in all {
2402 set_modified(&path, time);
2403 }
2404 }
2405
2406 fn set_home_marker(home: &Path, pid: u32, started_at_ms: u128) {
2407 fs::write(
2408 home.join(HOME_MARKER),
2409 serde_json::to_vec(&serde_json::json!({
2410 "schemaVersion": 1, "pid": pid, "startedAtMs": started_at_ms as u64,
2411 "app": "app", "runId": "x",
2412 }))
2413 .unwrap(),
2414 )
2415 .unwrap();
2416 }
2417
2418 fn make_finished(run: &ManagedRun, completed_at: u128) {
2419 let mut marker = read_marker(&run.root.join(RUN_MARKER)).unwrap();
2420 marker.owner_pid = 0;
2421 marker.lease_until = 0;
2422 marker.completed_at = Some(completed_at);
2423 write_marker(&run.root, &marker).unwrap();
2424 }
2425
2426 fn make_old(path: &Path) {
2427 let old = std::time::SystemTime::now() - Duration::from_secs(3 * 24 * 3600);
2428 set_modified(path, old);
2429 }
2430
2431 #[test]
2432 fn sweep_removes_old_orphans_and_finished_homes_and_spares_the_rest() {
2433 let (root, parent) = homes_parent("sweep");
2434 let day = Duration::from_secs(24 * 3600);
2435 let policy = HomePolicy {
2436 young: day,
2437 max_bytes: u64::MAX,
2438 };
2439
2440 let (orphan_run, orphan_old) = home_for(&root, &parent, "orphan-old", 1000);
2442 fs::remove_dir_all(&orphan_run.root).unwrap();
2443 make_old(&orphan_old.join(".rightkit-app.json"));
2444 make_old(&orphan_old);
2445 let (orphan_run2, orphan_young) = home_for(&root, &parent, "orphan-young", 1000);
2447 fs::remove_dir_all(&orphan_run2.root).unwrap();
2448 let (done_run, done_old) = home_for(&root, &parent, "done-old", 1000);
2450 make_finished(&done_run, 1);
2451 let (done_run2, done_young) = home_for(&root, &parent, "done-young", 1000);
2453 make_finished(&done_run2, now_ms());
2454 let (_active_run, active) = home_for(&root, &parent, "active", 1000);
2456 make_old(&active);
2457 let stranger = parent.join("stranger-0000000000000000");
2459 fs::create_dir_all(&stranger).unwrap();
2460 fs::write(stranger.join("x"), b"x").unwrap();
2461
2462 let dry = sweep_homes(&parent, policy, true);
2463 assert_eq!(dry.removed.len(), 2, "{dry:?}");
2464 assert!(
2465 orphan_old.exists() && done_old.exists(),
2466 "dry run deletes nothing"
2467 );
2468
2469 let report = sweep_homes(&parent, policy, false);
2470 assert_eq!(report.removed.len(), 2, "{report:?}");
2471 assert!(!orphan_old.exists() && !done_old.exists());
2472 for kept in [&orphan_young, &done_young, &active, &stranger] {
2473 assert!(kept.exists(), "{} must survive", kept.display());
2474 }
2475 assert!(report.removed_bytes >= 2000);
2476 let _ = fs::remove_dir_all(&root);
2477 }
2478
2479 #[test]
2480 fn sweep_enforces_the_size_bound_on_finished_homes_oldest_first() {
2481 let (root, parent) = homes_parent("sweep-size");
2482 let policy = HomePolicy {
2483 young: Duration::from_secs(24 * 3600),
2484 max_bytes: 5_000_000,
2485 };
2486 let (r1, h1) = home_for(&root, &parent, "a", 3_000_000);
2487 let (r2, h2) = home_for(&root, &parent, "b", 3_000_000);
2488 let (r3, h3) = home_for(&root, &parent, "c", 3_000_000);
2489 let now = now_ms();
2490 make_finished(&r1, now - 3000);
2491 make_finished(&r2, now - 2000);
2492 make_finished(&r3, now - 1000);
2493 let report = sweep_homes(&parent, policy, false);
2494 assert!(!h1.exists() && !h2.exists(), "{report:?}");
2495 assert!(h3.exists(), "the newest finished home fits the bound");
2496 let _ = fs::remove_dir_all(&root);
2497 }
2498
2499 #[test]
2500 fn sweep_keeps_finished_homes_when_the_size_bound_cannot_be_met_by_removing_them() {
2501 let (root, parent) = homes_parent("sweep-unmet");
2502 let policy = HomePolicy {
2503 young: Duration::from_secs(24 * 3600),
2504 max_bytes: 5_000_000,
2505 };
2506 let (_live, live_home) = home_for(&root, &parent, "live", 6_000_000);
2507 let (done, done_home) = home_for(&root, &parent, "done", 1_000_000);
2508 make_finished(&done, now_ms());
2509 let report = sweep_homes(&parent, policy, false);
2510 assert!(report.removed.is_empty(), "{report:?}");
2511 assert!(live_home.exists() && done_home.exists());
2512 let _ = fs::remove_dir_all(&root);
2513 }
2514
2515 #[test]
2516 fn sweep_refuses_a_parent_that_is_not_the_homes_directory() {
2517 let root = temp_root("sweep-parent");
2518 let victim = root.join("elsewhere");
2519 fs::create_dir_all(victim.join("h")).unwrap();
2520 let report = sweep_homes(&victim, HomePolicy::default(), false);
2521 assert!(report.removed.is_empty());
2522 assert!(victim.join("h").exists());
2523 let _ = fs::remove_dir_all(&root);
2524 }
2525
2526 #[test]
2527 fn home_root_override_must_be_absolute() {
2528 use std::ffi::OsStr;
2529 let absolute = if cfg!(windows) {
2531 r"D:\qa-homes"
2532 } else {
2533 "/Volumes/D/qa-homes"
2534 };
2535 assert_eq!(
2536 home_base_from(Some(OsStr::new(absolute))),
2537 PathBuf::from(absolute)
2538 );
2539 assert_eq!(
2540 home_base_from(Some(OsStr::new("relative/dir"))),
2541 app_temp_dir()
2542 );
2543 assert_eq!(home_base_from(Some(OsStr::new(""))), app_temp_dir());
2544 assert_eq!(home_base_from(None), app_temp_dir());
2545 }
2546
2547 #[test]
2550 fn a_created_home_records_its_owner_and_start() {
2551 let (root, parent) = homes_parent("home-marker");
2552 let (_run, home) = home_for(&root, &parent, "m", 10);
2553 let mut run = create(Some(&root), "app", Some("m2"), Duration::from_secs(60)).unwrap();
2555 let fresh = create_app_root_in(&mut run, &parent).unwrap();
2556 let (pid, started) = read_home_marker(&fresh).unwrap();
2557 assert_eq!(pid, std::process::id());
2558 assert!(now_ms().saturating_sub(started) < 60_000);
2559 assert!(home.exists());
2560 let _ = fs::remove_dir_all(&root);
2561 }
2562
2563 #[test]
2564 fn clean_removes_dead_old_homes_and_never_a_live_one() {
2565 let (root, parent) = homes_parent("clean");
2566 let window = Duration::from_secs(2 * 3600);
2567 let old = now_ms() - 3 * 3600 * 1000;
2568 let (_r1, dead_old) = home_for(&root, &parent, "dead-old", 100);
2569 set_home_marker(&dead_old, 0, old);
2570 let (_r2, dead_young) = home_for(&root, &parent, "dead-young", 100);
2571 set_home_marker(&dead_young, 0, now_ms());
2572 let (_r3, alive_old) = home_for(&root, &parent, "alive-old", 100);
2573 set_home_marker(&alive_old, std::process::id(), old);
2574 let stranger = parent.join("stranger-0000000000000000");
2575 fs::create_dir_all(&stranger).unwrap();
2576 fs::write(stranger.join("x"), b"x").unwrap();
2577
2578 let dry = clean_homes(&parent, window, true);
2579 assert_eq!(dry.removed.len(), 1, "{dry:?}");
2580 assert!(dead_old.exists(), "dry run deletes nothing");
2581 let report = clean_homes(&parent, window, false);
2582 assert_eq!(report.removed.len(), 1, "{report:?}");
2583 assert!(!dead_old.exists());
2584 for kept in [&dead_young, &alive_old, &stranger] {
2585 assert!(kept.exists(), "{} must survive", kept.display());
2586 }
2587 let _ = fs::remove_dir_all(&root);
2588 }
2589
2590 #[test]
2591 fn clean_refuses_a_parent_that_is_not_the_qa_root() {
2592 let root = temp_root("clean-parent");
2593 let victim = root.join("elsewhere");
2594 fs::create_dir_all(victim.join("h")).unwrap();
2595 assert!(clean_homes(&victim, Duration::ZERO, false)
2596 .removed
2597 .is_empty());
2598 assert!(victim.join("h").exists());
2599 let _ = fs::remove_dir_all(&root);
2600 }
2601
2602 #[cfg(unix)]
2603 #[test]
2604 fn clean_never_follows_a_symlink_out_of_the_root() {
2605 let (root, parent) = homes_parent("clean-symlink");
2606 let outside = root.join("outside");
2607 fs::create_dir_all(&outside).unwrap();
2608 fs::write(outside.join("precious"), b"keep").unwrap();
2609 let (_r, doomed) = home_for(&root, &parent, "doomed", 10);
2611 set_home_marker(&doomed, 0, 1);
2612 std::os::unix::fs::symlink(&outside, doomed.join("link-out")).unwrap();
2613 std::os::unix::fs::symlink(&outside, parent.join("link-home")).unwrap();
2615 let report = clean_homes(&parent, Duration::ZERO, false);
2616 assert!(!doomed.exists(), "{report:?}");
2617 assert_eq!(fs::read(outside.join("precious")).unwrap(), b"keep");
2618 assert!(fs::symlink_metadata(parent.join("link-home")).is_ok());
2619 let canonical_parent = fs::canonicalize(&parent).unwrap();
2621 assert!(remove_home_dir(&parent.join("link-home"), &canonical_parent).is_err());
2622 assert!(remove_home_dir(&outside, &canonical_parent).is_err());
2623 assert!(outside.join("precious").exists());
2624 let _ = fs::remove_dir_all(&root);
2625 }
2626
2627 #[test]
2630 fn another_products_homes_never_block_a_run() {
2631 let (root, parent) = homes_parent("cap-other");
2632 for i in 0..3 {
2633 home_of(&root, &parent, "genright", &format!("g{i}"), 1_000_000);
2634 }
2635 home_of(&root, &parent, "cutright", "c0", 1_000_000);
2636 let (_p, post) = home_of(&root, &parent, "postright", "p0", 100_000);
2637 check_root_cap(&parent, "postright", 2_000_000).unwrap();
2639 check_root_limits(&parent, "postright", 2_000_000, 0).unwrap();
2640 assert!(post.exists());
2641 let _ = fs::remove_dir_all(&root);
2642 }
2643
2644 #[test]
2645 fn own_product_over_the_cap_refuses_and_names_own_homes() {
2646 let (root, parent) = homes_parent("cap-own");
2647 let (_a, big) = home_of(&root, &parent, "genright", "g-big", 3_000_000);
2648 let (_b, small) = home_of(&root, &parent, "genright", "g-small", 1_000_000);
2649 let (_c, other) = home_of(&root, &parent, "cutright", "c0", 9_000_000);
2650 check_root_cap(&parent, "genright", 10_000_000).unwrap();
2651 check_root_cap(&parent, "genright", 0).unwrap();
2652 let e = check_root_cap(&parent, "genright", 2_000_000).unwrap_err();
2653 assert_eq!(e.kind, RootLimitKind::ProductCap);
2654 assert_eq!(e.app, "genright");
2655 assert!(
2656 e.total_bytes >= 4_000_000 && e.total_bytes < 5_000_000,
2657 "{}",
2658 e.total_bytes
2659 );
2660 assert_eq!(e.home_count, 2);
2661 assert_eq!(e.limit_bytes, 2_000_000);
2662 assert_eq!(e.largest.len(), 2);
2663 assert_eq!(e.largest[0].0, big);
2664 assert!(e
2665 .largest
2666 .iter()
2667 .any(|(p, _, product)| p == &small && product == "genright"));
2668 assert!(e.largest.iter().all(|(p, _, _)| p != &other));
2669 let text = crate::util::Error::from(e).to_string();
2670 assert!(
2671 text.contains("genright")
2672 && text.contains(&big.display().to_string())
2673 && text.contains(ROOT_CAP_ENV)
2674 && text.contains("rightkit-qa clean"),
2675 "{text}"
2676 );
2677 assert!(!text.contains(&other.display().to_string()), "{text}");
2678 let _ = fs::remove_dir_all(&root);
2679 }
2680
2681 #[test]
2682 fn a_home_without_a_binding_counts_for_the_product_in_its_name() {
2683 let (root, parent) = homes_parent("cap-unbound");
2684 let unbound = parent.join("cutright-0123456789abcdef");
2685 fs::create_dir_all(&unbound).unwrap();
2686 fs::write(unbound.join("blob"), vec![0u8; 3_000_000]).unwrap();
2687 check_root_cap(&parent, "genright", 1_000_000).unwrap();
2688 let e = check_root_cap(&parent, "cutright", 1_000_000).unwrap_err();
2689 assert_eq!(e.largest[0].0, unbound);
2690 assert_eq!(e.largest[0].2, "cutright");
2691 let _ = fs::remove_dir_all(&root);
2692 }
2693
2694 #[test]
2695 fn root_ceiling_names_the_largest_homes_with_their_product() {
2696 let (root, parent) = homes_parent("ceiling");
2697 let (_a, g) = home_of(&root, &parent, "genright", "g0", 3_000_000);
2698 let (_b, c) = home_of(&root, &parent, "cutright", "c0", 2_000_000);
2699 home_of(&root, &parent, "viewright", "v0", 1_000_000);
2700 check_root_limits(&parent, "viewright", 0, 10_000_000).unwrap();
2702 check_root_limits(&parent, "viewright", 0, 0).unwrap();
2703 let e = check_root_limits(&parent, "viewright", 0, 5_000_000).unwrap_err();
2704 assert_eq!(e.kind, RootLimitKind::RootCeiling);
2705 assert!(e.total_bytes >= 6_000_000 && e.limit_bytes == 5_000_000);
2706 assert_eq!(e.home_count, 3);
2707 assert_eq!(e.largest[0].0, g);
2708 assert_eq!(e.largest[0].2, "genright");
2709 assert_eq!(e.largest[1].0, c);
2710 assert_eq!(e.largest[1].2, "cutright");
2711 let text = crate::util::Error::from(e).to_string();
2712 assert!(
2713 text.contains("genright")
2714 && text.contains("cutright")
2715 && text.contains(ROOT_CEILING_ENV)
2716 && text.contains("rightkit-qa clean"),
2717 "{text}"
2718 );
2719 let e = check_root_limits(&parent, "genright", 2_000_000, 5_000_000).unwrap_err();
2721 assert_eq!(e.kind, RootLimitKind::ProductCap);
2722 let _ = fs::remove_dir_all(&root);
2723 }
2724
2725 #[test]
2726 fn limits_set_in_code_win_over_the_environment_and_the_environment_over_the_default() {
2727 let (root, parent) = homes_parent("limits-precedence");
2728 home_of(&root, &parent, "genright", "g0", 3_000_000);
2729 let tiny_env = |key: &str| match key {
2730 ROOT_CAP_ENV | ROOT_CEILING_ENV => Some("1".to_string()),
2731 _ => None,
2732 };
2733 let disabled_env = |_: &str| Some("0".to_string());
2734 let empty_env = |_: &str| None::<String>;
2735
2736 assert!(
2738 enforce_limits_with(&parent, "genright", RootLimits::default(), &tiny_env).is_err()
2739 );
2740 let roomy = RootLimits::default()
2742 .with_cap_bytes(10_000_000)
2743 .with_ceiling_bytes(10_000_000);
2744 enforce_limits_with(&parent, "genright", roomy, &tiny_env).unwrap();
2745 let off = RootLimits::default()
2746 .with_cap_bytes(0)
2747 .with_ceiling_bytes(0);
2748 enforce_limits_with(&parent, "genright", off, &tiny_env).unwrap();
2749 let tight = RootLimits::default().with_cap_bytes(1_000_000);
2750 let e = enforce_limits_with(&parent, "genright", tight, &disabled_env).unwrap_err();
2751 assert_eq!(e.kind, RootLimitKind::ProductCap);
2752 let cap_only = RootLimits::default().with_cap_bytes(0);
2754 let e = enforce_limits_with(&parent, "genright", cap_only, &tiny_env).unwrap_err();
2755 assert_eq!(e.kind, RootLimitKind::RootCeiling);
2756 let defaults = ResolvedRootLimits {
2758 cap_bytes: DEFAULT_ROOT_CAP_BYTES,
2759 ceiling_bytes: DEFAULT_ROOT_CEILING_BYTES,
2760 };
2761 assert_eq!(RootLimits::default().resolve_with(&empty_env), defaults);
2762 let junk_env = |_: &str| Some("lots".to_string());
2763 assert_eq!(RootLimits::default().resolve_with(&junk_env), defaults);
2764 assert_eq!(DEFAULT_ROOT_CAP_BYTES, 4_000_000_000);
2765 assert_eq!(DEFAULT_ROOT_CEILING_BYTES, 32_000_000_000);
2766 let _ = fs::remove_dir_all(&root);
2767 }
2768
2769 #[test]
2772 fn the_home_name_pattern_matches_what_the_runners_write() {
2773 assert_eq!(
2774 parse_home_name("genright-0123456789abcdef"),
2775 Some("genright")
2776 );
2777 assert_eq!(
2778 parse_home_name("scrape-right-0123456789abcdef"),
2779 Some("scrape-right")
2780 );
2781 assert_eq!(parse_home_name("viewright-aB3dE9"), Some("viewright"));
2783 for bad in [
2784 "stranger",
2785 "precious-data",
2786 "x-0123456789ABCDEF",
2787 "x-0123456789abcde",
2788 "-0123456789abcdef",
2789 "a-b-c",
2790 ".hidden-0123456789abcdef",
2791 "waytoolonglabelname-0123456789abcdef",
2792 "my dir-0123456789abcdef",
2793 ] {
2794 assert_eq!(parse_home_name(bad), None, "{bad}");
2795 }
2796 let (root, parent) = homes_parent("name-pattern");
2798 let key = "longappnamewithmorethan16chars";
2799 let mut run = create(Some(&root), key, Some("n"), Duration::from_secs(60)).unwrap();
2800 let home = create_app_root_in(&mut run, &parent).unwrap();
2801 let name = home.file_name().unwrap().to_string_lossy().into_owned();
2802 assert_eq!(parse_home_name(&name), Some("longappnamewithm"));
2803 assert!(same_product(key, "longappnamewithm"));
2804 assert!(!same_product("genright", "cutright"));
2805 let _ = fs::remove_dir_all(&root);
2806 }
2807
2808 #[test]
2809 fn a_homes_product_comes_from_its_binding_then_its_name() {
2810 let (root, parent) = homes_parent("product");
2811 let (_r, bound) = home_of(&root, &parent, "genright", "g0", 1);
2812 let name = bound.file_name().unwrap().to_string_lossy().into_owned();
2813 let binding = read_binding(&bound);
2814 assert_eq!(home_product(&name, binding.as_ref()), "genright");
2815 assert_eq!(
2817 home_product("cutright-0123456789abcdef", binding.as_ref()),
2818 "genright"
2819 );
2820 assert_eq!(home_product("cutright-0123456789abcdef", None), "cutright");
2822 assert_eq!(home_product("stranger", None), "stranger");
2823 fs::write(bound.join(BINDING_FILE), b"[1]").unwrap();
2825 assert!(read_binding(&bound).is_none());
2826 let _ = fs::remove_dir_all(&root);
2827 }
2828
2829 const WINDOW: Duration = Duration::from_secs(2 * 3600);
2832 const THREE_HOURS: Duration = Duration::from_secs(3 * 3600);
2833
2834 #[test]
2835 fn a_dead_unmarked_home_is_swept_and_the_sweep_unblocks_its_product() {
2836 let (root, parent) = homes_parent("unmarked-dead");
2837 let (run, with_record) = unmarked_home(&root, &parent, "genright", "rec", 1_000_000);
2840 record_owner(&run, dead_pid(), now_ms() + 3600 * 1000);
2841 let (gone, no_record) = unmarked_home(&root, &parent, "genright", "gone", 1_000_000);
2843 fs::remove_dir_all(&gone.root).unwrap();
2844 let (_u, no_binding) = unmarked_home(&root, &parent, "cutright", "nb", 1_000_000);
2846 fs::remove_file(no_binding.join(BINDING_FILE)).unwrap();
2847 for home in [&with_record, &no_record, &no_binding] {
2848 age_tree(home, THREE_HOURS);
2849 }
2850
2851 let dry = clean_homes(&parent, WINDOW, true);
2852 assert_eq!(dry.removed.len(), 3, "{dry:?}");
2853 assert!(
2854 with_record.exists() && no_record.exists() && no_binding.exists(),
2855 "dry run deletes nothing"
2856 );
2857 check_root_cap(&parent, "genright", 1_500_000).unwrap_err();
2859 let report = clean_homes(&parent, WINDOW, false);
2860 assert_eq!(report.removed.len(), 3, "{report:?}");
2861 assert!(report
2862 .removed
2863 .iter()
2864 .all(|(_, _, why)| why.contains("no owner marker")));
2865 assert!(!with_record.exists() && !no_record.exists() && !no_binding.exists());
2866 check_root_cap(&parent, "genright", 1_500_000).unwrap();
2867 let _ = fs::remove_dir_all(&root);
2868 }
2869
2870 #[test]
2871 fn a_young_unmarked_home_is_kept_and_so_is_one_with_a_recently_written_file() {
2872 let (root, parent) = homes_parent("unmarked-young");
2873 let (r1, fresh) = unmarked_home(&root, &parent, "genright", "fresh", 1000);
2875 record_owner(&r1, dead_pid(), 0);
2876 let (r2, busy) = unmarked_home(&root, &parent, "genright", "busy", 1000);
2878 record_owner(&r2, dead_pid(), 0);
2879 fs::create_dir_all(busy.join("data/deep")).unwrap();
2880 fs::write(busy.join("data/deep/app.log"), b"x").unwrap();
2881 age_tree(&busy, THREE_HOURS);
2882 fs::write(busy.join("data/deep/app.log"), b"y").unwrap();
2883
2884 let report = clean_homes(&parent, WINDOW, false);
2885 assert!(report.removed.is_empty(), "{report:?}");
2886 assert!(fresh.exists() && busy.exists());
2887 for kept in [&fresh, &busy] {
2888 assert!(
2889 report
2890 .retained
2891 .iter()
2892 .any(|(p, why)| p == kept && why.contains("modified within the window")),
2893 "{kept:?}: {report:?}"
2894 );
2895 }
2896 let _ = fs::remove_dir_all(&root);
2897 }
2898
2899 #[test]
2900 fn an_unmarked_home_with_a_live_recorded_pid_is_kept() {
2901 let (root, parent) = homes_parent("unmarked-live");
2902 let (run, home) = unmarked_home(&root, &parent, "genright", "live", 1000);
2904 age_tree(&home, THREE_HOURS);
2905 let report = clean_homes(&parent, WINDOW, false);
2906 assert!(home.exists(), "{report:?}");
2907 assert!(
2908 report
2909 .retained
2910 .iter()
2911 .any(|(p, why)| p == &home && why.contains("is alive")),
2912 "{report:?}"
2913 );
2914 record_owner(&run, dead_pid(), now_ms() + 3600 * 1000);
2916 let report = clean_homes(&parent, WINDOW, false);
2917 assert!(!home.exists(), "{report:?}");
2918
2919 let (_r, orphan) = unmarked_home(&root, &parent, "cutright", "marked", 1000);
2921 fs::remove_file(orphan.join(BINDING_FILE)).unwrap();
2922 set_home_marker(&orphan, std::process::id(), 1);
2923 age_tree(&orphan, THREE_HOURS);
2924 clean_homes(&parent, WINDOW, false);
2925 assert!(orphan.exists(), "live pid in the home marker");
2926 set_home_marker(&orphan, dead_pid(), 1);
2927 age_tree(&orphan, THREE_HOURS);
2928 clean_homes(&parent, WINDOW, false);
2929 assert!(!orphan.exists(), "dead pid in the home marker");
2930 let _ = fs::remove_dir_all(&root);
2931 }
2932
2933 #[test]
2934 fn an_unmarked_directory_that_is_not_named_like_a_home_is_kept() {
2935 let (root, parent) = homes_parent("unmarked-foreign");
2936 let names = ["precious-data", "stranger", "x-0123456789ABCDEF"];
2937 for name in names {
2938 let dir = parent.join(name);
2939 fs::create_dir_all(dir.join("sub")).unwrap();
2940 fs::write(dir.join("sub/f"), b"keep").unwrap();
2941 age_tree(&dir, THREE_HOURS);
2942 }
2943 let report = clean_homes(&parent, WINDOW, false);
2944 assert!(report.removed.is_empty(), "{report:?}");
2945 for name in names {
2946 assert!(parent.join(name).join("sub/f").exists(), "{name}");
2947 }
2948 assert!(report
2949 .retained
2950 .iter()
2951 .any(|(p, why)| p.ends_with("precious-data") && why.contains("not a QA home name")));
2952 let _ = fs::remove_dir_all(&root);
2953 }
2954
2955 #[cfg(unix)]
2956 #[test]
2957 fn a_symlinked_entry_named_like_a_home_is_skipped() {
2958 let (root, parent) = homes_parent("unmarked-symlink");
2959 let outside = root.join("outside");
2960 fs::create_dir_all(outside.join("sub")).unwrap();
2961 fs::write(outside.join("sub/precious"), b"keep").unwrap();
2962 age_tree(&outside, THREE_HOURS);
2963 let link = parent.join("genright-0123456789abcdef");
2965 std::os::unix::fs::symlink(&outside, &link).unwrap();
2966 let (run, doomed) = unmarked_home(&root, &parent, "genright", "doomed", 10);
2968 record_owner(&run, dead_pid(), 0);
2969 std::os::unix::fs::symlink(&outside, doomed.join("link-out")).unwrap();
2970 let report = clean_homes(&parent, Duration::ZERO, false);
2972 assert!(!doomed.exists(), "{report:?}");
2973 assert_eq!(fs::read(outside.join("sub/precious")).unwrap(), b"keep");
2974 assert!(fs::symlink_metadata(&link).is_ok());
2975 assert!(report
2976 .retained
2977 .iter()
2978 .any(|(p, why)| p == &link && why.contains("symlink")));
2979 let _ = fs::remove_dir_all(&root);
2980 }
2981
2982 #[cfg(windows)]
2985 #[test]
2986 fn a_home_with_a_junction_that_denies_listing_is_removed() {
2987 let (root, parent) = homes_parent("rm-deny");
2988 let (_r, home) = home_for(&root, &parent, "deny", 10);
2989 let link = test_links::wininet_cache(&home);
2990 let target = link.parent().unwrap().join("IE");
2991 assert!(target.join("container.dat").exists());
2992 remove_home_tree(&parent, &home).unwrap();
2993 assert!(!home.exists());
2994 let _ = fs::remove_dir_all(&root);
2995 }
2996
2997 #[cfg(windows)]
2998 #[test]
2999 fn std_remove_dir_all_stops_at_a_junction_that_denies_listing() {
3000 let (root, parent) = homes_parent("rm-std");
3004 let (_r, home) = home_for(&root, &parent, "std", 10);
3005 test_links::wininet_cache(&home);
3006 let outcome = fs::remove_dir_all(&home);
3007 eprintln!("[qa-home-removal] std::fs::remove_dir_all on the WinINet shape: {outcome:?}");
3008 assert!(outcome.is_err(), "{outcome:?}");
3009 assert!(home.exists());
3010 remove_home_tree(&parent, &home).unwrap();
3012 assert!(!home.exists());
3013 let _ = fs::remove_dir_all(&root);
3014 }
3015
3016 #[cfg(windows)]
3017 #[test]
3018 fn a_junction_out_of_the_home_is_removed_and_what_it_points_at_stays() {
3019 let (root, parent) = homes_parent("rm-models");
3020 let models = root.join("models-cache");
3022 fs::create_dir_all(models.join("whisper")).unwrap();
3023 let weights: Vec<u8> = (0..=255u8).cycle().take(70_000).collect();
3024 fs::write(models.join("whisper").join("model.bin"), &weights).unwrap();
3025 fs::write(models.join("index.json"), b"{\"models\":1}").unwrap();
3026 let (_r, home) = home_for(&root, &parent, "models", 10);
3027 fs::create_dir_all(home.join("data").join("shared")).unwrap();
3028 test_links::junction(&home.join("data").join("shared").join("models"), &models);
3029 assert_eq!(
3030 fs::read(
3031 home.join("data")
3032 .join("shared")
3033 .join("models")
3034 .join("index.json")
3035 )
3036 .unwrap(),
3037 b"{\"models\":1}"
3038 );
3039 remove_home_tree(&parent, &home).unwrap();
3040 assert!(!home.exists());
3041 assert_eq!(
3042 fs::read(models.join("whisper").join("model.bin")).unwrap(),
3043 weights
3044 );
3045 assert_eq!(
3046 fs::read(models.join("index.json")).unwrap(),
3047 b"{\"models\":1}"
3048 );
3049 let (_r, doomed) = home_for(&root, &parent, "models2", 10);
3051 set_home_marker(&doomed, 0, 1);
3052 fs::create_dir_all(doomed.join("data").join("shared")).unwrap();
3053 test_links::junction(&doomed.join("data").join("shared").join("models"), &models);
3054 test_links::wininet_cache(&doomed);
3055 let report = clean_homes(&parent, Duration::ZERO, false);
3056 assert!(!doomed.exists(), "{report:?}");
3057 assert_eq!(
3058 fs::read(models.join("whisper").join("model.bin")).unwrap(),
3059 weights
3060 );
3061 let _ = fs::remove_dir_all(&root);
3062 }
3063
3064 #[cfg(windows)]
3065 #[test]
3066 fn a_read_only_file_in_a_home_is_removed() {
3067 let (root, parent) = homes_parent("rm-readonly");
3068 let (_r, home) = home_for(&root, &parent, "ro", 10);
3069 let file = home.join("locked.txt");
3070 fs::write(&file, b"read only").unwrap();
3071 let mut permissions = fs::metadata(&file).unwrap().permissions();
3072 permissions.set_readonly(true);
3073 fs::set_permissions(&file, permissions).unwrap();
3074 remove_home_tree(&parent, &home).unwrap();
3075 assert!(!home.exists());
3076 let _ = fs::remove_dir_all(&root);
3077 }
3078
3079 #[cfg(unix)]
3080 #[test]
3081 fn a_symlink_out_of_the_home_is_removed_and_what_it_points_at_stays() {
3082 let (root, parent) = homes_parent("rm-symlink");
3083 let outside = root.join("outside");
3084 fs::create_dir_all(outside.join("sub")).unwrap();
3085 fs::write(outside.join("sub").join("precious"), b"keep").unwrap();
3086 fs::write(outside.join("file"), b"keep too").unwrap();
3087 let (_r, home) = home_for(&root, &parent, "links", 10);
3088 fs::create_dir_all(home.join("data")).unwrap();
3089 std::os::unix::fs::symlink(&outside, home.join("data").join("dir-link")).unwrap();
3090 std::os::unix::fs::symlink(outside.join("file"), home.join("file-link")).unwrap();
3091 std::os::unix::fs::symlink(root.join("nowhere"), home.join("dangling")).unwrap();
3092 remove_home_tree(&parent, &home).unwrap();
3093 assert!(!home.exists());
3094 assert_eq!(
3095 fs::read(outside.join("sub").join("precious")).unwrap(),
3096 b"keep"
3097 );
3098 assert_eq!(fs::read(outside.join("file")).unwrap(), b"keep too");
3099 let _ = fs::remove_dir_all(&root);
3100 }
3101
3102 #[test]
3103 fn a_removal_that_stops_names_the_entry_and_keeps_the_binding_file() {
3104 let (root, parent) = homes_parent("rm-held");
3105 let (_r, home) = home_for(&root, &parent, "held", 10);
3106 fs::create_dir_all(home.join("data").join("busy")).unwrap();
3107 let stuck = home.join("data").join("busy").join("held.db");
3108 fs::write(&stuck, b"in use").unwrap();
3109 #[cfg(windows)]
3111 let hold = {
3112 use std::os::windows::fs::OpenOptionsExt;
3113 OpenOptions::new()
3114 .read(true)
3115 .share_mode(0)
3116 .open(&stuck)
3117 .unwrap()
3118 };
3119 #[cfg(unix)]
3120 {
3121 use std::os::unix::fs::PermissionsExt;
3122 fs::set_permissions(
3123 home.join("data").join("busy"),
3124 fs::Permissions::from_mode(0o555),
3125 )
3126 .unwrap();
3127 }
3128 let why = remove_home_tree(&parent, &home).unwrap_err();
3129 assert!(why.contains("held.db"), "{why}");
3130 assert!(home.join(BINDING_FILE).is_file());
3131 assert!(is_owned_home(&home));
3132 #[cfg(windows)]
3133 drop(hold);
3134 #[cfg(unix)]
3135 {
3136 use std::os::unix::fs::PermissionsExt;
3137 fs::set_permissions(
3138 home.join("data").join("busy"),
3139 fs::Permissions::from_mode(0o755),
3140 )
3141 .unwrap();
3142 }
3143 remove_home_tree(&parent, &home).unwrap();
3145 assert!(!home.exists());
3146 let _ = fs::remove_dir_all(&root);
3147 }
3148
3149 #[test]
3150 fn removal_refuses_a_path_that_is_not_strictly_inside_the_qa_root() {
3151 let (root, parent) = homes_parent("rm-outside");
3152 let (_r, home) = home_for(&root, &parent, "inside", 10);
3153 let outside = root.join("outside");
3154 fs::create_dir_all(&outside).unwrap();
3155 fs::write(outside.join("precious"), b"keep").unwrap();
3156 assert!(remove_home_tree(&parent, &outside).is_err());
3158 assert!(remove_home_tree(&parent, &parent).is_err());
3159 assert!(remove_home_tree(&parent, &home.join("..").join("..").join("outside")).is_err());
3160 assert!(remove_home_tree(&root, &outside).is_err());
3161 assert!(remove_home_tree(&parent, &parent.join("missing")).is_err());
3162 assert_eq!(fs::read(outside.join("precious")).unwrap(), b"keep");
3163 assert!(home.join(BINDING_FILE).is_file());
3164 remove_home_tree(&parent, &home).unwrap();
3165 assert!(!home.exists());
3166 let _ = fs::remove_dir_all(&root);
3167 }
3168
3169 #[test]
3170 fn only_a_runner_created_home_counts_as_owned() {
3171 let (root, parent) = homes_parent("owned");
3172 let (_r, home) = home_for(&root, &parent, "h", 1);
3173 assert!(is_owned_home(&home));
3174 let stranger = parent.join("stranger");
3175 fs::create_dir_all(&stranger).unwrap();
3176 assert!(!is_owned_home(&stranger));
3177 assert!(!is_owned_home(&root));
3178 let _ = fs::remove_dir_all(&root);
3179 }
3180}