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