Skip to main content

rch_common/
gc_roots.rs

1//! The single source of truth for the directory roots `rch gc` scans.
2//!
3//! # Why this module exists
4//!
5//! rch creates long-lived, multi-GB runtime state on a worker in exactly three
6//! places, and every one of them is derived from a value that already lives
7//! somewhere else in this crate:
8//!
9//! 1. **The project mirror** — `[remediation.pooled_target] remote_base`
10//!    (default [`crate::remediation_config::DEFAULT_POOLED_REMOTE_BASE`]).
11//!    Pooled target stores land at
12//!    `<remote_base>/<project>/.rch-target-<worker>-pool-<key>`.
13//! 2. **The relocated pooled store** — `[remediation.pooled_target] store_base`
14//!    (issue #64), when an operator moves warm pools onto a bigger volume.
15//! 3. **The worker temp base** — resolved *on the worker* by
16//!    [`remote_cargo_home_base_prelude`] as
17//!    `$TMPDIR` → `/data/tmp` → `/tmp`. Both the durable per-worker Cargo
18//!    caches ([`RCH_CARGO_CACHE_PREFIX`], issue #42)
19//!    and staged `.rch-target-*` dirs live under it.
20//!
21//! Before this module the GC scan set was a **second, hardcoded list**: the
22//! surface config built `[remote_base, store_base]` by hand and the reap script
23//! re-implemented the `$TMPDIR` → `/data/tmp` → `/tmp` ladder inline instead of
24//! calling the prelude that creates the dirs. Two lists that must agree but are
25//! written twice always drift, and this pair drifted badly: workers whose real
26//! runtime roots sat elsewhere (an operator build root on a mounted volume, a
27//! `$TMPDIR` that differed between dir-creation time and sweep time) were never
28//! scanned, so `rch gc` truthfully reported "0 dirs, 0 MB" on every worker while
29//! hundreds of GB of `.rch-target-*-pool-*` and `rch-cargo-cache-*` sat on disk
30//! (~700 GB fleet-wide before the 2026-09 sweep).
31//!
32//! [`derive_gc_roots`] is now the only place a root set is constructed. It
33//! folds the config-derived roots, any operator-configured extra roots
34//! (`[remediation.pooled_target] gc_extra_roots`) and any `--root` flags into
35//! one validated, de-duplicated, order-stable list, and it always reports the
36//! worker temp base as a first-class root so `rch gc` output names every place
37//! it looked.
38
39use crate::remediation_config::PooledTargetConfig;
40use crate::stale_target_reap::is_safe_reap_base;
41
42// These pure path constants and shell builders must remain platform-neutral:
43// shared GC classification uses them even on a native Windows worker, where
44// the SSH-dependent remote_compilation module is not compiled.
45
46/// Shell-snippet name of the variable that [`remote_cargo_home_base_prelude`]
47/// assigns the isolated-CARGO_HOME staging base into.
48pub const RCH_CARGO_HOME_BASE_VAR: &str = "RCH_CH_BASE";
49
50/// Basename prefix of every isolated CARGO_HOME staging dir. Cleanup/reaper
51/// passes (in-tree and external, e.g. sbh) match on this exact prefix, so it
52/// must never change.
53pub const RCH_CARGO_HOME_PREFIX: &str = "rch-cargo-home-";
54
55/// A POSIX-`sh` statement that resolves the temp base for isolated CARGO_HOME
56/// staging dirs **on the worker, at job-execution time**, into the shell
57/// variable [`RCH_CARGO_HOME_BASE_VAR`].
58///
59/// Why resolve remotely rather than bake a path in from the client: these dirs
60/// are created and used on the *worker*, and the worker's correct temp location
61/// (`$TMPDIR`, fleet-wide `/data/tmp`) is what matters, not the orchestrating
62/// client's. Critically, rchd runs under systemd and so does **not** inherit
63/// PAM's `/etc/environment` (where `TMPDIR=/data/tmp` lives) — but a build
64/// dispatched over SSH may, and either way the explicit `/data/tmp` fallback
65/// keeps these (potentially large, long-lived) caches off a tmpfs `/tmp` that
66/// would otherwise eat RAM.
67///
68/// Resolution order: `$TMPDIR` (if set and a real directory) → `/data/tmp`
69/// (if it exists) → `/tmp`.
70pub fn remote_cargo_home_base_prelude() -> String {
71    format!(
72        "{var}=\"${{TMPDIR:-}}\"; \
73         [ -n \"${{{var}}}\" ] && [ -d \"${{{var}}}\" ] || {var}=/data/tmp; \
74         [ -d \"${{{var}}}\" ] || {var}=/tmp",
75        var = RCH_CARGO_HOME_BASE_VAR
76    )
77}
78
79/// The shell expression (referencing the variable set by
80/// [`remote_cargo_home_base_prelude`]) for an isolated CARGO_HOME staging dir
81/// whose unique `suffix` is already path-safe. Keeps the
82/// [`RCH_CARGO_HOME_PREFIX`] basename so cleanup matching still works.
83///
84/// Returns an unquoted expression that must be embedded in a context where the
85/// shell expands `$VAR` (i.e. inside double quotes or bare); both call sites
86/// double-quote it.
87pub fn remote_cargo_home_expr(suffix: &str) -> String {
88    format!(
89        "${{{var}}}/{prefix}{suffix}",
90        var = RCH_CARGO_HOME_BASE_VAR,
91        prefix = RCH_CARGO_HOME_PREFIX
92    )
93}
94
95/// Basename prefix of the *durable* per-worker Cargo cache dir used as
96/// `CARGO_HOME` for offloaded builds (issue #42).
97///
98/// Deliberately distinct from [`RCH_CARGO_HOME_PREFIX`]: that prefix names
99/// throwaway per-job staging dirs which orphan-cleanup passes (in-tree and
100/// external, e.g. sbh) are allowed to reap. Dirs with THIS prefix are
101/// long-lived caches — registry downloads and git-dependency db/checkout
102/// state persist here across jobs so an interrupted/timed-out job's
103/// dependency preparation is reusable by the next one. Reapers must not treat
104/// them as orphans (worker disk-pressure reclaim may still clear them; the
105/// only cost is a cold cache).
106pub const RCH_CARGO_CACHE_PREFIX: &str = "rch-cargo-cache-";
107
108/// The shell expression (referencing the variable set by
109/// [`remote_cargo_home_base_prelude`]) for the durable per-worker Cargo cache
110/// dir whose `worker` token is already path-safe.
111///
112/// Returns an unquoted expression that must be embedded in a context where the
113/// shell expands `$VAR` (i.e. inside double quotes or bare).
114pub fn remote_cargo_cache_expr(worker: &str) -> String {
115    format!(
116        "${{{var}}}/{prefix}{worker}",
117        var = RCH_CARGO_HOME_BASE_VAR,
118        prefix = RCH_CARGO_CACHE_PREFIX
119    )
120}
121
122/// Where a scan root came from. Reported per root so an operator reading
123/// `rch gc` output can tell a default from something they configured.
124#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
125pub enum GcRootSource {
126    /// `[remediation.pooled_target] remote_base` — the project mirror.
127    PooledRemoteBase,
128    /// `[remediation.pooled_target] store_base` — a relocated pooled store.
129    PooledStoreBase,
130    /// `[remediation.pooled_target] gc_extra_roots` — operator-configured.
131    ConfiguredExtra,
132    /// A `--root` flag on this invocation.
133    CommandLine,
134    /// The worker temp base, resolved on the worker at sweep time by
135    /// [`remote_cargo_home_base_prelude`].
136    WorkerTempBase,
137}
138
139impl GcRootSource {
140    /// Stable, machine-readable tag used in JSON output and tests.
141    #[must_use]
142    pub const fn as_str(self) -> &'static str {
143        match self {
144            Self::PooledRemoteBase => "pooled_remote_base",
145            Self::PooledStoreBase => "pooled_store_base",
146            Self::ConfiguredExtra => "configured_extra",
147            Self::CommandLine => "command_line",
148            Self::WorkerTempBase => "worker_temp_base",
149        }
150    }
151
152    /// The config key or flag an operator edits to change this root.
153    #[must_use]
154    pub const fn origin_hint(self) -> &'static str {
155        match self {
156            Self::PooledRemoteBase => "[remediation.pooled_target] remote_base",
157            Self::PooledStoreBase => "[remediation.pooled_target] store_base",
158            Self::ConfiguredExtra => "[remediation.pooled_target] gc_extra_roots",
159            Self::CommandLine => "--root",
160            Self::WorkerTempBase => "$TMPDIR → /data/tmp → /tmp (resolved on the worker)",
161        }
162    }
163}
164
165impl std::fmt::Display for GcRootSource {
166    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167        f.write_str(self.as_str())
168    }
169}
170
171/// Placeholder path reported for [`GcRootSource::WorkerTempBase`]. The real
172/// path is only knowable on the worker, so the sweep script resolves it there
173/// and echoes the resolved value back (see
174/// `stale_target_reap::parse_scan_roots`); this constant is what the client
175/// prints before it has that answer.
176pub const WORKER_TEMP_BASE_PLACEHOLDER: &str = "<worker temp base>";
177
178/// One root `rch gc` scans.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct GcRoot {
181    /// Absolute path on the worker, `/`-trimmed. For
182    /// [`GcRootSource::WorkerTempBase`] this is
183    /// [`WORKER_TEMP_BASE_PLACEHOLDER`].
184    pub path: String,
185    /// Where this root came from.
186    pub source: GcRootSource,
187}
188
189impl GcRoot {
190    /// Whether this root is resolved on the worker rather than by the client,
191    /// i.e. it carries no usable client-side path.
192    #[must_use]
193    pub fn is_worker_resolved(&self) -> bool {
194        self.source == GcRootSource::WorkerTempBase
195    }
196}
197
198/// A root that failed validation. Rejected roots are never scanned — the path
199/// is embedded into a generated remote shell command, so anything that could
200/// escape its double-quoted context, traverse upward, or name the filesystem
201/// root is refused outright rather than sanitized.
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub struct GcRootRejection {
204    /// The offending value, verbatim.
205    pub path: String,
206    /// Where it came from.
207    pub source: GcRootSource,
208    /// Human-readable reason.
209    pub reason: String,
210}
211
212impl std::fmt::Display for GcRootRejection {
213    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
214        write!(
215            f,
216            "{} root {:?} ({}) is unusable: {}",
217            self.source,
218            self.path,
219            self.source.origin_hint(),
220            self.reason
221        )
222    }
223}
224
225/// The validated, de-duplicated root set for one `rch gc` run.
226#[derive(Debug, Clone, PartialEq, Eq)]
227pub struct GcRootSet {
228    /// Roots to scan, in a stable order: pooled remote base, pooled store
229    /// base, configured extras, `--root` flags, then the worker temp base.
230    pub roots: Vec<GcRoot>,
231    /// Roots that failed validation and will NOT be scanned.
232    pub rejected: Vec<GcRootRejection>,
233}
234
235impl GcRootSet {
236    /// The client-resolvable roots, i.e. every root except the worker temp
237    /// base. These are the paths handed to the remote enumeration/sweep
238    /// scripts as `$base`.
239    #[must_use]
240    pub fn scan_bases(&self) -> Vec<String> {
241        self.roots
242            .iter()
243            .filter(|r| !r.is_worker_resolved())
244            .map(|r| r.path.clone())
245            .collect()
246    }
247
248    /// Look up which configured root a discovered absolute path belongs to.
249    ///
250    /// Attribution is longest-prefix so a `store_base` nested inside
251    /// `remote_base` still wins, and it is path-segment aware so
252    /// `/data/projects-old/x` is never attributed to `/data/projects`.
253    /// Returns `None` for a path under no configured root — in practice a dir
254    /// found under the worker-resolved temp base.
255    ///
256    /// NOTE: this matches the CONFIGURED spelling of each root. The remote
257    /// scripts canonicalize their root with `pwd -P` and report it back
258    /// (`stale_target_reap::parse_scan_roots`), so a caller holding those
259    /// resolved paths — `rch gc` does — must match against them with
260    /// [`path_is_under`] instead; a symlinked `remote_base` matches nothing
261    /// here.
262    #[must_use]
263    pub fn attribute<'a>(&'a self, path: &str) -> Option<&'a GcRoot> {
264        self.roots
265            .iter()
266            .filter(|root| !root.is_worker_resolved())
267            .filter(|root| path_is_under(path, &root.path))
268            .max_by_key(|root| root.path.len())
269    }
270}
271
272/// Whether `path` is `base` itself or lives beneath it, comparing whole path
273/// segments (so `/data/projects-old` is not under `/data/projects`).
274#[must_use]
275pub fn path_is_under(path: &str, base: &str) -> bool {
276    let base = base.trim_end_matches('/');
277    if base.is_empty() {
278        return false;
279    }
280    if path == base {
281        return true;
282    }
283    path.strip_prefix(base)
284        .is_some_and(|rest| rest.starts_with('/'))
285}
286
287fn normalize(path: &str) -> String {
288    let trimmed = path.trim();
289    let stripped = trimmed.trim_end_matches('/');
290    if stripped.is_empty() {
291        trimmed.to_string()
292    } else {
293        stripped.to_string()
294    }
295}
296
297/// Build the root set for a `rch gc` run.
298///
299/// `pooled` supplies the config-derived roots; `cli_roots` are `--root` flags
300/// (already in the order the operator gave them). Every candidate is validated
301/// with [`is_safe_reap_base`]; failures land in [`GcRootSet::rejected`] rather
302/// than aborting, so one bad `--root` cannot stop the sweep of the good ones —
303/// but a rejected root is never scanned, and callers surface the rejection.
304///
305/// The worker temp base is always appended: it is where
306/// [`remote_cargo_cache_expr`] puts durable Cargo
307/// caches and where staged `.rch-target-*` dirs live, and it is resolved on the
308/// worker by the very prelude that creates them.
309#[must_use]
310pub fn derive_gc_roots(pooled: &PooledTargetConfig, cli_roots: &[String]) -> GcRootSet {
311    let mut roots: Vec<GcRoot> = Vec::new();
312    let mut rejected: Vec<GcRootRejection> = Vec::new();
313    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
314
315    let mut push = |raw: &str, source: GcRootSource| {
316        let path = normalize(raw);
317        if path.is_empty() {
318            rejected.push(GcRootRejection {
319                path: raw.to_string(),
320                source,
321                reason: "empty path".to_string(),
322            });
323            return;
324        }
325        if !is_safe_reap_base(&path) {
326            rejected.push(GcRootRejection {
327                path: raw.to_string(),
328                source,
329                reason: "not an absolute, `..`-free path of at least one segment built from \
330                         [A-Za-z0-9/._-] (it is embedded in a remote shell command)"
331                    .to_string(),
332            });
333            return;
334        }
335        if seen.insert(path.clone()) {
336            roots.push(GcRoot { path, source });
337        }
338    };
339
340    push(&pooled.remote_base, GcRootSource::PooledRemoteBase);
341    if let Some(store_base) = pooled.store_base.as_deref() {
342        push(store_base, GcRootSource::PooledStoreBase);
343    }
344    for extra in &pooled.gc_extra_roots {
345        push(extra, GcRootSource::ConfiguredExtra);
346    }
347    for cli in cli_roots {
348        push(cli, GcRootSource::CommandLine);
349    }
350    drop(push);
351
352    roots.push(GcRoot {
353        path: WORKER_TEMP_BASE_PLACEHOLDER.to_string(),
354        source: GcRootSource::WorkerTempBase,
355    });
356
357    GcRootSet { roots, rejected }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    fn cfg() -> PooledTargetConfig {
365        PooledTargetConfig::default()
366    }
367
368    #[test]
369    fn default_root_set_is_remote_base_plus_worker_temp_base() {
370        let set = derive_gc_roots(&cfg(), &[]);
371        assert_eq!(
372            set.roots,
373            vec![
374                GcRoot {
375                    path: "/data/projects".to_string(),
376                    source: GcRootSource::PooledRemoteBase,
377                },
378                GcRoot {
379                    path: WORKER_TEMP_BASE_PLACEHOLDER.to_string(),
380                    source: GcRootSource::WorkerTempBase,
381                },
382            ]
383        );
384        assert!(set.rejected.is_empty());
385        assert_eq!(set.scan_bases(), vec!["/data/projects".to_string()]);
386    }
387
388    /// The gap this module closes: a pool root rch itself writes to must be
389    /// scannable from configuration alone, with no code change.
390    #[test]
391    fn configured_extra_roots_and_cli_roots_are_scanned() {
392        let mut c = cfg();
393        c.store_base = Some("/bigdisk/rch-pools".to_string());
394        c.gc_extra_roots = vec!["/Volumes/EE1/rch".to_string(), "/srv/rch".to_string()];
395        let set = derive_gc_roots(&c, &["/private/tmp/ee-reality-1".to_string()]);
396
397        assert_eq!(
398            set.scan_bases(),
399            vec![
400                "/data/projects".to_string(),
401                "/bigdisk/rch-pools".to_string(),
402                "/Volumes/EE1/rch".to_string(),
403                "/srv/rch".to_string(),
404                "/private/tmp/ee-reality-1".to_string(),
405            ]
406        );
407        assert!(set.rejected.is_empty(), "{:?}", set.rejected);
408        assert_eq!(
409            set.roots.last().map(|r| r.source),
410            Some(GcRootSource::WorkerTempBase),
411            "the worker temp base is always scanned last"
412        );
413    }
414
415    #[test]
416    fn duplicate_roots_collapse_and_keep_first_source() {
417        let mut c = cfg();
418        c.store_base = Some("/data/projects/".to_string());
419        c.gc_extra_roots = vec!["/data/projects".to_string()];
420        let set = derive_gc_roots(&c, &["/data/projects//".to_string()]);
421        assert_eq!(set.scan_bases(), vec!["/data/projects".to_string()]);
422        assert_eq!(set.roots[0].source, GcRootSource::PooledRemoteBase);
423    }
424
425    #[test]
426    fn unsafe_roots_are_rejected_not_sanitized() {
427        let c = cfg();
428        let bad = [
429            "/",
430            "relative/path",
431            "/tmp/../etc",
432            "/tmp/$(whoami)",
433            "/tmp/rch; rm -rf x",
434            "",
435            "   ",
436        ];
437        for candidate in bad {
438            let set = derive_gc_roots(&c, &[candidate.to_string()]);
439            assert_eq!(
440                set.scan_bases(),
441                vec!["/data/projects".to_string()],
442                "{candidate:?} must never become a scan base"
443            );
444            assert!(
445                set.rejected
446                    .iter()
447                    .any(|r| r.source == GcRootSource::CommandLine && r.path == candidate),
448                "{candidate:?} must be reported as rejected"
449            );
450        }
451    }
452
453    /// One bad `--root` must not disable the sweep of the good roots.
454    #[test]
455    fn a_rejected_root_does_not_drop_the_valid_ones() {
456        let set = derive_gc_roots(&cfg(), &["/".to_string(), "/srv/rch".to_string()]);
457        assert_eq!(
458            set.scan_bases(),
459            vec!["/data/projects".to_string(), "/srv/rch".to_string()]
460        );
461        assert_eq!(set.rejected.len(), 1);
462        assert!(set.rejected[0].to_string().contains("unusable"));
463    }
464
465    #[test]
466    fn attribution_is_longest_prefix_and_segment_aware() {
467        let mut c = cfg();
468        c.gc_extra_roots = vec![
469            "/data/projects/nested".to_string(),
470            "/data/projects-old".to_string(),
471        ];
472        let set = derive_gc_roots(&c, &[]);
473
474        assert_eq!(
475            set.attribute("/data/projects/nested/x/.rch-target-w-pool-a")
476                .map(|r| r.path.as_str()),
477            Some("/data/projects/nested"),
478        );
479        assert_eq!(
480            set.attribute("/data/projects/x/.rch-target-w-pool-a")
481                .map(|r| r.path.as_str()),
482            Some("/data/projects"),
483        );
484        assert_eq!(
485            set.attribute("/data/projects-old/x")
486                .map(|r| r.path.as_str()),
487            Some("/data/projects-old"),
488            "a sibling root must not be swallowed by a prefix match",
489        );
490        assert_eq!(set.attribute("/data/tmp/rch-cargo-cache-hz2"), None);
491    }
492
493    #[test]
494    fn path_is_under_compares_whole_segments() {
495        assert!(path_is_under("/a/b", "/a/b"));
496        assert!(path_is_under("/a/b/c", "/a/b"));
497        assert!(path_is_under("/a/b/c", "/a/b/"));
498        assert!(!path_is_under("/a/bc", "/a/b"));
499        assert!(!path_is_under("/a", "/a/b"));
500        assert!(!path_is_under("/a/b", ""));
501        assert!(!path_is_under("/a/b", "/"));
502    }
503}