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}