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