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