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