Skip to main content

rightkit_qa/
storage.rs

1//! Managed QA run storage: shared marker, retention, and cross-language lock protocol.
2use 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/// What a run is for. A `functional` run checks clicks, state and text and takes no screenshots
15/// and no screen recordings; a `visual` run is the audit-visual run that captures screens.
16#[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    /// `functional` or `visual`; anything else is an error that names both.
32    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/// How a finished run ended, as recorded in its marker. Retention reads only this.
48/// `Skipped` is a run in which nothing executed (every scenario skipped): it holds no evidence,
49/// so retention removes it and it never counts as the newest pass.
50#[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    /// Run kind. Absent in markers written before 0.5.0; retention treats those as `functional`
72    /// but never prunes them, because they carry no recorded outcome either.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub kind: Option<RunKind>,
75    /// Pass or fail, set when the job ends. `None` while running and in pre-0.5.0 markers.
76    #[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    // Workstation default only when its drive exists; hosted CI and other machines
107    // fall back to the runner's temp dir, then the system temp dir.
108    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
140/// Resolves a trusted root once, before [`ensure_dir`] walks it. The root is created if missing
141/// and canonicalised, so an OS-level alias above it is resolved rather than refused: on macOS
142/// `/var` and `/tmp` are symlinks into `/private`, and `std::env::temp_dir()` lives under `/var`.
143/// Components below the returned path are still checked by [`ensure_dir`].
144fn 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    // The retention lock guards only the prune pass. A second starter does not wait for it and does
176    // not fail: another process is already pruning this app, so it skips its own pass and starts.
177    // Parallel scenarios in one binary, or in several processes, all proceed.
178    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    // `create_dir` is the atomic claim on the run id, so no lock is needed to make it unique.
199    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
225/// Rewrite the marker of the run at `run_root` through `change`. Fails when the marker is missing
226/// or malformed; the run directory is never created or guessed.
227pub(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
234/// Record the kind of the run at `run_root` in its marker.
235pub fn set_run_kind(run_root: &Path, kind: RunKind) -> Result<()> {
236    update_marker(run_root, |m| m.kind = Some(kind))
237}
238
239/// Record how the run at `run_root` ended in its marker. [`finish`] keeps it.
240pub 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
273/// Use short internal temp paths on macOS: launchd needs internal storage &
274/// service fallback sockets must fit sockaddr_un even after app temp overrides.
275pub(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
284/// Environment variable that moves QA app homes: homes go under `<value>/rightkit-qa/`.
285/// Must be an absolute path. Unset or empty keeps the system default ([`app_temp_dir`]).
286///
287/// The default is NOT the managed data root: on macOS a launchd-started app cannot open its
288/// live log on an external volume (`open` fails with -10810, see `control::launch`) and the
289/// app's control socket path must fit `sockaddr_un`, so homes stay on internal storage unless the
290/// caller opts in with this variable.
291pub const HOME_ROOT_ENV: &str = "RIGHTKIT_QA_HOME_ROOT";
292/// Set to `0` to skip the automatic home sweep at workspace creation.
293pub const HOME_SWEEP_ENV: &str = "RIGHTKIT_QA_HOME_SWEEP";
294/// Minimum time between automatic home sweeps, across processes (stamp file in the homes dir).
295const SWEEP_INTERVAL: Duration = Duration::from_secs(10 * 60);
296/// Default bound on the bytes held by app homes that are not protected as live or young.
297pub 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
306/// The directory that holds `rightkit-qa/`: the override, else [`app_temp_dir`].
307pub(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    // Bind both sides; a forged path in a managed marker cannot delete arbitrary temp data.
325    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/// What a home sweep removes: homes whose run is over (or whose run record is gone) and that are
338/// older than `young`, then the oldest inactive homes until the rest fit `max_bytes`.
339#[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    /// (home, bytes, reason). With `dry_run` these are what would be removed.
357    pub removed: Vec<(PathBuf, u64, String)>,
358    pub retained: Vec<(PathBuf, String)>,
359    /// Bytes of every home found, before removal.
360    pub total_bytes: u64,
361    pub removed_bytes: u64,
362}
363
364struct HomeEntry {
365    dir: PathBuf,
366    bytes: u64,
367    /// Milliseconds since the epoch the home was last known to be in use or created.
368    stamp: u128,
369    kind: HomeKind,
370}
371
372enum HomeKind {
373    /// Run record present and live (owner alive or lease unexpired).
374    Active,
375    /// Run record present, run over.
376    Finished,
377    /// Run record missing or not matching: nothing else will ever reclaim this home.
378    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
391/// Bytes under `dir`, skipping symlinks and unreadable entries (a size estimate, not a proof).
392fn 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
411/// Sweep app homes under `parent` (the `.../rightkit-qa` directory). Marker-driven pruning only
412/// reaches homes whose run record still exists; this pass also reclaims homes left by killed
413/// processes and tests whose run root was deleted. A home is never removed while its run is
414/// live, while it is younger than the policy window and unowned, or when it has no binding file
415/// (it may not be ours). `dry_run` reports without deleting.
416pub 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    // Size bound: oldest finished homes go next, whatever their age. Orphans are unproven dead
497    // when young, so they stay.
498    // Only when it can work: deleting finished homes that still leave the total over the bound
499    // (live or young homes hold the rest) would lose debugging state for nothing.
500    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
552/// [`sweep_homes`] with the default policy, at most once per [`SWEEP_INTERVAL`] across processes.
553/// Never fails the caller.
554fn 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    // Claim the interval first so concurrent starters do not all walk the tree.
562    let _ = fs::write(&stamp, now_ms().to_string());
563    let _ = sweep_homes(parent, HomePolicy::default(), false);
564}
565
566// ---------------------------------------------------------------------------------------------
567// QA root hygiene: every home is removed when its run ends; dead homes are swept at run start;
568// a product whose own homes hold too much, or a root that holds far too much, refuses to start
569// another run.
570
571/// Marker inside each home: the owning process and when the run started.
572pub const HOME_MARKER: &str = ".rightkit-home.json";
573/// Binding file inside each home: the run record it belongs to and its product (`app`).
574const BINDING_FILE: &str = ".rightkit-app.json";
575/// Bytes one product's homes may hold after the start-of-run sweep. `0` disables the cap. The cap
576/// is per product: a home belongs to the product named by its binding file (else by its directory
577/// name prefix), and other products' homes never count against it.
578pub const ROOT_CAP_ENV: &str = "RIGHTKIT_QA_ROOT_CAP_BYTES";
579/// Bytes the whole `rightkit-qa` root may hold, all products together, after the start-of-run
580/// sweep. Above it every run is refused, because the disk itself is at risk. `0` disables it.
581pub const ROOT_CEILING_ENV: &str = "RIGHTKIT_QA_ROOT_CEILING_BYTES";
582/// Seconds a home whose owner is dead must be untouched before the start-of-run sweep removes it.
583pub const HOME_MAX_AGE_ENV: &str = "RIGHTKIT_QA_HOME_MAX_AGE_SECS";
584/// `1` keeps the home of a failed run for inspection (its path and size are printed).
585pub 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);
589/// The app key is cut to this many characters to make the product label in a home's name.
590const 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
597/// The per-product cap from the environment, else [`DEFAULT_ROOT_CAP_BYTES`].
598pub 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
605/// The root-wide ceiling from the environment, else [`DEFAULT_ROOT_CEILING_BYTES`].
606pub 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/// The limits a run start enforces on the QA root, as an app sets them in code. A value set here
614/// wins over the environment variable; `None` leaves the limit to the environment, then the
615/// default; `Some(0)` disables it.
616#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
617pub struct RootLimits {
618    /// Per-product cap ([`ROOT_CAP_ENV`]).
619    pub cap_bytes: Option<u64>,
620    /// Root-wide ceiling ([`ROOT_CEILING_ENV`]).
621    pub ceiling_bytes: Option<u64>,
622}
623
624/// [`RootLimits`] after setter, environment and default are applied. `0` means disabled.
625#[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    /// Setter, else environment, else default.
643    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
681/// Write a binding or marker file so that another process (a retention sweep reads these while
682/// runs start) sees the whole file or none of it: temp file, flush, rename.
683fn write_whole(path: &Path, bytes: &[u8]) -> Result<()> {
684    rightkit_fs::atomic_write(path, bytes).map_err(std::io::Error::from)?;
685    Ok(())
686}
687
688/// `(pid, started_at_ms)` recorded in a home.
689fn read_home_marker(home: &Path) -> Option<(u32, u128)> {
690    let v: serde_json::Value =
691        serde_json::from_slice(&fs::read(home.join(HOME_MARKER)).ok()?).ok()?;
692    let pid = u32::try_from(v.get("pid")?.as_u64()?).ok()?;
693    Some((pid, u128::from(v.get("startedAtMs")?.as_u64()?)))
694}
695
696/// The binding file of a home, when it is readable JSON object.
697fn read_binding(home: &Path) -> Option<serde_json::Value> {
698    fs::read(home.join(BINDING_FILE))
699        .ok()
700        .and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
701        .filter(serde_json::Value::is_object)
702}
703
704/// The product label a home's directory name carries: the app key cut to [`HOME_LABEL_LEN`].
705fn product_label(app: &str) -> String {
706    app.chars().take(HOME_LABEL_LEN).collect()
707}
708
709/// The product label of a directory name that follows the naming pattern of a QA home, else
710/// `None`. The pattern is `<label>-<suffix>`: the label is an app key cut to 16 characters (a
711/// safe key: ASCII letters, digits, `.`, `_`, `-`, starting with a letter or digit), and the suffix
712/// is the 16 lowercase hex digits this crate appends or the 6 letters and digits `mkdtemp` appends
713/// in `@rightkit/qa`. Both runners write into the same root, so both suffixes are accepted.
714fn parse_home_name(name: &str) -> Option<&str> {
715    let (label, suffix) = name.rsplit_once('-')?;
716    let suffix_ok = (suffix.len() == 16
717        && suffix
718            .bytes()
719            .all(|b| b.is_ascii_digit() || matches!(b, b'a'..=b'f')))
720        || (suffix.len() == 6 && suffix.bytes().all(|b| b.is_ascii_alphanumeric()));
721    let label_ok = !label.is_empty()
722        && label.chars().count() <= HOME_LABEL_LEN
723        && label.starts_with(|c: char| c.is_ascii_alphanumeric())
724        && label
725            .chars()
726            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
727    (suffix_ok && label_ok).then_some(label)
728}
729
730/// The product a home belongs to: the `app` in its binding file when present, else the label in
731/// its directory name, else the whole name (a directory this runner did not name).
732fn home_product(name: &str, binding: Option<&serde_json::Value>) -> String {
733    binding
734        .and_then(|b| b.get("app"))
735        .and_then(|v| v.as_str())
736        .filter(|app| !app.is_empty())
737        .map(str::to_owned)
738        .unwrap_or_else(|| parse_home_name(name).unwrap_or(name).to_owned())
739}
740
741/// Two product names are the same product when their home-name labels are equal.
742fn same_product(home_product: &str, app: &str) -> bool {
743    product_label(home_product) == product_label(app)
744}
745
746/// A directory this runner created as a QA home: a real directory directly under `rightkit-qa`
747/// with the binding file. `workspace::dispose` deletes a home only when this holds.
748pub(crate) fn is_owned_home(home: &Path) -> bool {
749    safe_dir(home)
750        && home.parent().and_then(Path::file_name) == Some(std::ffi::OsStr::new("rightkit-qa"))
751        && home.join(BINDING_FILE).is_file()
752}
753
754/// `<home base>/rightkit-qa`, the root this runner owns.
755pub fn qa_root() -> PathBuf {
756    home_base().join("rightkit-qa")
757}
758
759/// Remove `dir` only when it is a plain directory directly inside `canonical_parent`. Never
760/// follows a link out of the root ([`remove_home_tree`] removes a link as itself).
761fn remove_home_dir(dir: &Path, canonical_parent: &Path) -> std::result::Result<(), String> {
762    let plain = safe_dir(dir)
763        && fs::canonicalize(dir).is_ok_and(|c| c == dir && c.parent() == Some(canonical_parent));
764    if !plain {
765        return Err("path is not a plain home directory".into());
766    }
767    remove_home_tree(canonical_parent, dir)
768}
769
770/// Remove the QA home `home` and everything in it. `qa_root` is the `rightkit-qa` directory the
771/// caller may delete under: a `home` that is not a real directory strictly inside it, after both
772/// paths are resolved, is refused and nothing is touched.
773///
774/// A link met in the tree (a symlink, or on Windows a junction) is removed as itself. It is never
775/// listed and never followed, so what it points at stays, inside the home or outside it. The
776/// binding file goes last, just before the home directory: a removal that stops part way leaves a
777/// home that [`is_owned_home`] and the sweeps can still prove ours, so a later run finishes it.
778/// The error names the entry that could not be removed.
779///
780/// `std::fs::remove_dir_all` is not used here. On Windows it fails with "Access is denied" on a
781/// home whose app used WinINet, because WinINet leaves the junction
782/// `AppData\Local\Microsoft\Windows\INetCache\Content.IE5` with an entry that denies listing to
783/// everyone, and it removes entries in directory order, so the binding file (its name sorts first)
784/// was already gone when it failed (gap ledger 216). The test
785/// `std_remove_dir_all_stops_at_a_junction_that_denies_listing` shows the failure; that the cause
786/// inside the standard library is an open of the junction with list access is our reading of it.
787pub(crate) fn remove_home_tree(qa_root: &Path, home: &Path) -> std::result::Result<(), String> {
788    let (Ok(root), Ok(real)) = (fs::canonicalize(qa_root), fs::canonicalize(home)) else {
789        return Err(format!("{}: cannot be resolved", home.display()));
790    };
791    if root.file_name().is_none_or(|n| n != "rightkit-qa")
792        || !safe_dir(home)
793        || !is_inside(&root, &real)
794    {
795        return Err(format!(
796            "{}: not a directory inside the QA root {}",
797            home.display(),
798            root.display()
799        ));
800    }
801    let binding = std::ffi::OsStr::new(BINDING_FILE);
802    remove_children(&real, Some(binding))?;
803    remove_children(&real, None)?;
804    fs::remove_dir(&real).map_err(|e| removal_error(&real, &e))
805}
806
807fn removal_error(path: &Path, error: &std::io::Error) -> String {
808    format!("{}: {error}", path.display())
809}
810
811/// Remove everything in `dir` except the entry named `keep`. Links are removed as themselves.
812pub(crate) fn remove_children(
813    dir: &Path,
814    keep: Option<&std::ffi::OsStr>,
815) -> std::result::Result<(), String> {
816    for entry in fs::read_dir(dir).map_err(|e| removal_error(dir, &e))? {
817        let entry = entry.map_err(|e| removal_error(dir, &e))?;
818        if keep == Some(entry.file_name().as_os_str()) {
819            continue;
820        }
821        let path = entry.path();
822        // From the directory listing: the entry itself is not opened, so a link is not followed.
823        let kind = entry.file_type().map_err(|e| removal_error(&path, &e))?;
824        let outcome = if kind.is_symlink() {
825            remove_link(&entry)
826        } else if kind.is_dir() {
827            remove_children(&path, None)?;
828            fs::remove_dir(&path)
829        } else {
830            remove_plain_file(&path)
831        };
832        match outcome {
833            Ok(()) => {}
834            // Someone else removed it first: gone is what was wanted.
835            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
836            Err(e) => return Err(removal_error(&path, &e)),
837        }
838    }
839    Ok(())
840}
841
842/// Remove a link itself. Windows removes a link to a directory (a junction included) as a
843/// directory and a link to a file as a file; which one it is comes from the listing.
844fn remove_link(entry: &fs::DirEntry) -> std::io::Result<()> {
845    #[cfg(windows)]
846    {
847        use std::os::windows::fs::MetadataExt;
848        const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x10;
849        if entry.metadata()?.file_attributes() & FILE_ATTRIBUTE_DIRECTORY != 0 {
850            return fs::remove_dir(entry.path());
851        }
852    }
853    fs::remove_file(entry.path())
854}
855
856/// Remove a file. Windows refuses a read-only file, so that attribute is cleared and the removal
857/// tried once more; elsewhere the attribute of the file does not decide.
858fn remove_plain_file(path: &Path) -> std::io::Result<()> {
859    let first = match fs::remove_file(path) {
860        Ok(()) => return Ok(()),
861        Err(e) => e,
862    };
863    #[cfg(windows)]
864    if first.kind() == std::io::ErrorKind::PermissionDenied {
865        if let Ok(meta) = fs::symlink_metadata(path) {
866            let mut permissions = meta.permissions();
867            if permissions.readonly() {
868                #[allow(clippy::permissions_set_readonly_false)]
869                permissions.set_readonly(false);
870                if fs::set_permissions(path, permissions).is_ok() {
871                    return fs::remove_file(path);
872                }
873            }
874        }
875    }
876    Err(first)
877}
878
879/// True when `dir` itself or anything under it was modified after `cutoff_ms` (milliseconds since
880/// the epoch). Symlinks are examined, never followed. Stops at the first newer entry. A tree that
881/// cannot be inspected counts as recently modified: it is not proven idle.
882fn modified_after(dir: &Path, cutoff_ms: u128) -> bool {
883    if modified_ms(dir).is_none_or(|t| t > cutoff_ms) {
884        return true;
885    }
886    let mut stack = vec![dir.to_path_buf()];
887    while let Some(d) = stack.pop() {
888        let Ok(entries) = fs::read_dir(&d) else {
889            return true;
890        };
891        for entry in entries.flatten() {
892            let path = entry.path();
893            if modified_ms(&path).is_none_or(|t| t > cutoff_ms) {
894                return true;
895            }
896            if entry.file_type().is_ok_and(|ft| ft.is_dir()) {
897                stack.push(path);
898            }
899        }
900    }
901    false
902}
903
904/// Whether a home that has no owner marker (`.rightkit-home.json`, or no binding file beside it)
905/// may be swept. `Ok(reason)` removes it, `Err(why)` keeps it.
906///
907/// "Dead" for such a home means every one of these holds:
908/// * its name matches the home naming pattern ([`parse_home_name`]);
909/// * no pid recorded for it is alive: the pid in a home marker that has no binding beside it, and
910///   the `ownerPid` of the run record its binding names (`runRoot`), when either is readable
911///   ([`pid_live`]: a process that cannot be measured counts as alive);
912/// * nothing in the home, the directory itself included, was modified within `max_age_ms`.
913///
914/// Not checked: which processes have the home as their working directory or an open file in it
915/// (no process table walk), the run record's lease (`leaseUntil` is a timer set when the run
916/// began, not proof of a live process, and a cancelled run's lease is what kept these homes for
917/// 24 hours), and whether a pid in the record now belongs to an unrelated process (that keeps the
918/// home). A process that holds the home but writes nothing for the whole window is not noticed.
919fn unmarked_home_verdict(
920    dir: &Path,
921    name: &str,
922    binding: Option<&serde_json::Value>,
923    marker_pid: Option<u32>,
924    now: u128,
925    max_age_ms: u128,
926) -> std::result::Result<&'static str, String> {
927    if parse_home_name(name).is_none() {
928        return Err("no owner marker and the name is not a QA home name: not proven ours".into());
929    }
930    let record_pid = binding
931        .and_then(|b| b.get("runRoot"))
932        .and_then(|v| v.as_str())
933        .and_then(|root| read_marker(&Path::new(root).join(RUN_MARKER)))
934        .map(|m| m.owner_pid);
935    for pid in marker_pid.into_iter().chain(record_pid) {
936        if pid_live(pid) {
937            return Err(format!(
938                "no owner marker; recorded owner process {pid} is alive"
939            ));
940        }
941    }
942    if modified_after(dir, now.saturating_sub(max_age_ms)) {
943        return Err("no owner marker; modified within the window".into());
944    }
945    Ok("no owner marker, no live recorded owner, untouched for longer than the window")
946}
947
948/// Remove the homes of dead runs. Two kinds of home exist:
949///
950/// * A home with an owner marker (`.rightkit-home.json`) and a binding file goes when its recorded
951///   process is gone and its run started more than `max_age` ago. A home whose pid is alive is
952///   never touched.
953/// * Any other home (written before the marker existed, or missing a file) goes only under
954///   [`unmarked_home_verdict`]: a QA home name, no live recorded pid, and nothing in it modified
955///   within `max_age`. That function states what is and is not checked.
956///
957/// Symlinks, dot-directories, anything outside `parent` (the `.../rightkit-qa` directory), and any
958/// path that is not a plain directory directly inside it are never removed. `dry_run` reports
959/// without deleting.
960pub fn clean_homes(parent: &Path, max_age: Duration, dry_run: bool) -> HomeSweepReport {
961    let mut report = HomeSweepReport::default();
962    if parent.file_name().is_none_or(|n| n != "rightkit-qa") || !safe_dir(parent) {
963        return report;
964    }
965    let Ok(canonical_parent) = fs::canonicalize(parent) else {
966        return report;
967    };
968    let Ok(entries) = fs::read_dir(&canonical_parent) else {
969        return report;
970    };
971    let now = now_ms();
972    let max_age_ms = max_age.as_millis();
973    for entry in entries.flatten() {
974        let name = entry.file_name().to_string_lossy().into_owned();
975        if name.starts_with('.') {
976            continue;
977        }
978        let Ok(ft) = entry.file_type() else { continue };
979        let dir = entry.path();
980        if ft.is_symlink() {
981            report.retained.push((dir, "symlink skipped".into()));
982            continue;
983        }
984        if !ft.is_dir() {
985            continue;
986        }
987        let binding = read_binding(&dir);
988        let owner = read_home_marker(&dir);
989        let bytes = tree_bytes(&dir);
990        report.total_bytes = report.total_bytes.saturating_add(bytes);
991        let verdict: std::result::Result<&'static str, String> = match (&binding, owner) {
992            (Some(_), Some((pid, started))) => {
993                if pid_live(pid) {
994                    Err("owner process alive".into())
995                } else if now.saturating_sub(started) < max_age_ms {
996                    Err("owner gone but younger than the window".into())
997                } else {
998                    Ok("owner process gone and older than the window")
999                }
1000            }
1001            _ => unmarked_home_verdict(
1002                &dir,
1003                &name,
1004                binding.as_ref(),
1005                owner.map(|(pid, _)| pid),
1006                now,
1007                max_age_ms,
1008            ),
1009        };
1010        match verdict {
1011            Err(why) => report.retained.push((dir, why)),
1012            Ok(reason) => {
1013                if !dry_run {
1014                    if let Err(why) = remove_home_dir(&dir, &canonical_parent) {
1015                        report.retained.push((dir, why));
1016                        continue;
1017                    }
1018                }
1019                report.removed_bytes = report.removed_bytes.saturating_add(bytes);
1020                report.removed.push((dir, bytes, reason.to_string()));
1021            }
1022        }
1023    }
1024    report
1025}
1026
1027/// [`clean_homes`] on this runner's own root with the environment's bounds. The entry point for
1028/// apps and for `rightkit-qa clean`.
1029pub fn clean(dry_run: bool) -> HomeSweepReport {
1030    clean_homes(&qa_root(), home_max_age(), dry_run)
1031}
1032
1033/// Which limit a refused run exceeded.
1034#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1035pub enum RootLimitKind {
1036    /// The run's own product holds more than its per-product cap.
1037    ProductCap,
1038    /// All products together hold more than the root-wide ceiling.
1039    RootCeiling,
1040}
1041
1042/// A run was refused because a QA root limit is exceeded even after the sweep.
1043#[derive(Debug, Clone)]
1044pub struct RootOverCap {
1045    pub kind: RootLimitKind,
1046    pub root: PathBuf,
1047    /// The product the refused run is for.
1048    pub app: String,
1049    /// Bytes counted against the limit: the product's homes, or every home.
1050    pub total_bytes: u64,
1051    /// Homes counted against the limit.
1052    pub home_count: usize,
1053    /// The limit that was exceeded.
1054    pub limit_bytes: u64,
1055    /// Largest homes first: `(home, bytes, product)`.
1056    pub largest: Vec<(PathBuf, u64, String)>,
1057}
1058
1059/// `"5.26 GB (5260000000 bytes)"`, or `"812 bytes"` below a megabyte.
1060fn human_bytes(bytes: u64) -> String {
1061    if bytes >= 1_000_000_000 {
1062        format!("{:.2} GB ({bytes} bytes)", bytes as f64 / 1e9)
1063    } else if bytes >= 1_000_000 {
1064        format!("{:.1} MB ({bytes} bytes)", bytes as f64 / 1e6)
1065    } else {
1066        format!("{bytes} bytes")
1067    }
1068}
1069
1070impl std::fmt::Display for RootOverCap {
1071    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1072        match self.kind {
1073            RootLimitKind::ProductCap => write!(
1074                f,
1075                "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:",
1076                app = self.app,
1077                root = self.root.display(),
1078                total = human_bytes(self.total_bytes),
1079                n = self.home_count,
1080                limit = human_bytes(self.limit_bytes),
1081                env = ROOT_CAP_ENV,
1082            )?,
1083            RootLimitKind::RootCeiling => write!(
1084                f,
1085                "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:",
1086                app = self.app,
1087                root = self.root.display(),
1088                total = human_bytes(self.total_bytes),
1089                n = self.home_count,
1090                limit = human_bytes(self.limit_bytes),
1091                env = ROOT_CEILING_ENV,
1092            )?,
1093        }
1094        for (home, bytes, product) in &self.largest {
1095            write!(
1096                f,
1097                " {} [{product}] ({});",
1098                home.display(),
1099                human_bytes(*bytes)
1100            )?;
1101        }
1102        write!(
1103            f,
1104            " 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"
1105        )
1106    }
1107}
1108impl std::error::Error for RootOverCap {}
1109impl From<RootOverCap> for crate::util::Error {
1110    fn from(e: RootOverCap) -> Self {
1111        crate::util::Error(e.to_string())
1112    }
1113}
1114
1115/// Fail with [`RootOverCap`] when the homes of the product `app` hold more than `cap_bytes`
1116/// (`0` disables the check). Other products' homes are not counted.
1117pub fn check_root_cap(
1118    parent: &Path,
1119    app: &str,
1120    cap_bytes: u64,
1121) -> std::result::Result<(), RootOverCap> {
1122    check_root_limits(parent, app, cap_bytes, 0)
1123}
1124
1125/// Fail with [`RootOverCap`] when the homes of the product `app` hold more than `cap_bytes`
1126/// (checked first), or when every directory under `parent` together holds more than
1127/// `ceiling_bytes`. `0` disables a limit. A home's product is the `app` in its binding file, else
1128/// the label in its directory name. Symlinks are not followed or counted.
1129pub fn check_root_limits(
1130    parent: &Path,
1131    app: &str,
1132    cap_bytes: u64,
1133    ceiling_bytes: u64,
1134) -> std::result::Result<(), RootOverCap> {
1135    if (cap_bytes == 0 && ceiling_bytes == 0) || !safe_dir(parent) {
1136        return Ok(());
1137    }
1138    struct SizedHome {
1139        path: PathBuf,
1140        bytes: u64,
1141        product: String,
1142        own: bool,
1143    }
1144    let mut homes: Vec<SizedHome> = Vec::new();
1145    if let Ok(entries) = fs::read_dir(parent) {
1146        for entry in entries.flatten() {
1147            let Ok(ft) = entry.file_type() else { continue };
1148            if !ft.is_dir() || ft.is_symlink() {
1149                continue;
1150            }
1151            let path = entry.path();
1152            let name = entry.file_name().to_string_lossy().into_owned();
1153            let product = home_product(&name, read_binding(&path).as_ref());
1154            let own = same_product(&product, app);
1155            // Another product's home is sized only for the ceiling.
1156            let bytes = if (own && cap_bytes > 0) || ceiling_bytes > 0 {
1157                tree_bytes(&path)
1158            } else {
1159                0
1160            };
1161            homes.push(SizedHome {
1162                path,
1163                bytes,
1164                product,
1165                own,
1166            });
1167        }
1168    }
1169    fn refusal(
1170        kind: RootLimitKind,
1171        limit_bytes: u64,
1172        parent: &Path,
1173        app: &str,
1174        mut counted: Vec<&SizedHome>,
1175    ) -> RootOverCap {
1176        let total_bytes = counted
1177            .iter()
1178            .fold(0u64, |sum, home| sum.saturating_add(home.bytes));
1179        let home_count = counted.len();
1180        counted.sort_by_key(|home| std::cmp::Reverse(home.bytes));
1181        RootOverCap {
1182            kind,
1183            root: parent.to_path_buf(),
1184            app: app.to_string(),
1185            total_bytes,
1186            home_count,
1187            limit_bytes,
1188            largest: counted
1189                .into_iter()
1190                .take(5)
1191                .map(|home| (home.path.clone(), home.bytes, home.product.clone()))
1192                .collect(),
1193        }
1194    }
1195    if cap_bytes > 0 {
1196        let own: Vec<&SizedHome> = homes.iter().filter(|home| home.own).collect();
1197        let own_total = own
1198            .iter()
1199            .fold(0u64, |sum, home| sum.saturating_add(home.bytes));
1200        if own_total > cap_bytes {
1201            return Err(refusal(
1202                RootLimitKind::ProductCap,
1203                cap_bytes,
1204                parent,
1205                app,
1206                own,
1207            ));
1208        }
1209    }
1210    if ceiling_bytes > 0 {
1211        let all: Vec<&SizedHome> = homes.iter().collect();
1212        let total = all
1213            .iter()
1214            .fold(0u64, |sum, home| sum.saturating_add(home.bytes));
1215        if total > ceiling_bytes {
1216            return Err(refusal(
1217                RootLimitKind::RootCeiling,
1218                ceiling_bytes,
1219                parent,
1220                app,
1221                all,
1222            ));
1223        }
1224    }
1225    Ok(())
1226}
1227
1228/// Sweep dead homes under `parent`, then the 24 hour rules (at most every ten minutes).
1229/// `RIGHTKIT_QA_HOME_SWEEP=0` skips both.
1230fn sweep_root_at_run_start(parent: &Path) {
1231    if std::env::var(HOME_SWEEP_ENV).is_ok_and(|v| v.trim() == "0") {
1232        return;
1233    }
1234    let _ = clean_homes(parent, home_max_age(), false);
1235    sweep_homes_throttled(parent);
1236}
1237
1238/// [`check_root_limits`] with `limits` resolved against `env`: a limit set in code wins over the
1239/// environment, and the environment over the default.
1240fn enforce_limits_with(
1241    parent: &Path,
1242    app: &str,
1243    limits: RootLimits,
1244    env: &dyn Fn(&str) -> Option<String>,
1245) -> std::result::Result<(), RootOverCap> {
1246    let limits = limits.resolve_with(env);
1247    check_root_limits(parent, app, limits.cap_bytes, limits.ceiling_bytes)
1248}
1249
1250/// The sweep half of the start-of-run preflight, for a caller that checks the limits later (the
1251/// harness, so a limit set after construction takes effect). Skipped in this crate's own unit
1252/// tests, which run against the developer's real root.
1253pub(crate) fn sweep_home_root() {
1254    if cfg!(test) {
1255        return;
1256    }
1257    let parent = qa_root();
1258    if safe_dir(&parent) {
1259        sweep_root_at_run_start(&parent);
1260    }
1261}
1262
1263/// The limit half of the start-of-run preflight for the product `app`. Skipped in this crate's own
1264/// unit tests, like [`sweep_home_root`].
1265pub(crate) fn enforce_root_limits(
1266    app: &str,
1267    limits: RootLimits,
1268) -> std::result::Result<(), RootOverCap> {
1269    if cfg!(test) {
1270        return Ok(());
1271    }
1272    let parent = qa_root();
1273    if !safe_dir(&parent) {
1274        return Ok(());
1275    }
1276    enforce_limits_with(&parent, app, limits, &|key: &str| std::env::var(key).ok())
1277}
1278
1279/// Run before a run is created: sweep dead homes, then refuse to start when the run's own product
1280/// is over its cap or the root is over its ceiling (`limits` set in code, else the environment,
1281/// else the defaults).
1282pub(crate) fn preflight_home_root(app: &str, limits: RootLimits) -> Result<()> {
1283    sweep_home_root();
1284    enforce_root_limits(app, limits)?;
1285    Ok(())
1286}
1287
1288/// `"<path> (<bytes> bytes)"` for a kept home.
1289pub fn describe_kept_home(home: &Path) -> String {
1290    format!("{} ({} bytes)", home.display(), tree_bytes(home))
1291}
1292
1293fn owned_app_root(root: &Path, marker: &RunMarker) -> Result<Option<PathBuf>> {
1294    let Some(home) = &marker.app_root else {
1295        return Ok(None);
1296    };
1297    if !home.exists() {
1298        return Ok(None);
1299    }
1300    let parent = home
1301        .parent()
1302        .ok_or_else(|| crate::util::Error("invalid QA app temp root".into()))?;
1303    let temp = parent
1304        .parent()
1305        .ok_or_else(|| crate::util::Error("invalid QA app temp root".into()))?;
1306    let allowed_temp = temp == fs::canonicalize(app_temp_dir())?
1307        || fs::canonicalize(home_base()).is_ok_and(|base| temp == base)
1308        || (cfg!(target_os = "macos")
1309            && (temp == Path::new("/private/tmp")
1310                || (temp.starts_with("/private/var/folders")
1311                    && temp.file_name().is_some_and(|n| n == "T"))));
1312    if parent.file_name().is_none_or(|n| n != "rightkit-qa")
1313        || !allowed_temp
1314        || fs::canonicalize(home)? != *home
1315        || !safe_dir(home)
1316    {
1317        return err("invalid QA app temp root");
1318    }
1319    let binding: serde_json::Value =
1320        serde_json::from_slice(&fs::read(home.join(".rightkit-app.json"))?)?;
1321    if binding
1322        != serde_json::json!({"runRoot": fs::canonicalize(root)?, "app": marker.app,
1323        "runId": marker.run_id})
1324    {
1325        return err("QA app temp ownership mismatch");
1326    }
1327    Ok(Some(home.clone()))
1328}
1329
1330pub(crate) fn remove_app_root(root: &Path, marker: &RunMarker) -> Result<()> {
1331    if let Some(home) = owned_app_root(root, marker)? {
1332        let Some(qa_root) = home.parent() else {
1333            return err("invalid QA app temp root");
1334        };
1335        if let Err(why) = remove_home_tree(qa_root, &home) {
1336            return err(why);
1337        }
1338    }
1339    Ok(())
1340}
1341
1342pub(crate) fn copy_tree(source: &Path, target: &Path) -> Result<()> {
1343    let meta = fs::symlink_metadata(source)?;
1344    if meta.file_type().is_symlink() {
1345        return err("symlink in QA app evidence");
1346    }
1347    if target
1348        .symlink_metadata()
1349        .is_ok_and(|m| m.file_type().is_symlink())
1350    {
1351        return err("symlink in managed QA evidence");
1352    }
1353    if meta.is_dir() {
1354        ensure_dir(target)?;
1355        for entry in fs::read_dir(source)? {
1356            let entry = entry?;
1357            copy_tree(&entry.path(), &target.join(entry.file_name()))?;
1358        }
1359    } else if meta.is_file() {
1360        ensure_dir(
1361            target
1362                .parent()
1363                .ok_or_else(|| crate::util::Error("missing evidence parent".into()))?,
1364        )?;
1365        fs::copy(source, target)?;
1366    } else {
1367        return err("unsupported QA app evidence file");
1368    }
1369    Ok(())
1370}
1371
1372pub fn prune(root: &Path, app: &str, max_bytes: Option<u64>) -> Result<RetentionReport> {
1373    prune_with_keep(root, app, max_bytes, &[])
1374}
1375
1376pub fn prune_with_keep(
1377    root: &Path,
1378    app: &str,
1379    max_bytes: Option<u64>,
1380    keep_run_ids: &[String],
1381) -> Result<RetentionReport> {
1382    prune_with_policy(
1383        root,
1384        app,
1385        max_bytes,
1386        keep_run_ids,
1387        Duration::from_millis(DEFAULT_YOUNG_MS as u64),
1388    )
1389}
1390
1391pub fn prune_with_policy(
1392    root: &Path,
1393    app: &str,
1394    max_bytes: Option<u64>,
1395    keep_run_ids: &[String],
1396    young_for: Duration,
1397) -> Result<RetentionReport> {
1398    prune_with_policy_count(
1399        root,
1400        app,
1401        max_bytes,
1402        keep_run_ids,
1403        young_for,
1404        DEFAULT_KEEP_COUNT,
1405    )
1406}
1407
1408pub fn prune_with_age_override(
1409    root: &Path,
1410    app: &str,
1411    keep_run_ids: &[String],
1412    young_for: Duration,
1413) -> Result<RetentionReport> {
1414    prune_with_policy_count(root, app, Some(0), keep_run_ids, young_for, 0)
1415}
1416
1417fn prune_with_policy_count(
1418    root: &Path,
1419    app: &str,
1420    max_bytes: Option<u64>,
1421    keep_run_ids: &[String],
1422    young_for: Duration,
1423    keep_count: usize,
1424) -> Result<RetentionReport> {
1425    let app = safe_key(app, "app")?;
1426    let app_root = managed_root(Some(root)).join("runs").join(&app);
1427    let initial = RetentionReport {
1428        cap_bytes: max_bytes.unwrap_or(DEFAULT_MAX_BYTES),
1429        ..Default::default()
1430    };
1431    if !safe_dir(&app_root) {
1432        return Ok(initial);
1433    }
1434    let _guard = lock_or_wait(&app_root, PRUNE_LOCK_WAIT)?;
1435    {
1436        let mut report = RetentionReport::default();
1437        prune_unlocked(
1438            &app_root,
1439            &app,
1440            max_bytes.unwrap_or(DEFAULT_MAX_BYTES),
1441            keep_count,
1442            young_for.as_millis(),
1443            keep_run_ids,
1444            &mut report,
1445        )?;
1446        Ok(report)
1447    }
1448}
1449
1450fn prune_unlocked(
1451    app_root: &Path,
1452    app: &str,
1453    cap: u64,
1454    keep_count: usize,
1455    young_ms: u128,
1456    keep_run_ids: &[String],
1457    report: &mut RetentionReport,
1458) -> Result<()> {
1459    let now = now_ms();
1460    report.cap_bytes = cap;
1461    let mut runs = Vec::new();
1462    let entries = match fs::read_dir(app_root) {
1463        Ok(x) => x,
1464        Err(_) => return Ok(()),
1465    };
1466    for entry in entries.flatten() {
1467        let ft = match entry.file_type() {
1468            Ok(x) => x,
1469            Err(_) => continue,
1470        };
1471        if ft.is_symlink() {
1472            report
1473                .retained
1474                .push((entry.path(), "symlink skipped".into()));
1475            continue;
1476        }
1477        if !ft.is_dir() || entry.file_name().to_string_lossy().starts_with('.') {
1478            continue;
1479        }
1480        let dir = entry.path();
1481        if !is_inside(app_root, &dir) {
1482            continue;
1483        }
1484        let Some(marker) = read_marker(&dir.join(RUN_MARKER)) else {
1485            if let Some(bytes) = directory_bytes(&dir) {
1486                report.unknown_bytes = report.unknown_bytes.saturating_add(bytes);
1487            }
1488            report
1489                .retained
1490                .push((dir, "unknown or malformed run metadata".into()));
1491            continue;
1492        };
1493        if marker.app != app || marker.run_id != entry.file_name().to_string_lossy() {
1494            if let Some(bytes) = directory_bytes(&dir) {
1495                report.unknown_bytes = report.unknown_bytes.saturating_add(bytes);
1496            }
1497            report
1498                .retained
1499                .push((dir, "run marker identity mismatch".into()));
1500            continue;
1501        }
1502        let Some(bytes) = directory_bytes(&dir) else {
1503            report
1504                .retained
1505                .push((dir, "symlink or unreadable content skipped".into()));
1506            continue;
1507        };
1508        let active =
1509            (marker.owner_pid > 0 && pid_live(marker.owner_pid)) || marker.lease_until > now;
1510        runs.push((dir, marker, bytes, active));
1511    }
1512    runs.sort_by(|a, b| {
1513        b.1.created_at
1514            .cmp(&a.1.created_at)
1515            .then_with(|| a.1.run_id.cmp(&b.1.run_id))
1516    });
1517    let count_protected: std::collections::HashSet<String> = runs
1518        .iter()
1519        .take(keep_count)
1520        .map(|x| x.1.run_id.clone())
1521        .collect();
1522    let protected: std::collections::HashSet<String> = runs
1523        .iter()
1524        .filter(|x| now.saturating_sub(x.1.created_at) < young_ms || x.3)
1525        .map(|x| x.1.run_id.clone())
1526        .collect();
1527    let caller_protected: std::collections::HashSet<&str> =
1528        keep_run_ids.iter().map(String::as_str).collect();
1529    let protected: std::collections::HashSet<String> = protected
1530        .into_iter()
1531        .chain(caller_protected.iter().map(|x| (*x).to_string()))
1532        .collect();
1533    // A failed run is kept by the count rule too; only the byte cap, a disk-safety limit, can
1534    // still evict one (oldest first).
1535    let failed: std::collections::HashSet<String> = runs
1536        .iter()
1537        .filter(|x| x.1.outcome == Some(RunOutcome::Failed))
1538        .map(|x| x.1.run_id.clone())
1539        .collect();
1540    let mut total: u64 = runs.iter().map(|x| x.2).sum();
1541    let mut old: Vec<_> = runs
1542        .iter()
1543        .filter(|x| {
1544            !protected.contains(&x.1.run_id)
1545                && (total > cap
1546                    || (!count_protected.contains(&x.1.run_id) && !failed.contains(&x.1.run_id)))
1547        })
1548        .collect();
1549    old.sort_by(|a, b| {
1550        a.1.created_at
1551            .cmp(&b.1.created_at)
1552            .then_with(|| a.1.run_id.cmp(&b.1.run_id))
1553    });
1554    let mut removed = std::collections::HashSet::new();
1555    for item in old {
1556        if total <= cap
1557            && (count_protected.contains(&item.1.run_id) || failed.contains(&item.1.run_id))
1558        {
1559            continue;
1560        }
1561        if !safe_dir(&item.0) {
1562            continue;
1563        }
1564        match remove_app_root(&item.0, &item.1)
1565            .and_then(|()| fs::remove_dir_all(&item.0).map_err(Into::into))
1566        {
1567            Ok(()) => {
1568                report.removed.push(item.0.clone());
1569                removed.insert(item.1.run_id.clone());
1570                total = total.saturating_sub(item.2);
1571            }
1572            Err(e) => report.retained.push((item.0.clone(), e.to_string())),
1573        }
1574    }
1575    report.blocked_bytes = runs
1576        .iter()
1577        .filter(|x| {
1578            !removed.contains(&x.1.run_id)
1579                && (protected.contains(&x.1.run_id)
1580                    || (total <= cap
1581                        && (count_protected.contains(&x.1.run_id) || failed.contains(&x.1.run_id))))
1582        })
1583        .map(|x| x.2)
1584        .sum();
1585    report.total_bytes = total.saturating_add(report.unknown_bytes);
1586    report.over_cap_bytes = report.total_bytes.saturating_sub(report.cap_bytes);
1587    for item in runs.iter().filter(|x| {
1588        !removed.contains(&x.1.run_id)
1589            && (protected.contains(&x.1.run_id)
1590                || (total <= cap
1591                    && (count_protected.contains(&x.1.run_id) || failed.contains(&x.1.run_id))))
1592    }) {
1593        report.retained.push((
1594            item.0.clone(),
1595            if item.3 {
1596                "active run".into()
1597            } else if now.saturating_sub(item.1.created_at) < young_ms {
1598                "younger than retention window".into()
1599            } else if failed.contains(&item.1.run_id) {
1600                "failed run kept".into()
1601            } else {
1602                "newest protected run".into()
1603            },
1604        ));
1605    }
1606    Ok(())
1607}
1608
1609/// How long an explicit prune waits for another prune of the same app before it reports
1610/// [`LOCK_HELD`]. Pruning a large tree takes seconds, so this is generous.
1611const PRUNE_LOCK_WAIT: Duration = Duration::from_secs(120);
1612/// A lock directory with no owner file this old was abandoned between `mkdir` and the owner write.
1613const OWNERLESS_LOCK_GRACE: Duration = Duration::from_secs(30);
1614/// Prefix of the error message for a retention lock that stayed held. [`is_lock_held`] tests for it.
1615pub const LOCK_HELD: &str = "QA retention lock is held";
1616
1617/// True when `error` is the retention lock staying held past an explicit prune's wait.
1618pub fn is_lock_held(error: &crate::util::Error) -> bool {
1619    error.0.starts_with(LOCK_HELD)
1620}
1621
1622/// The retention lock, released on drop (including a panic in the prune).
1623pub(crate) struct LockGuard(PathBuf);
1624
1625impl Drop for LockGuard {
1626    fn drop(&mut self) {
1627        let _ = fs::remove_dir_all(&self.0);
1628    }
1629}
1630
1631/// Take the lock, waiting up to `wait`. `None` means a live owner still held it.
1632/// A lock whose owner process is dead, or that never got an owner file, is recovered.
1633pub(crate) fn try_lock(app_root: &Path, wait: Duration) -> Result<Option<LockGuard>> {
1634    ensure_dir(app_root)?;
1635    let lock = app_root.join(".retention-lock");
1636    let owner = lock.join("owner.json");
1637    let deadline = std::time::Instant::now() + wait;
1638    loop {
1639        match fs::create_dir(&lock) {
1640            Ok(()) => {
1641                let guard = LockGuard(lock.clone());
1642                let mut file = File::create(&owner)?;
1643                write!(
1644                    file,
1645                    "{{\"pid\":{},\"createdAt\":{}}}",
1646                    std::process::id(),
1647                    now_ms()
1648                )?;
1649                return Ok(Some(guard));
1650            }
1651            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
1652                let pid = fs::read_to_string(&owner)
1653                    .ok()
1654                    .and_then(|x| serde_json::from_str::<serde_json::Value>(&x).ok())
1655                    .and_then(|x| x.get("pid").and_then(|v| v.as_u64()))
1656                    .and_then(|x| u32::try_from(x).ok())
1657                    .filter(|p| *p > 0);
1658                let abandoned = match pid {
1659                    Some(pid) => !pid_live(pid),
1660                    None => fs::metadata(&lock)
1661                        .and_then(|m| m.modified())
1662                        .ok()
1663                        .and_then(|t| t.elapsed().ok())
1664                        .is_some_and(|age| age >= OWNERLESS_LOCK_GRACE),
1665                };
1666                if abandoned {
1667                    let _ = fs::remove_dir_all(&lock);
1668                    continue;
1669                }
1670                if std::time::Instant::now() >= deadline {
1671                    return Ok(None);
1672                }
1673                std::thread::sleep(Duration::from_millis(20));
1674            }
1675            // Windows: a directory that is being removed keeps its name until the last handle on
1676            // it closes, and creating that name meanwhile is refused with "access denied", not
1677            // "already exists". Another starter is releasing the lock (or reading it while it is
1678            // released): the lock counts as held, and a starter never fails for it.
1679            Err(e) if cfg!(windows) && e.kind() == std::io::ErrorKind::PermissionDenied => {
1680                if std::time::Instant::now() >= deadline {
1681                    return Ok(None);
1682                }
1683                std::thread::sleep(Duration::from_millis(20));
1684            }
1685            Err(e) => return Err(e.into()),
1686        }
1687    }
1688}
1689
1690fn lock_or_wait(app_root: &Path, wait: Duration) -> Result<LockGuard> {
1691    try_lock(app_root, wait)?.ok_or_else(|| {
1692        crate::util::Error(format!(
1693            "{LOCK_HELD}: {}",
1694            app_root.join(".retention-lock").display()
1695        ))
1696    })
1697}
1698
1699pub(crate) fn write_marker(root: &Path, marker: &RunMarker) -> Result<()> {
1700    let target = root.join(RUN_MARKER);
1701    let temp = root.join(format!(".{RUN_MARKER}.{}.tmp", std::process::id()));
1702    let mut file = OpenOptions::new()
1703        .write(true)
1704        .create_new(true)
1705        .open(&temp)?;
1706    file.write_all(serde_json::to_string(marker)?.as_bytes())?;
1707    file.write_all(b"\n")?;
1708    file.sync_all()?;
1709    fs::rename(temp, target)?;
1710    Ok(())
1711}
1712
1713pub(crate) fn read_marker(path: &Path) -> Option<RunMarker> {
1714    let marker: RunMarker = serde_json::from_slice(&fs::read(path).ok()?).ok()?;
1715    if marker.schema_version != 1
1716        || safe_key(&marker.app, "app").is_err()
1717        || safe_key(&marker.run_id, "run id").is_err()
1718        || marker.created_at == 0
1719    {
1720        return None;
1721    }
1722    Some(marker)
1723}
1724
1725pub(crate) fn marker_active(marker: &RunMarker, now: u128) -> bool {
1726    (marker.owner_pid > 0 && pid_live(marker.owner_pid)) || marker.lease_until > now
1727}
1728
1729pub(crate) fn directory_bytes(root: &Path) -> Option<u64> {
1730    let mut total = 0u64;
1731    fn walk(dir: &Path, total: &mut u64) -> bool {
1732        let entries = match fs::read_dir(dir) {
1733            Ok(x) => x,
1734            Err(_) => return false,
1735        };
1736        for e in entries.flatten() {
1737            let ft = match e.file_type() {
1738                Ok(x) => x,
1739                Err(_) => return false,
1740            };
1741            if ft.is_symlink() {
1742                return false;
1743            }
1744            if ft.is_dir() {
1745                if !walk(&e.path(), total) {
1746                    return false;
1747                }
1748            } else if ft.is_file() {
1749                let Some(size) = e.metadata().ok().map(|m| m.len()) else {
1750                    return false;
1751                };
1752                let Some(next) = total.checked_add(size) else {
1753                    return false;
1754                };
1755                *total = next;
1756            } else {
1757                return false;
1758            }
1759        }
1760        true
1761    }
1762    walk(root, &mut total).then_some(total)
1763}
1764
1765fn ensure_dir(dir: &Path) -> Result<()> {
1766    let mut current = PathBuf::new();
1767    for component in dir.components() {
1768        current.push(component.as_os_str());
1769        if current.exists() && fs::symlink_metadata(&current)?.file_type().is_symlink() {
1770            return err(format!(
1771                "refusing symlink in managed path: {}",
1772                current.display()
1773            ));
1774        }
1775    }
1776    fs::create_dir_all(dir)?;
1777    Ok(())
1778}
1779pub(crate) fn safe_dir(dir: &Path) -> bool {
1780    fs::symlink_metadata(dir)
1781        .map(|m| m.is_dir() && !m.file_type().is_symlink())
1782        .unwrap_or(false)
1783}
1784pub(crate) fn safe_key(value: &str, name: &str) -> Result<String> {
1785    if !value.is_empty()
1786        && value
1787            .chars()
1788            .next()
1789            .is_some_and(|c| c.is_ascii_alphanumeric())
1790        && value
1791            .chars()
1792            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
1793    {
1794        Ok(value.to_string())
1795    } else {
1796        err(format!("unsafe QA {name}: {value}"))
1797    }
1798}
1799pub(crate) fn is_inside(root: &Path, candidate: &Path) -> bool {
1800    candidate
1801        .strip_prefix(root)
1802        .map(|r| {
1803            !r.as_os_str().is_empty()
1804                && !r
1805                    .components()
1806                    .any(|c| matches!(c, std::path::Component::ParentDir))
1807        })
1808        .unwrap_or(false)
1809}
1810fn pid_live(pid: u32) -> bool {
1811    let Some(pid) = std::num::NonZeroU32::new(pid) else {
1812        return false;
1813    };
1814    // An unmeasurable process counts as live: a sweep must never reap a run it cannot prove dead.
1815    rightkit_child_process::AdoptedProcess::new(pid)
1816        .is_running()
1817        .unwrap_or(true)
1818}
1819
1820/// Windows links for tests, made with the system's own tools.
1821#[cfg(all(test, windows))]
1822pub(crate) mod test_links {
1823    use std::ffi::c_void;
1824    use std::path::Path;
1825    use std::process::Command;
1826
1827    /// `path` without the `\\?\` prefix, which `cmd` and `icacls` do not take.
1828    fn plain(path: &Path) -> String {
1829        let text = path.to_string_lossy().into_owned();
1830        match text.strip_prefix(r"\\?\") {
1831            Some(rest) => rest.to_owned(),
1832            None => text,
1833        }
1834    }
1835
1836    /// A junction at `link` that points at the directory `target`.
1837    pub(crate) fn junction(link: &Path, target: &Path) {
1838        let out = Command::new("cmd")
1839            .args(["/C", "mklink", "/J", &plain(link), &plain(target)])
1840            .output()
1841            .unwrap();
1842        assert!(
1843            out.status.success(),
1844            "mklink: {}{}",
1845            String::from_utf8_lossy(&out.stdout),
1846            String::from_utf8_lossy(&out.stderr)
1847        );
1848    }
1849
1850    /// Deny everyone the listing of the link itself (`/L`), as WinINet leaves `Content.IE5`.
1851    pub(crate) fn deny_listing(link: &Path) {
1852        let out = Command::new("icacls")
1853            .args([&plain(link), "/L", "/deny", "*S-1-1-0:(RD)"])
1854            .output()
1855            .unwrap();
1856        assert!(
1857            out.status.success(),
1858            "icacls: {}{}",
1859            String::from_utf8_lossy(&out.stdout),
1860            String::from_utf8_lossy(&out.stderr)
1861        );
1862    }
1863
1864    /// The shape WinINet leaves in a redirected profile: `INetCache\IE` with a file, and beside
1865    /// it the junction `Content.IE5` to it that nobody may list. Returns the junction.
1866    pub(crate) fn wininet_cache(home: &Path) -> std::path::PathBuf {
1867        let cache = home
1868            .join("AppData")
1869            .join("Local")
1870            .join("Microsoft")
1871            .join("Windows")
1872            .join("INetCache");
1873        std::fs::create_dir_all(cache.join("IE")).unwrap();
1874        std::fs::write(cache.join("IE").join("container.dat"), b"cache").unwrap();
1875        let link = cache.join("Content.IE5");
1876        junction(&link, &cache.join("IE"));
1877        deny_listing(&link);
1878        if std::fs::read_dir(&link).is_ok() {
1879            // An account that holds the backup privilege enabled (a hosted CI runner) is let
1880            // through whatever the access list says. Give the privilege up for this test
1881            // process, so the fixture behaves as it does for an ordinary user.
1882            drop_backup_privileges();
1883            eprintln!(
1884                "[qa-home-removal] listing was allowed; backup and restore privileges dropped"
1885            );
1886        } else {
1887            eprintln!("[qa-home-removal] listing is refused by the access list alone");
1888        }
1889        assert!(
1890            std::fs::read_dir(&link).is_err(),
1891            "the junction must refuse listing for this test to mean anything; privileges: {}",
1892            privileges()
1893        );
1894        link
1895    }
1896
1897    /// The backup and restore lines of `whoami /priv`, for the message of a failed precondition.
1898    fn privileges() -> String {
1899        let out = Command::new("whoami").arg("/priv").output();
1900        let Ok(out) = out else {
1901            return "whoami did not run".into();
1902        };
1903        String::from_utf8_lossy(&out.stdout)
1904            .lines()
1905            .filter(|l| l.contains("SeBackupPrivilege") || l.contains("SeRestorePrivilege"))
1906            .collect::<Vec<_>>()
1907            .join(" | ")
1908    }
1909
1910    // TOKEN_PRIVILEGES with one entry is four 32-bit words: the count, the two halves of the
1911    // privilege's LUID, and its attributes.
1912    #[link(name = "advapi32")]
1913    extern "system" {
1914        fn OpenProcessToken(process: *mut c_void, access: u32, token: *mut *mut c_void) -> i32;
1915        fn LookupPrivilegeValueW(system: *const u16, name: *const u16, luid: *mut u32) -> i32;
1916        fn AdjustTokenPrivileges(
1917            token: *mut c_void,
1918            disable_all: i32,
1919            new_state: *const u32,
1920            length: u32,
1921            previous: *mut c_void,
1922            returned: *mut u32,
1923        ) -> i32;
1924    }
1925    #[link(name = "kernel32")]
1926    extern "system" {
1927        fn GetCurrentProcess() -> *mut c_void;
1928        fn CloseHandle(handle: *mut c_void) -> i32;
1929    }
1930
1931    /// Disable `SeBackupPrivilege` and `SeRestorePrivilege` in this process's token. They stay
1932    /// disabled for the rest of the test process; no test here needs them. A privilege the
1933    /// account does not hold is skipped.
1934    fn drop_backup_privileges() {
1935        const TOKEN_ADJUST_PRIVILEGES: u32 = 0x0020;
1936        const TOKEN_QUERY: u32 = 0x0008;
1937        let mut token: *mut c_void = std::ptr::null_mut();
1938        // SAFETY: the pseudo-handle of this process is always valid; `token` receives a handle
1939        // that is closed below.
1940        let opened = unsafe {
1941            OpenProcessToken(
1942                GetCurrentProcess(),
1943                TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY,
1944                &mut token,
1945            )
1946        };
1947        assert!(opened != 0, "OpenProcessToken failed");
1948        for name in ["SeBackupPrivilege", "SeRestorePrivilege"] {
1949            let wide: Vec<u16> = name.encode_utf16().chain(std::iter::once(0)).collect();
1950            // Count 1; LUID filled in below; no attribute bit set, which disables the privilege
1951            // without removing it.
1952            let mut state: [u32; 4] = [1, 0, 0, 0];
1953            // SAFETY: `wide` is NUL-terminated and outlives the call; words 1 and 2 of `state`
1954            // are room for one LUID.
1955            let found = unsafe {
1956                LookupPrivilegeValueW(std::ptr::null(), wide.as_ptr(), state[1..].as_mut_ptr())
1957            };
1958            if found == 0 {
1959                continue;
1960            }
1961            // SAFETY: `token` was opened with adjust access; `state` is one TOKEN_PRIVILEGES
1962            // record with one entry (16 bytes) and outlives the call; no previous state is
1963            // asked for.
1964            unsafe {
1965                AdjustTokenPrivileges(
1966                    token,
1967                    0,
1968                    state.as_ptr(),
1969                    16,
1970                    std::ptr::null_mut(),
1971                    std::ptr::null_mut(),
1972                );
1973            }
1974        }
1975        // SAFETY: `token` is the handle opened above and is not used again.
1976        unsafe { CloseHandle(token) };
1977    }
1978}
1979
1980#[cfg(test)]
1981mod tests {
1982    use super::*;
1983    use std::io::Write;
1984
1985    /// Set the modification time of a file or directory. Windows opens a directory only with
1986    /// backup semantics, and changing the time needs write-attributes access.
1987    fn set_modified(path: &Path, time: std::time::SystemTime) {
1988        #[cfg(windows)]
1989        let handle = {
1990            use std::os::windows::fs::OpenOptionsExt;
1991            const FILE_WRITE_ATTRIBUTES: u32 = 0x0100;
1992            const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
1993            fs::OpenOptions::new()
1994                .access_mode(FILE_WRITE_ATTRIBUTES)
1995                .custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
1996                .open(path)
1997                .unwrap()
1998        };
1999        #[cfg(not(windows))]
2000        let handle = File::open(path).unwrap();
2001        handle.set_modified(time).unwrap();
2002    }
2003
2004    fn temp_root(label: &str) -> PathBuf {
2005        // `app_temp_dir`, not `std::env::temp_dir`: managed paths refuse symlinks, and the managed
2006        // build's TMPDIR (`/tmp`) is one on macOS.
2007        let root = app_temp_dir().join(format!(
2008            "rkqa-storage-{label}-{}-{}",
2009            std::process::id(),
2010            new_id()
2011        ));
2012        fs::create_dir_all(&root).unwrap();
2013        root
2014    }
2015
2016    fn age(run: &ManagedRun) {
2017        age_at(run, 1);
2018    }
2019
2020    fn age_at(run: &ManagedRun, created_at: u128) {
2021        let mut marker = read_marker(&run.root.join(RUN_MARKER)).unwrap();
2022        marker.created_at = created_at;
2023        marker.owner_pid = 0;
2024        marker.lease_until = 0;
2025        write_marker(&run.root, &marker).unwrap();
2026    }
2027
2028    #[test]
2029    fn newest_five_are_kept_under_byte_cap() {
2030        let root = temp_root("count");
2031        let mut runs = Vec::new();
2032        for i in 0..7 {
2033            let run = create(
2034                Some(&root),
2035                "count",
2036                Some(&format!("run-{i}")),
2037                Duration::from_secs(1),
2038            )
2039            .unwrap();
2040            runs.push(run);
2041        }
2042        for (i, run) in runs.iter().enumerate() {
2043            finish(run).unwrap();
2044            age_at(run, 100 + i as u128);
2045        }
2046        let report = prune(&root, "count", Some(1024 * 1024)).unwrap();
2047        assert_eq!(report.removed.len(), 2);
2048        assert_eq!(report.over_cap_bytes, 0);
2049        assert!(root.join("runs/count/run-5").exists());
2050        assert!(root.join("runs/count/run-6").exists());
2051        let _ = fs::remove_dir_all(root);
2052    }
2053
2054    #[test]
2055    fn young_run_is_protected_when_cap_requires_old_eviction() {
2056        let root = temp_root("young");
2057        let old = create(Some(&root), "young", Some("old"), Duration::from_secs(1)).unwrap();
2058        let young = create(Some(&root), "young", Some("young"), Duration::from_secs(1)).unwrap();
2059        finish(&old).unwrap();
2060        finish(&young).unwrap();
2061        age(&old);
2062        let report = prune(&root, "young", Some(0)).unwrap();
2063        assert!(report.removed.iter().any(|p| p.ends_with("old")));
2064        assert!(root.join("runs/young/young").exists());
2065        assert!(report.over_cap_bytes > 0);
2066        let _ = fs::remove_dir_all(root);
2067    }
2068
2069    #[test]
2070    fn live_pid_and_future_lease_are_protected() {
2071        let root = temp_root("active");
2072        let live = create(Some(&root), "active", Some("live"), Duration::from_secs(1)).unwrap();
2073        let lease = create(
2074            Some(&root),
2075            "active",
2076            Some("lease"),
2077            Duration::from_secs(3600),
2078        )
2079        .unwrap();
2080        let old = create(Some(&root), "active", Some("old"), Duration::from_secs(1)).unwrap();
2081        finish(&lease).unwrap();
2082        let mut lease_marker = read_marker(&lease.root.join(RUN_MARKER)).unwrap();
2083        lease_marker.created_at = 1;
2084        lease_marker.lease_until = now_ms() + 3600 * 1000;
2085        write_marker(&lease.root, &lease_marker).unwrap();
2086        let mut live_marker = read_marker(&live.root.join(RUN_MARKER)).unwrap();
2087        live_marker.created_at = 1;
2088        write_marker(&live.root, &live_marker).unwrap();
2089        finish(&old).unwrap();
2090        age(&old);
2091        let report = prune(&root, "active", Some(0)).unwrap();
2092        assert!(report.removed.iter().any(|p| p.ends_with("old")));
2093        assert!(root.join("runs/active/live").exists());
2094        assert!(root.join("runs/active/lease").exists());
2095        let _ = fs::remove_dir_all(root);
2096    }
2097
2098    #[test]
2099    fn malformed_and_symlink_roots_are_retained_and_reported() {
2100        let root = temp_root("unknown");
2101        let app_root = prepare_app_root(&root, "unknown").unwrap();
2102        let malformed = app_root.join("bad");
2103        fs::create_dir_all(&malformed).unwrap();
2104        fs::write(malformed.join("payload"), b"unknown").unwrap();
2105        #[cfg(unix)]
2106        std::os::unix::fs::symlink(&malformed, app_root.join("link")).unwrap();
2107        let report = prune(&root, "unknown", Some(0)).unwrap();
2108        assert!(report.removed.is_empty());
2109        assert!(report.unknown_bytes >= 7);
2110        assert!(report.retained.iter().any(|(p, _)| p.ends_with("bad")));
2111        #[cfg(unix)]
2112        assert!(report
2113            .retained
2114            .iter()
2115            .any(|(p, reason)| p.ends_with("link") && reason.contains("symlink")));
2116        let _ = fs::remove_dir_all(root);
2117    }
2118
2119    #[test]
2120    fn caller_keep_id_blocks_eviction_and_reports_overage() {
2121        let root = temp_root("keep");
2122        let keep = create(Some(&root), "keep", Some("keep-me"), Duration::from_secs(1)).unwrap();
2123        let remove = create(
2124            Some(&root),
2125            "keep",
2126            Some("remove-me"),
2127            Duration::from_secs(1),
2128        )
2129        .unwrap();
2130        finish(&keep).unwrap();
2131        finish(&remove).unwrap();
2132        age(&keep);
2133        age(&remove);
2134        let report = prune_with_keep(&root, "keep", Some(0), &["keep-me".into()]).unwrap();
2135        assert!(report.removed.iter().any(|p| p.ends_with("remove-me")));
2136        assert!(root.join("runs/keep/keep-me").exists());
2137        assert!(report.over_cap_bytes > 0);
2138        let _ = fs::remove_dir_all(root);
2139    }
2140
2141    #[test]
2142    fn create_refuses_when_protected_data_exceeds_cap() {
2143        let root = temp_root("blocked");
2144        let run = create(
2145            Some(&root),
2146            "blocked",
2147            Some("live"),
2148            Duration::from_secs(3600),
2149        )
2150        .unwrap();
2151        let mut file = File::create(run.root.join("large")).unwrap();
2152        file.set_len(DEFAULT_MAX_BYTES + 1).unwrap();
2153        file.flush().unwrap();
2154        let result = create(Some(&root), "blocked", Some("next"), Duration::from_secs(1));
2155        assert!(result.is_err());
2156        assert!(!root.join("runs/blocked/next").exists());
2157        let _ = fs::remove_dir_all(root);
2158    }
2159
2160    // ---- retention lock (regression: parallel scenarios failed with "lock is held") ----
2161
2162    fn dead_pid() -> u32 {
2163        let mut child = std::process::Command::new(if cfg!(windows) { "cmd" } else { "true" })
2164            .args(if cfg!(windows) {
2165                vec!["/C", "exit"]
2166            } else {
2167                vec![]
2168            })
2169            .spawn()
2170            .unwrap();
2171        let pid = child.id();
2172        child.wait().unwrap();
2173        pid
2174    }
2175
2176    #[test]
2177    fn a_starter_does_not_wait_for_or_fail_on_a_held_prune_lock() {
2178        let root = temp_root("lock-held");
2179        let first = create(Some(&root), "app", Some("one"), Duration::from_secs(60)).unwrap();
2180        let app_root = first.root.parent().unwrap().to_path_buf();
2181        let guard = try_lock(&app_root, Duration::ZERO)
2182            .unwrap()
2183            .expect("lock is free");
2184        let started = std::time::Instant::now();
2185        let second = create(Some(&root), "app", Some("two"), Duration::from_secs(60)).unwrap();
2186        assert!(
2187            started.elapsed() < Duration::from_secs(2),
2188            "starter waited on the lock"
2189        );
2190        assert!(second.root.join(RUN_MARKER).exists());
2191        drop(guard);
2192        let _ = fs::remove_dir_all(&root);
2193    }
2194
2195    #[test]
2196    fn parallel_starters_in_one_process_all_proceed() {
2197        let root = temp_root("lock-threads");
2198        let handles: Vec<_> = (0..8)
2199            .map(|i| {
2200                let root = root.clone();
2201                std::thread::spawn(move || {
2202                    create(
2203                        Some(&root),
2204                        "app",
2205                        Some(&format!("run{i}")),
2206                        Duration::from_secs(60),
2207                    )
2208                    .map(|r| r.root)
2209                })
2210            })
2211            .collect();
2212        for h in handles {
2213            let run = h.join().unwrap().expect("every starter proceeds");
2214            assert!(run.join(RUN_MARKER).exists());
2215        }
2216        let _ = fs::remove_dir_all(&root);
2217    }
2218
2219    /// The state the parallel starters hit by chance, made on purpose: the lock directory is
2220    /// marked for deletion while another handle on it is still open. Creating it is then refused
2221    /// with "access denied". The lock must count as held, and be takeable once the handle closes.
2222    #[cfg(windows)]
2223    #[test]
2224    fn a_lock_directory_that_is_being_removed_counts_as_held() {
2225        use std::os::windows::fs::OpenOptionsExt;
2226        use std::os::windows::io::AsRawHandle;
2227
2228        #[link(name = "kernel32")]
2229        unsafe extern "system" {
2230            fn SetFileInformationByHandle(
2231                file: *mut core::ffi::c_void,
2232                class: u32,
2233                info: *const core::ffi::c_void,
2234                size: u32,
2235            ) -> i32;
2236        }
2237        const DELETE: u32 = 0x0001_0000;
2238        const FILE_READ_ATTRIBUTES: u32 = 0x80;
2239        const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
2240        const SHARE_ALL: u32 = 7;
2241        const FILE_DISPOSITION_INFO: u32 = 4;
2242
2243        let app_root = temp_root("lock-pending");
2244        let lock = app_root.join(".retention-lock");
2245        fs::create_dir(&lock).unwrap();
2246        let open = |access: u32| {
2247            OpenOptions::new()
2248                .access_mode(access)
2249                .share_mode(SHARE_ALL)
2250                .custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
2251                .open(&lock)
2252                .unwrap()
2253        };
2254        let reader = open(FILE_READ_ATTRIBUTES);
2255        let remover = open(DELETE);
2256        let delete: u8 = 1;
2257        // SAFETY: a live handle opened with DELETE access; `delete` is one BOOLEAN, as the
2258        // FILE_DISPOSITION_INFO class takes.
2259        let marked = unsafe {
2260            SetFileInformationByHandle(
2261                remover.as_raw_handle().cast(),
2262                FILE_DISPOSITION_INFO,
2263                (&raw const delete).cast(),
2264                1,
2265            )
2266        };
2267        assert_ne!(marked, 0, "{}", std::io::Error::last_os_error());
2268        drop(remover);
2269
2270        // What the starter saw: the name is still there and cannot be created.
2271        let refused = fs::create_dir(&lock).unwrap_err();
2272        assert_eq!(
2273            refused.kind(),
2274            std::io::ErrorKind::PermissionDenied,
2275            "{refused}"
2276        );
2277        assert!(try_lock(&app_root, Duration::ZERO).unwrap().is_none());
2278        assert!(try_lock(&app_root, Duration::from_millis(100))
2279            .unwrap()
2280            .is_none());
2281
2282        // The last handle closes, the directory goes, and the lock can be taken.
2283        drop(reader);
2284        let guard = try_lock(&app_root, Duration::from_secs(5)).unwrap();
2285        assert!(guard.is_some());
2286        drop(guard);
2287        let _ = fs::remove_dir_all(&app_root);
2288    }
2289
2290    /// Child half of `parallel_starters_in_two_processes_all_proceed`: inert unless driven.
2291    #[test]
2292    fn child_starter_for_the_two_process_test() {
2293        let (Some(root), Some(id)) = (
2294            std::env::var_os("RKQA_TEST_LOCK_ROOT"),
2295            std::env::var_os("RKQA_TEST_LOCK_ID"),
2296        ) else {
2297            return;
2298        };
2299        let started = std::time::Instant::now();
2300        create(
2301            Some(Path::new(&root)),
2302            "app",
2303            id.to_str(),
2304            Duration::from_secs(60),
2305        )
2306        .unwrap();
2307        assert!(started.elapsed() < Duration::from_secs(20));
2308    }
2309
2310    #[test]
2311    fn parallel_starters_in_two_processes_all_proceed() {
2312        let root = temp_root("lock-procs");
2313        let exe = std::env::current_exe().unwrap();
2314        let spawned: Vec<_> = (0..3)
2315            .map(|i| {
2316                std::process::Command::new(&exe)
2317                    .args([
2318                        "--exact",
2319                        "storage::tests::child_starter_for_the_two_process_test",
2320                        "--nocapture",
2321                    ])
2322                    .env("RKQA_TEST_LOCK_ROOT", &root)
2323                    .env("RKQA_TEST_LOCK_ID", format!("par{i}"))
2324                    .stdout(std::process::Stdio::piped())
2325                    .stderr(std::process::Stdio::piped())
2326                    .spawn()
2327                    .unwrap()
2328            })
2329            .collect();
2330        for child in spawned {
2331            let out = child.wait_with_output().unwrap();
2332            assert!(
2333                out.status.success(),
2334                "{}",
2335                String::from_utf8_lossy(&out.stdout)
2336            );
2337        }
2338        for i in 0..3 {
2339            assert!(root
2340                .join("runs/app")
2341                .join(format!("par{i}"))
2342                .join(RUN_MARKER)
2343                .exists());
2344        }
2345        let _ = fs::remove_dir_all(&root);
2346    }
2347
2348    #[test]
2349    fn a_lock_held_by_a_dead_process_is_recovered() {
2350        let root = temp_root("lock-dead");
2351        let app_root = root.join("runs/app");
2352        fs::create_dir_all(app_root.join(".retention-lock")).unwrap();
2353        fs::write(
2354            app_root.join(".retention-lock/owner.json"),
2355            format!("{{\"pid\":{},\"createdAt\":1}}", dead_pid()),
2356        )
2357        .unwrap();
2358        assert!(try_lock(&app_root, Duration::ZERO).unwrap().is_some());
2359        let _ = fs::remove_dir_all(&root);
2360    }
2361
2362    #[test]
2363    fn an_ownerless_lock_is_recovered_only_after_the_grace_period() {
2364        let root = temp_root("lock-ownerless");
2365        let app_root = root.join("runs/app");
2366        let lock = app_root.join(".retention-lock");
2367        fs::create_dir_all(&lock).unwrap();
2368        assert!(
2369            try_lock(&app_root, Duration::ZERO).unwrap().is_none(),
2370            "fresh: owner may be mid-write"
2371        );
2372        let old = std::time::SystemTime::now() - Duration::from_secs(120);
2373        set_modified(&lock, old);
2374        assert!(try_lock(&app_root, Duration::ZERO).unwrap().is_some());
2375        let _ = fs::remove_dir_all(&root);
2376    }
2377
2378    #[test]
2379    fn an_explicit_prune_that_cannot_get_the_lock_fails_with_the_typed_error() {
2380        let root = temp_root("lock-typed");
2381        let app_root = root.join("runs/app");
2382        fs::create_dir_all(&app_root).unwrap();
2383        let _guard = try_lock(&app_root, Duration::ZERO).unwrap().unwrap();
2384        let error = lock_or_wait(&app_root, Duration::from_millis(60))
2385            .err()
2386            .expect("held");
2387        assert!(is_lock_held(&error), "{error}");
2388        let _ = fs::remove_dir_all(&root);
2389    }
2390
2391    // ---- symlink guard ----
2392
2393    #[cfg(unix)]
2394    #[test]
2395    fn a_symlink_above_the_root_is_resolved_and_a_link_below_it_is_refused() {
2396        let base = fs::canonicalize(temp_root("symlink-guard")).unwrap();
2397        let real = base.join("real");
2398        fs::create_dir_all(&real).unwrap();
2399        let alias = base.join("alias");
2400        std::os::unix::fs::symlink(&real, &alias).unwrap();
2401
2402        // The managed root sits behind an alias, the way `/var/folders/...` sits behind `/var`.
2403        let run = create(
2404            Some(&alias.join("managed")),
2405            "app",
2406            Some("alias-run"),
2407            Duration::from_secs(60),
2408        )
2409        .unwrap();
2410        assert!(run.root.starts_with(&real), "{}", run.root.display());
2411
2412        // A link below the root is still refused.
2413        let elsewhere = base.join("elsewhere");
2414        fs::create_dir_all(&elsewhere).unwrap();
2415        let managed = real.join("managed");
2416        std::os::unix::fs::symlink(&elsewhere, managed.join("runs/linked")).unwrap();
2417        let e = ensure_dir(&managed.join("runs/linked/deeper")).unwrap_err();
2418        assert!(e.0.contains("refusing symlink"), "{}", e.0);
2419        let _ = fs::remove_dir_all(&base);
2420    }
2421
2422    // ---- app home sweep ----
2423
2424    fn homes_parent(label: &str) -> (PathBuf, PathBuf) {
2425        let root = temp_root(label);
2426        let parent = fs::canonicalize(&root).unwrap().join("rightkit-qa");
2427        fs::create_dir_all(&parent).unwrap();
2428        (root, parent)
2429    }
2430
2431    fn home_for(root: &Path, parent: &Path, run_id: &str, payload: usize) -> (ManagedRun, PathBuf) {
2432        home_of(root, parent, "app", run_id, payload)
2433    }
2434
2435    /// A home of the product `app` with a dead owner recorded in its marker.
2436    fn home_of(
2437        root: &Path,
2438        parent: &Path,
2439        app: &str,
2440        run_id: &str,
2441        payload: usize,
2442    ) -> (ManagedRun, PathBuf) {
2443        let mut run = create(Some(root), app, Some(run_id), Duration::from_secs(60)).unwrap();
2444        let home = create_app_root_in(&mut run, parent).unwrap();
2445        // The sweep tests model homes of other, finished processes: record a dead owner.
2446        set_home_marker(&home, 0, now_ms());
2447        fs::write(home.join("payload.bin"), vec![0u8; payload]).unwrap();
2448        (run, home)
2449    }
2450
2451    /// A home written before the owner marker existed: binding and run record, no
2452    /// `.rightkit-home.json`. The run record still names this process, which is alive.
2453    fn unmarked_home(
2454        root: &Path,
2455        parent: &Path,
2456        app: &str,
2457        run_id: &str,
2458        payload: usize,
2459    ) -> (ManagedRun, PathBuf) {
2460        let (run, home) = home_of(root, parent, app, run_id, payload);
2461        fs::remove_file(home.join(HOME_MARKER)).unwrap();
2462        (run, home)
2463    }
2464
2465    /// Set the owner and lease the run record of `run` names.
2466    fn record_owner(run: &ManagedRun, pid: u32, lease_until: u128) {
2467        let mut marker = read_marker(&run.root.join(RUN_MARKER)).unwrap();
2468        marker.owner_pid = pid;
2469        marker.lease_until = lease_until;
2470        write_marker(&run.root, &marker).unwrap();
2471    }
2472
2473    /// Set the modification time of everything under `dir`, `dir` included, to `age` ago.
2474    /// Symlinks are left alone (setting a time would follow them).
2475    fn age_tree(dir: &Path, age: Duration) {
2476        let time = std::time::SystemTime::now() - age;
2477        let mut all = vec![dir.to_path_buf()];
2478        let mut stack = vec![dir.to_path_buf()];
2479        while let Some(d) = stack.pop() {
2480            for entry in fs::read_dir(&d).unwrap().flatten() {
2481                let ft = entry.file_type().unwrap();
2482                if ft.is_symlink() {
2483                    continue;
2484                }
2485                if ft.is_dir() {
2486                    stack.push(entry.path());
2487                }
2488                all.push(entry.path());
2489            }
2490        }
2491        for path in all {
2492            set_modified(&path, time);
2493        }
2494    }
2495
2496    fn set_home_marker(home: &Path, pid: u32, started_at_ms: u128) {
2497        fs::write(
2498            home.join(HOME_MARKER),
2499            serde_json::to_vec(&serde_json::json!({
2500                "schemaVersion": 1, "pid": pid, "startedAtMs": started_at_ms as u64,
2501                "app": "app", "runId": "x",
2502            }))
2503            .unwrap(),
2504        )
2505        .unwrap();
2506    }
2507
2508    fn make_finished(run: &ManagedRun, completed_at: u128) {
2509        let mut marker = read_marker(&run.root.join(RUN_MARKER)).unwrap();
2510        marker.owner_pid = 0;
2511        marker.lease_until = 0;
2512        marker.completed_at = Some(completed_at);
2513        write_marker(&run.root, &marker).unwrap();
2514    }
2515
2516    fn make_old(path: &Path) {
2517        let old = std::time::SystemTime::now() - Duration::from_secs(3 * 24 * 3600);
2518        set_modified(path, old);
2519    }
2520
2521    #[test]
2522    fn sweep_removes_old_orphans_and_finished_homes_and_spares_the_rest() {
2523        let (root, parent) = homes_parent("sweep");
2524        let day = Duration::from_secs(24 * 3600);
2525        let policy = HomePolicy {
2526            young: day,
2527            max_bytes: u64::MAX,
2528        };
2529
2530        // Orphan (run record deleted), old: removed.
2531        let (orphan_run, orphan_old) = home_for(&root, &parent, "orphan-old", 1000);
2532        fs::remove_dir_all(&orphan_run.root).unwrap();
2533        make_old(&orphan_old.join(".rightkit-app.json"));
2534        make_old(&orphan_old);
2535        // Orphan, young: kept (liveness unknown).
2536        let (orphan_run2, orphan_young) = home_for(&root, &parent, "orphan-young", 1000);
2537        fs::remove_dir_all(&orphan_run2.root).unwrap();
2538        // Finished, old: removed.
2539        let (done_run, done_old) = home_for(&root, &parent, "done-old", 1000);
2540        make_finished(&done_run, 1);
2541        // Finished, just now: kept.
2542        let (done_run2, done_young) = home_for(&root, &parent, "done-young", 1000);
2543        make_finished(&done_run2, now_ms());
2544        // Active (this process owns it): kept even if the home looks old.
2545        let (_active_run, active) = home_for(&root, &parent, "active", 1000);
2546        make_old(&active);
2547        // Not ours (no binding): kept.
2548        let stranger = parent.join("stranger-0000000000000000");
2549        fs::create_dir_all(&stranger).unwrap();
2550        fs::write(stranger.join("x"), b"x").unwrap();
2551
2552        let dry = sweep_homes(&parent, policy, true);
2553        assert_eq!(dry.removed.len(), 2, "{dry:?}");
2554        assert!(
2555            orphan_old.exists() && done_old.exists(),
2556            "dry run deletes nothing"
2557        );
2558
2559        let report = sweep_homes(&parent, policy, false);
2560        assert_eq!(report.removed.len(), 2, "{report:?}");
2561        assert!(!orphan_old.exists() && !done_old.exists());
2562        for kept in [&orphan_young, &done_young, &active, &stranger] {
2563            assert!(kept.exists(), "{} must survive", kept.display());
2564        }
2565        assert!(report.removed_bytes >= 2000);
2566        let _ = fs::remove_dir_all(&root);
2567    }
2568
2569    #[test]
2570    fn sweep_enforces_the_size_bound_on_finished_homes_oldest_first() {
2571        let (root, parent) = homes_parent("sweep-size");
2572        let policy = HomePolicy {
2573            young: Duration::from_secs(24 * 3600),
2574            max_bytes: 5_000_000,
2575        };
2576        let (r1, h1) = home_for(&root, &parent, "a", 3_000_000);
2577        let (r2, h2) = home_for(&root, &parent, "b", 3_000_000);
2578        let (r3, h3) = home_for(&root, &parent, "c", 3_000_000);
2579        let now = now_ms();
2580        make_finished(&r1, now - 3000);
2581        make_finished(&r2, now - 2000);
2582        make_finished(&r3, now - 1000);
2583        let report = sweep_homes(&parent, policy, false);
2584        assert!(!h1.exists() && !h2.exists(), "{report:?}");
2585        assert!(h3.exists(), "the newest finished home fits the bound");
2586        let _ = fs::remove_dir_all(&root);
2587    }
2588
2589    #[test]
2590    fn sweep_keeps_finished_homes_when_the_size_bound_cannot_be_met_by_removing_them() {
2591        let (root, parent) = homes_parent("sweep-unmet");
2592        let policy = HomePolicy {
2593            young: Duration::from_secs(24 * 3600),
2594            max_bytes: 5_000_000,
2595        };
2596        let (_live, live_home) = home_for(&root, &parent, "live", 6_000_000);
2597        let (done, done_home) = home_for(&root, &parent, "done", 1_000_000);
2598        make_finished(&done, now_ms());
2599        let report = sweep_homes(&parent, policy, false);
2600        assert!(report.removed.is_empty(), "{report:?}");
2601        assert!(live_home.exists() && done_home.exists());
2602        let _ = fs::remove_dir_all(&root);
2603    }
2604
2605    #[test]
2606    fn sweep_refuses_a_parent_that_is_not_the_homes_directory() {
2607        let root = temp_root("sweep-parent");
2608        let victim = root.join("elsewhere");
2609        fs::create_dir_all(victim.join("h")).unwrap();
2610        let report = sweep_homes(&victim, HomePolicy::default(), false);
2611        assert!(report.removed.is_empty());
2612        assert!(victim.join("h").exists());
2613        let _ = fs::remove_dir_all(&root);
2614    }
2615
2616    #[test]
2617    fn home_root_override_must_be_absolute() {
2618        use std::ffi::OsStr;
2619        // A rooted path without a drive is not absolute on Windows.
2620        let absolute = if cfg!(windows) {
2621            r"D:\qa-homes"
2622        } else {
2623            "/Volumes/D/qa-homes"
2624        };
2625        assert_eq!(
2626            home_base_from(Some(OsStr::new(absolute))),
2627            PathBuf::from(absolute)
2628        );
2629        assert_eq!(
2630            home_base_from(Some(OsStr::new("relative/dir"))),
2631            app_temp_dir()
2632        );
2633        assert_eq!(home_base_from(Some(OsStr::new(""))), app_temp_dir());
2634        assert_eq!(home_base_from(None), app_temp_dir());
2635    }
2636
2637    // ---- dead-home clean, cap, and containment ----
2638
2639    #[test]
2640    fn a_created_home_records_its_owner_and_start() {
2641        let (root, parent) = homes_parent("home-marker");
2642        let (_run, home) = home_for(&root, &parent, "m", 10);
2643        // `home_for` overwrites it with a dead owner; create one untouched.
2644        let mut run = create(Some(&root), "app", Some("m2"), Duration::from_secs(60)).unwrap();
2645        let fresh = create_app_root_in(&mut run, &parent).unwrap();
2646        let (pid, started) = read_home_marker(&fresh).unwrap();
2647        assert_eq!(pid, std::process::id());
2648        assert!(now_ms().saturating_sub(started) < 60_000);
2649        assert!(home.exists());
2650        let _ = fs::remove_dir_all(&root);
2651    }
2652
2653    #[test]
2654    fn clean_removes_dead_old_homes_and_never_a_live_one() {
2655        let (root, parent) = homes_parent("clean");
2656        let window = Duration::from_secs(2 * 3600);
2657        let old = now_ms() - 3 * 3600 * 1000;
2658        let (_r1, dead_old) = home_for(&root, &parent, "dead-old", 100);
2659        set_home_marker(&dead_old, 0, old);
2660        let (_r2, dead_young) = home_for(&root, &parent, "dead-young", 100);
2661        set_home_marker(&dead_young, 0, now_ms());
2662        let (_r3, alive_old) = home_for(&root, &parent, "alive-old", 100);
2663        set_home_marker(&alive_old, std::process::id(), old);
2664        let stranger = parent.join("stranger-0000000000000000");
2665        fs::create_dir_all(&stranger).unwrap();
2666        fs::write(stranger.join("x"), b"x").unwrap();
2667
2668        let dry = clean_homes(&parent, window, true);
2669        assert_eq!(dry.removed.len(), 1, "{dry:?}");
2670        assert!(dead_old.exists(), "dry run deletes nothing");
2671        let report = clean_homes(&parent, window, false);
2672        assert_eq!(report.removed.len(), 1, "{report:?}");
2673        assert!(!dead_old.exists());
2674        for kept in [&dead_young, &alive_old, &stranger] {
2675            assert!(kept.exists(), "{} must survive", kept.display());
2676        }
2677        let _ = fs::remove_dir_all(&root);
2678    }
2679
2680    #[test]
2681    fn clean_refuses_a_parent_that_is_not_the_qa_root() {
2682        let root = temp_root("clean-parent");
2683        let victim = root.join("elsewhere");
2684        fs::create_dir_all(victim.join("h")).unwrap();
2685        assert!(clean_homes(&victim, Duration::ZERO, false)
2686            .removed
2687            .is_empty());
2688        assert!(victim.join("h").exists());
2689        let _ = fs::remove_dir_all(&root);
2690    }
2691
2692    #[cfg(unix)]
2693    #[test]
2694    fn clean_never_follows_a_symlink_out_of_the_root() {
2695        let (root, parent) = homes_parent("clean-symlink");
2696        let outside = root.join("outside");
2697        fs::create_dir_all(&outside).unwrap();
2698        fs::write(outside.join("precious"), b"keep").unwrap();
2699        // A link inside a doomed home that points out of the root.
2700        let (_r, doomed) = home_for(&root, &parent, "doomed", 10);
2701        set_home_marker(&doomed, 0, 1);
2702        std::os::unix::fs::symlink(&outside, doomed.join("link-out")).unwrap();
2703        // A link directly in the QA root that points out of it.
2704        std::os::unix::fs::symlink(&outside, parent.join("link-home")).unwrap();
2705        let report = clean_homes(&parent, Duration::ZERO, false);
2706        assert!(!doomed.exists(), "{report:?}");
2707        assert_eq!(fs::read(outside.join("precious")).unwrap(), b"keep");
2708        assert!(fs::symlink_metadata(parent.join("link-home")).is_ok());
2709        // `remove_home_dir` refuses a symlinked or foreign path outright.
2710        let canonical_parent = fs::canonicalize(&parent).unwrap();
2711        assert!(remove_home_dir(&parent.join("link-home"), &canonical_parent).is_err());
2712        assert!(remove_home_dir(&outside, &canonical_parent).is_err());
2713        assert!(outside.join("precious").exists());
2714        let _ = fs::remove_dir_all(&root);
2715    }
2716
2717    // ---- per-product cap, root ceiling, setters ----
2718
2719    #[test]
2720    fn another_products_homes_never_block_a_run() {
2721        let (root, parent) = homes_parent("cap-other");
2722        for i in 0..3 {
2723            home_of(&root, &parent, "genright", &format!("g{i}"), 1_000_000);
2724        }
2725        home_of(&root, &parent, "cutright", "c0", 1_000_000);
2726        let (_p, post) = home_of(&root, &parent, "postright", "p0", 100_000);
2727        // genright and cutright hold 4 MB between them; none of it counts against postright.
2728        check_root_cap(&parent, "postright", 2_000_000).unwrap();
2729        check_root_limits(&parent, "postright", 2_000_000, 0).unwrap();
2730        assert!(post.exists());
2731        let _ = fs::remove_dir_all(&root);
2732    }
2733
2734    #[test]
2735    fn own_product_over_the_cap_refuses_and_names_own_homes() {
2736        let (root, parent) = homes_parent("cap-own");
2737        let (_a, big) = home_of(&root, &parent, "genright", "g-big", 3_000_000);
2738        let (_b, small) = home_of(&root, &parent, "genright", "g-small", 1_000_000);
2739        let (_c, other) = home_of(&root, &parent, "cutright", "c0", 9_000_000);
2740        check_root_cap(&parent, "genright", 10_000_000).unwrap();
2741        check_root_cap(&parent, "genright", 0).unwrap();
2742        let e = check_root_cap(&parent, "genright", 2_000_000).unwrap_err();
2743        assert_eq!(e.kind, RootLimitKind::ProductCap);
2744        assert_eq!(e.app, "genright");
2745        assert!(
2746            e.total_bytes >= 4_000_000 && e.total_bytes < 5_000_000,
2747            "{}",
2748            e.total_bytes
2749        );
2750        assert_eq!(e.home_count, 2);
2751        assert_eq!(e.limit_bytes, 2_000_000);
2752        assert_eq!(e.largest.len(), 2);
2753        assert_eq!(e.largest[0].0, big);
2754        assert!(e
2755            .largest
2756            .iter()
2757            .any(|(p, _, product)| p == &small && product == "genright"));
2758        assert!(e.largest.iter().all(|(p, _, _)| p != &other));
2759        let text = crate::util::Error::from(e).to_string();
2760        assert!(
2761            text.contains("genright")
2762                && text.contains(&big.display().to_string())
2763                && text.contains(ROOT_CAP_ENV)
2764                && text.contains("rightkit-qa clean"),
2765            "{text}"
2766        );
2767        assert!(!text.contains(&other.display().to_string()), "{text}");
2768        let _ = fs::remove_dir_all(&root);
2769    }
2770
2771    #[test]
2772    fn a_home_without_a_binding_counts_for_the_product_in_its_name() {
2773        let (root, parent) = homes_parent("cap-unbound");
2774        let unbound = parent.join("cutright-0123456789abcdef");
2775        fs::create_dir_all(&unbound).unwrap();
2776        fs::write(unbound.join("blob"), vec![0u8; 3_000_000]).unwrap();
2777        check_root_cap(&parent, "genright", 1_000_000).unwrap();
2778        let e = check_root_cap(&parent, "cutright", 1_000_000).unwrap_err();
2779        assert_eq!(e.largest[0].0, unbound);
2780        assert_eq!(e.largest[0].2, "cutright");
2781        let _ = fs::remove_dir_all(&root);
2782    }
2783
2784    #[test]
2785    fn root_ceiling_names_the_largest_homes_with_their_product() {
2786        let (root, parent) = homes_parent("ceiling");
2787        let (_a, g) = home_of(&root, &parent, "genright", "g0", 3_000_000);
2788        let (_b, c) = home_of(&root, &parent, "cutright", "c0", 2_000_000);
2789        home_of(&root, &parent, "viewright", "v0", 1_000_000);
2790        // Under the ceiling, or no ceiling: fine. The per-product cap is off here.
2791        check_root_limits(&parent, "viewright", 0, 10_000_000).unwrap();
2792        check_root_limits(&parent, "viewright", 0, 0).unwrap();
2793        let e = check_root_limits(&parent, "viewright", 0, 5_000_000).unwrap_err();
2794        assert_eq!(e.kind, RootLimitKind::RootCeiling);
2795        assert!(e.total_bytes >= 6_000_000 && e.limit_bytes == 5_000_000);
2796        assert_eq!(e.home_count, 3);
2797        assert_eq!(e.largest[0].0, g);
2798        assert_eq!(e.largest[0].2, "genright");
2799        assert_eq!(e.largest[1].0, c);
2800        assert_eq!(e.largest[1].2, "cutright");
2801        let text = crate::util::Error::from(e).to_string();
2802        assert!(
2803            text.contains("genright")
2804                && text.contains("cutright")
2805                && text.contains(ROOT_CEILING_ENV)
2806                && text.contains("rightkit-qa clean"),
2807            "{text}"
2808        );
2809        // A product over its own cap is told about its cap first.
2810        let e = check_root_limits(&parent, "genright", 2_000_000, 5_000_000).unwrap_err();
2811        assert_eq!(e.kind, RootLimitKind::ProductCap);
2812        let _ = fs::remove_dir_all(&root);
2813    }
2814
2815    #[test]
2816    fn limits_set_in_code_win_over_the_environment_and_the_environment_over_the_default() {
2817        let (root, parent) = homes_parent("limits-precedence");
2818        home_of(&root, &parent, "genright", "g0", 3_000_000);
2819        let tiny_env = |key: &str| match key {
2820            ROOT_CAP_ENV | ROOT_CEILING_ENV => Some("1".to_string()),
2821            _ => None,
2822        };
2823        let disabled_env = |_: &str| Some("0".to_string());
2824        let empty_env = |_: &str| None::<String>;
2825
2826        // The environment alone (1 byte) refuses.
2827        assert!(
2828            enforce_limits_with(&parent, "genright", RootLimits::default(), &tiny_env).is_err()
2829        );
2830        // A limit set in code wins over it, in either direction.
2831        let roomy = RootLimits::default()
2832            .with_cap_bytes(10_000_000)
2833            .with_ceiling_bytes(10_000_000);
2834        enforce_limits_with(&parent, "genright", roomy, &tiny_env).unwrap();
2835        let off = RootLimits::default()
2836            .with_cap_bytes(0)
2837            .with_ceiling_bytes(0);
2838        enforce_limits_with(&parent, "genright", off, &tiny_env).unwrap();
2839        let tight = RootLimits::default().with_cap_bytes(1_000_000);
2840        let e = enforce_limits_with(&parent, "genright", tight, &disabled_env).unwrap_err();
2841        assert_eq!(e.kind, RootLimitKind::ProductCap);
2842        // One limit set in code leaves the other to the environment.
2843        let cap_only = RootLimits::default().with_cap_bytes(0);
2844        let e = enforce_limits_with(&parent, "genright", cap_only, &tiny_env).unwrap_err();
2845        assert_eq!(e.kind, RootLimitKind::RootCeiling);
2846        // Defaults when neither says anything, or the environment is not a number.
2847        let defaults = ResolvedRootLimits {
2848            cap_bytes: DEFAULT_ROOT_CAP_BYTES,
2849            ceiling_bytes: DEFAULT_ROOT_CEILING_BYTES,
2850        };
2851        assert_eq!(RootLimits::default().resolve_with(&empty_env), defaults);
2852        let junk_env = |_: &str| Some("lots".to_string());
2853        assert_eq!(RootLimits::default().resolve_with(&junk_env), defaults);
2854        assert_eq!(DEFAULT_ROOT_CAP_BYTES, 4_000_000_000);
2855        assert_eq!(DEFAULT_ROOT_CEILING_BYTES, 32_000_000_000);
2856        let _ = fs::remove_dir_all(&root);
2857    }
2858
2859    // ---- home names and products ----
2860
2861    #[test]
2862    fn the_home_name_pattern_matches_what_the_runners_write() {
2863        assert_eq!(
2864            parse_home_name("genright-0123456789abcdef"),
2865            Some("genright")
2866        );
2867        assert_eq!(
2868            parse_home_name("scrape-right-0123456789abcdef"),
2869            Some("scrape-right")
2870        );
2871        // `@rightkit/qa` names homes with `mkdtemp`: six letters and digits.
2872        assert_eq!(parse_home_name("viewright-aB3dE9"), Some("viewright"));
2873        for bad in [
2874            "stranger",
2875            "precious-data",
2876            "x-0123456789ABCDEF",
2877            "x-0123456789abcde",
2878            "-0123456789abcdef",
2879            "a-b-c",
2880            ".hidden-0123456789abcdef",
2881            "waytoolonglabelname-0123456789abcdef",
2882            "my dir-0123456789abcdef",
2883        ] {
2884            assert_eq!(parse_home_name(bad), None, "{bad}");
2885        }
2886        // A home this crate creates matches its own pattern, and a long app key is cut to 16.
2887        let (root, parent) = homes_parent("name-pattern");
2888        let key = "longappnamewithmorethan16chars";
2889        let mut run = create(Some(&root), key, Some("n"), Duration::from_secs(60)).unwrap();
2890        let home = create_app_root_in(&mut run, &parent).unwrap();
2891        let name = home.file_name().unwrap().to_string_lossy().into_owned();
2892        assert_eq!(parse_home_name(&name), Some("longappnamewithm"));
2893        assert!(same_product(key, "longappnamewithm"));
2894        assert!(!same_product("genright", "cutright"));
2895        let _ = fs::remove_dir_all(&root);
2896    }
2897
2898    #[test]
2899    fn a_homes_product_comes_from_its_binding_then_its_name() {
2900        let (root, parent) = homes_parent("product");
2901        let (_r, bound) = home_of(&root, &parent, "genright", "g0", 1);
2902        let name = bound.file_name().unwrap().to_string_lossy().into_owned();
2903        let binding = read_binding(&bound);
2904        assert_eq!(home_product(&name, binding.as_ref()), "genright");
2905        // The binding wins over a name that says otherwise.
2906        assert_eq!(
2907            home_product("cutright-0123456789abcdef", binding.as_ref()),
2908            "genright"
2909        );
2910        // No binding: the label in the name; an unrecognised name stands for itself.
2911        assert_eq!(home_product("cutright-0123456789abcdef", None), "cutright");
2912        assert_eq!(home_product("stranger", None), "stranger");
2913        // A binding file that is not a JSON object is no binding.
2914        fs::write(bound.join(BINDING_FILE), b"[1]").unwrap();
2915        assert!(read_binding(&bound).is_none());
2916        let _ = fs::remove_dir_all(&root);
2917    }
2918
2919    // ---- homes without an owner marker ----
2920
2921    const WINDOW: Duration = Duration::from_secs(2 * 3600);
2922    const THREE_HOURS: Duration = Duration::from_secs(3 * 3600);
2923
2924    #[test]
2925    fn a_dead_unmarked_home_is_swept_and_the_sweep_unblocks_its_product() {
2926        let (root, parent) = homes_parent("unmarked-dead");
2927        // The run record names a dead owner but its lease is still running: the 24 hour lease is
2928        // what kept cancelled runs alive before.
2929        let (run, with_record) = unmarked_home(&root, &parent, "genright", "rec", 1_000_000);
2930        record_owner(&run, dead_pid(), now_ms() + 3600 * 1000);
2931        // The run record is gone.
2932        let (gone, no_record) = unmarked_home(&root, &parent, "genright", "gone", 1_000_000);
2933        fs::remove_dir_all(&gone.root).unwrap();
2934        // No binding either: only the name says whose it is.
2935        let (_u, no_binding) = unmarked_home(&root, &parent, "cutright", "nb", 1_000_000);
2936        fs::remove_file(no_binding.join(BINDING_FILE)).unwrap();
2937        for home in [&with_record, &no_record, &no_binding] {
2938            age_tree(home, THREE_HOURS);
2939        }
2940
2941        let dry = clean_homes(&parent, WINDOW, true);
2942        assert_eq!(dry.removed.len(), 3, "{dry:?}");
2943        assert!(
2944            with_record.exists() && no_record.exists() && no_binding.exists(),
2945            "dry run deletes nothing"
2946        );
2947        // They count against genright until swept, and not after.
2948        check_root_cap(&parent, "genright", 1_500_000).unwrap_err();
2949        let report = clean_homes(&parent, WINDOW, false);
2950        assert_eq!(report.removed.len(), 3, "{report:?}");
2951        assert!(report
2952            .removed
2953            .iter()
2954            .all(|(_, _, why)| why.contains("no owner marker")));
2955        assert!(!with_record.exists() && !no_record.exists() && !no_binding.exists());
2956        check_root_cap(&parent, "genright", 1_500_000).unwrap();
2957        let _ = fs::remove_dir_all(&root);
2958    }
2959
2960    #[test]
2961    fn a_young_unmarked_home_is_kept_and_so_is_one_with_a_recently_written_file() {
2962        let (root, parent) = homes_parent("unmarked-young");
2963        // Just created: nothing is old.
2964        let (r1, fresh) = unmarked_home(&root, &parent, "genright", "fresh", 1000);
2965        record_owner(&r1, dead_pid(), 0);
2966        // An old tree with one recently written file deep inside (a live app's log).
2967        let (r2, busy) = unmarked_home(&root, &parent, "genright", "busy", 1000);
2968        record_owner(&r2, dead_pid(), 0);
2969        fs::create_dir_all(busy.join("data/deep")).unwrap();
2970        fs::write(busy.join("data/deep/app.log"), b"x").unwrap();
2971        age_tree(&busy, THREE_HOURS);
2972        fs::write(busy.join("data/deep/app.log"), b"y").unwrap();
2973
2974        let report = clean_homes(&parent, WINDOW, false);
2975        assert!(report.removed.is_empty(), "{report:?}");
2976        assert!(fresh.exists() && busy.exists());
2977        for kept in [&fresh, &busy] {
2978            assert!(
2979                report
2980                    .retained
2981                    .iter()
2982                    .any(|(p, why)| p == kept && why.contains("modified within the window")),
2983                "{kept:?}: {report:?}"
2984            );
2985        }
2986        let _ = fs::remove_dir_all(&root);
2987    }
2988
2989    #[test]
2990    fn an_unmarked_home_with_a_live_recorded_pid_is_kept() {
2991        let (root, parent) = homes_parent("unmarked-live");
2992        // `create` records this process as the run's owner, and this process is alive.
2993        let (run, home) = unmarked_home(&root, &parent, "genright", "live", 1000);
2994        age_tree(&home, THREE_HOURS);
2995        let report = clean_homes(&parent, WINDOW, false);
2996        assert!(home.exists(), "{report:?}");
2997        assert!(
2998            report
2999                .retained
3000                .iter()
3001                .any(|(p, why)| p == &home && why.contains("is alive")),
3002            "{report:?}"
3003        );
3004        // Once the record names a dead process the home goes, lease or not.
3005        record_owner(&run, dead_pid(), now_ms() + 3600 * 1000);
3006        let report = clean_homes(&parent, WINDOW, false);
3007        assert!(!home.exists(), "{report:?}");
3008
3009        // A pid in a home marker that has no binding beside it counts the same way.
3010        let (_r, orphan) = unmarked_home(&root, &parent, "cutright", "marked", 1000);
3011        fs::remove_file(orphan.join(BINDING_FILE)).unwrap();
3012        set_home_marker(&orphan, std::process::id(), 1);
3013        age_tree(&orphan, THREE_HOURS);
3014        clean_homes(&parent, WINDOW, false);
3015        assert!(orphan.exists(), "live pid in the home marker");
3016        set_home_marker(&orphan, dead_pid(), 1);
3017        age_tree(&orphan, THREE_HOURS);
3018        clean_homes(&parent, WINDOW, false);
3019        assert!(!orphan.exists(), "dead pid in the home marker");
3020        let _ = fs::remove_dir_all(&root);
3021    }
3022
3023    #[test]
3024    fn an_unmarked_directory_that_is_not_named_like_a_home_is_kept() {
3025        let (root, parent) = homes_parent("unmarked-foreign");
3026        let names = ["precious-data", "stranger", "x-0123456789ABCDEF"];
3027        for name in names {
3028            let dir = parent.join(name);
3029            fs::create_dir_all(dir.join("sub")).unwrap();
3030            fs::write(dir.join("sub/f"), b"keep").unwrap();
3031            age_tree(&dir, THREE_HOURS);
3032        }
3033        let report = clean_homes(&parent, WINDOW, false);
3034        assert!(report.removed.is_empty(), "{report:?}");
3035        for name in names {
3036            assert!(parent.join(name).join("sub/f").exists(), "{name}");
3037        }
3038        assert!(report
3039            .retained
3040            .iter()
3041            .any(|(p, why)| p.ends_with("precious-data") && why.contains("not a QA home name")));
3042        let _ = fs::remove_dir_all(&root);
3043    }
3044
3045    #[cfg(unix)]
3046    #[test]
3047    fn a_symlinked_entry_named_like_a_home_is_skipped() {
3048        let (root, parent) = homes_parent("unmarked-symlink");
3049        let outside = root.join("outside");
3050        fs::create_dir_all(outside.join("sub")).unwrap();
3051        fs::write(outside.join("sub/precious"), b"keep").unwrap();
3052        age_tree(&outside, THREE_HOURS);
3053        // A link directly in the root, named like a home, pointing out of it.
3054        let link = parent.join("genright-0123456789abcdef");
3055        std::os::unix::fs::symlink(&outside, &link).unwrap();
3056        // A doomed unmarked home that holds a link out of the root.
3057        let (run, doomed) = unmarked_home(&root, &parent, "genright", "doomed", 10);
3058        record_owner(&run, dead_pid(), 0);
3059        std::os::unix::fs::symlink(&outside, doomed.join("link-out")).unwrap();
3060        // A zero window: a link's own modification time cannot be aged here.
3061        let report = clean_homes(&parent, Duration::ZERO, false);
3062        assert!(!doomed.exists(), "{report:?}");
3063        assert_eq!(fs::read(outside.join("sub/precious")).unwrap(), b"keep");
3064        assert!(fs::symlink_metadata(&link).is_ok());
3065        assert!(report
3066            .retained
3067            .iter()
3068            .any(|(p, why)| p == &link && why.contains("symlink")));
3069        let _ = fs::remove_dir_all(&root);
3070    }
3071
3072    // ---- home removal (gap ledger 216) ----
3073
3074    #[cfg(windows)]
3075    #[test]
3076    fn a_home_with_a_junction_that_denies_listing_is_removed() {
3077        let (root, parent) = homes_parent("rm-deny");
3078        let (_r, home) = home_for(&root, &parent, "deny", 10);
3079        let link = test_links::wininet_cache(&home);
3080        let target = link.parent().unwrap().join("IE");
3081        assert!(target.join("container.dat").exists());
3082        remove_home_tree(&parent, &home).unwrap();
3083        assert!(!home.exists());
3084        let _ = fs::remove_dir_all(&root);
3085    }
3086
3087    #[cfg(windows)]
3088    #[test]
3089    fn std_remove_dir_all_stops_at_a_junction_that_denies_listing() {
3090        // Why `remove_home_tree` exists. If this starts to fail because the standard library
3091        // learned to remove such a tree, the comment on `remove_home_tree` is out of date; the
3092        // routine is still needed for the binding-file order and the root check.
3093        let (root, parent) = homes_parent("rm-std");
3094        let (_r, home) = home_for(&root, &parent, "std", 10);
3095        test_links::wininet_cache(&home);
3096        let outcome = fs::remove_dir_all(&home);
3097        eprintln!("[qa-home-removal] std::fs::remove_dir_all on the WinINet shape: {outcome:?}");
3098        assert!(outcome.is_err(), "{outcome:?}");
3099        assert!(home.exists());
3100        // The routine finishes what the standard library left.
3101        remove_home_tree(&parent, &home).unwrap();
3102        assert!(!home.exists());
3103        let _ = fs::remove_dir_all(&root);
3104    }
3105
3106    #[cfg(windows)]
3107    #[test]
3108    fn a_junction_out_of_the_home_is_removed_and_what_it_points_at_stays() {
3109        let (root, parent) = homes_parent("rm-models");
3110        // Outside the home and outside the QA root, as the shared models cache is.
3111        let models = root.join("models-cache");
3112        fs::create_dir_all(models.join("whisper")).unwrap();
3113        let weights: Vec<u8> = (0..=255u8).cycle().take(70_000).collect();
3114        fs::write(models.join("whisper").join("model.bin"), &weights).unwrap();
3115        fs::write(models.join("index.json"), b"{\"models\":1}").unwrap();
3116        let (_r, home) = home_for(&root, &parent, "models", 10);
3117        fs::create_dir_all(home.join("data").join("shared")).unwrap();
3118        test_links::junction(&home.join("data").join("shared").join("models"), &models);
3119        assert_eq!(
3120            fs::read(
3121                home.join("data")
3122                    .join("shared")
3123                    .join("models")
3124                    .join("index.json")
3125            )
3126            .unwrap(),
3127            b"{\"models\":1}"
3128        );
3129        remove_home_tree(&parent, &home).unwrap();
3130        assert!(!home.exists());
3131        assert_eq!(
3132            fs::read(models.join("whisper").join("model.bin")).unwrap(),
3133            weights
3134        );
3135        assert_eq!(
3136            fs::read(models.join("index.json")).unwrap(),
3137            b"{\"models\":1}"
3138        );
3139        // The same through the start-of-run cleaning.
3140        let (_r, doomed) = home_for(&root, &parent, "models2", 10);
3141        set_home_marker(&doomed, 0, 1);
3142        fs::create_dir_all(doomed.join("data").join("shared")).unwrap();
3143        test_links::junction(&doomed.join("data").join("shared").join("models"), &models);
3144        test_links::wininet_cache(&doomed);
3145        let report = clean_homes(&parent, Duration::ZERO, false);
3146        assert!(!doomed.exists(), "{report:?}");
3147        assert_eq!(
3148            fs::read(models.join("whisper").join("model.bin")).unwrap(),
3149            weights
3150        );
3151        let _ = fs::remove_dir_all(&root);
3152    }
3153
3154    #[cfg(windows)]
3155    #[test]
3156    fn a_read_only_file_in_a_home_is_removed() {
3157        let (root, parent) = homes_parent("rm-readonly");
3158        let (_r, home) = home_for(&root, &parent, "ro", 10);
3159        let file = home.join("locked.txt");
3160        fs::write(&file, b"read only").unwrap();
3161        let mut permissions = fs::metadata(&file).unwrap().permissions();
3162        permissions.set_readonly(true);
3163        fs::set_permissions(&file, permissions).unwrap();
3164        remove_home_tree(&parent, &home).unwrap();
3165        assert!(!home.exists());
3166        let _ = fs::remove_dir_all(&root);
3167    }
3168
3169    #[cfg(unix)]
3170    #[test]
3171    fn a_symlink_out_of_the_home_is_removed_and_what_it_points_at_stays() {
3172        let (root, parent) = homes_parent("rm-symlink");
3173        let outside = root.join("outside");
3174        fs::create_dir_all(outside.join("sub")).unwrap();
3175        fs::write(outside.join("sub").join("precious"), b"keep").unwrap();
3176        fs::write(outside.join("file"), b"keep too").unwrap();
3177        let (_r, home) = home_for(&root, &parent, "links", 10);
3178        fs::create_dir_all(home.join("data")).unwrap();
3179        std::os::unix::fs::symlink(&outside, home.join("data").join("dir-link")).unwrap();
3180        std::os::unix::fs::symlink(outside.join("file"), home.join("file-link")).unwrap();
3181        std::os::unix::fs::symlink(root.join("nowhere"), home.join("dangling")).unwrap();
3182        remove_home_tree(&parent, &home).unwrap();
3183        assert!(!home.exists());
3184        assert_eq!(
3185            fs::read(outside.join("sub").join("precious")).unwrap(),
3186            b"keep"
3187        );
3188        assert_eq!(fs::read(outside.join("file")).unwrap(), b"keep too");
3189        let _ = fs::remove_dir_all(&root);
3190    }
3191
3192    #[test]
3193    fn a_removal_that_stops_names_the_entry_and_keeps_the_binding_file() {
3194        let (root, parent) = homes_parent("rm-held");
3195        let (_r, home) = home_for(&root, &parent, "held", 10);
3196        fs::create_dir_all(home.join("data").join("busy")).unwrap();
3197        let stuck = home.join("data").join("busy").join("held.db");
3198        fs::write(&stuck, b"in use").unwrap();
3199        // Windows: a handle that shares nothing. Unix: a directory that may not be changed.
3200        #[cfg(windows)]
3201        let hold = {
3202            use std::os::windows::fs::OpenOptionsExt;
3203            OpenOptions::new()
3204                .read(true)
3205                .share_mode(0)
3206                .open(&stuck)
3207                .unwrap()
3208        };
3209        #[cfg(unix)]
3210        {
3211            use std::os::unix::fs::PermissionsExt;
3212            fs::set_permissions(
3213                home.join("data").join("busy"),
3214                fs::Permissions::from_mode(0o555),
3215            )
3216            .unwrap();
3217        }
3218        let why = remove_home_tree(&parent, &home).unwrap_err();
3219        assert!(why.contains("held.db"), "{why}");
3220        assert!(home.join(BINDING_FILE).is_file());
3221        assert!(is_owned_home(&home));
3222        #[cfg(windows)]
3223        drop(hold);
3224        #[cfg(unix)]
3225        {
3226            use std::os::unix::fs::PermissionsExt;
3227            fs::set_permissions(
3228                home.join("data").join("busy"),
3229                fs::Permissions::from_mode(0o755),
3230            )
3231            .unwrap();
3232        }
3233        // A later pass finishes it.
3234        remove_home_tree(&parent, &home).unwrap();
3235        assert!(!home.exists());
3236        let _ = fs::remove_dir_all(&root);
3237    }
3238
3239    #[test]
3240    fn removal_refuses_a_path_that_is_not_strictly_inside_the_qa_root() {
3241        let (root, parent) = homes_parent("rm-outside");
3242        let (_r, home) = home_for(&root, &parent, "inside", 10);
3243        let outside = root.join("outside");
3244        fs::create_dir_all(&outside).unwrap();
3245        fs::write(outside.join("precious"), b"keep").unwrap();
3246        // Beside the root, the root itself, a path that climbs out, and a root of another name.
3247        assert!(remove_home_tree(&parent, &outside).is_err());
3248        assert!(remove_home_tree(&parent, &parent).is_err());
3249        assert!(remove_home_tree(&parent, &home.join("..").join("..").join("outside")).is_err());
3250        assert!(remove_home_tree(&root, &outside).is_err());
3251        assert!(remove_home_tree(&parent, &parent.join("missing")).is_err());
3252        assert_eq!(fs::read(outside.join("precious")).unwrap(), b"keep");
3253        assert!(home.join(BINDING_FILE).is_file());
3254        remove_home_tree(&parent, &home).unwrap();
3255        assert!(!home.exists());
3256        let _ = fs::remove_dir_all(&root);
3257    }
3258
3259    #[test]
3260    fn only_a_runner_created_home_counts_as_owned() {
3261        let (root, parent) = homes_parent("owned");
3262        let (_r, home) = home_for(&root, &parent, "h", 1);
3263        assert!(is_owned_home(&home));
3264        let stranger = parent.join("stranger");
3265        fs::create_dir_all(&stranger).unwrap();
3266        assert!(!is_owned_home(&stranger));
3267        assert!(!is_owned_home(&root));
3268        let _ = fs::remove_dir_all(&root);
3269    }
3270}