Skip to main content

runner_manager_platform/
dependency_cache.rs

1//! Persistent dependency and tool caches for native runners.
2//!
3//! An ephemeral attempt starts in a fresh directory, and on Windows with a
4//! fresh profile (`runner_env::platform_defaults`), so every tool cache a job
5//! relies on starts empty: pnpm reinstalls its store, `setup-node` downloads
6//! Node again into `_work/_tool`, NuGet restores every package from the
7//! network. Worse, a cache under the attempt directory has a different absolute
8//! path every job, and `actions/cache` hashes the paths into its key, so even an
9//! explicit cache step never hits.
10//!
11//! This module gives each runner a set of environment variables that point
12//! those tools at directories that outlive the attempt, without anything being
13//! configured by hand on each machine.
14//!
15//! # Layout
16//!
17//! ```text
18//! <cache root>/                  default <runner root>/_cache
19//!   .lock                        LockKind::DependencyCache
20//!   .usage.json                  the daemon's last measurement
21//!   .trash/                      namespaces being deleted
22//!   <owner>/<repo>/              a repository policy's namespace (lower case)
23//!   _org/<org>/                  an organization policy's, when it opts in
24//!   _shared/<name>/              a namespace policies share on purpose
25//!     .last-used                 touched at every launch; the LRU clock
26//!     npm/ pnpm-store/ nuget/ …  caches every runner of the namespace shares
27//!     _slots/<n>.lease           which attempt holds slot n
28//!     _slots/<n>/tool-cache/ …   caches one runner at a time may write
29//! ```
30//!
31//! Owners and organizations on GitHub cannot start with `_`, so `_org` and
32//! `_shared` never collide with a repository's namespace.
33//!
34//! # Trust model
35//!
36//! A cache is an input to the next job that reads it: a job that can write
37//! `npm/` can plant a package the next job installs. So the default namespace
38//! is one **repository**: every profile of `owner/repo` shares one, and no
39//! other repository can read or write it. An organization policy accepts jobs
40//! from every repository in the organization, so it gets no cache unless an
41//! operator opts in, for the same reason it never gets a persistent workspace.
42//! Two policies share a namespace only when an operator names the same
43//! `_shared/<name>` for both. None of this is a defence against a hostile
44//! workflow in the same repository; that is the model GitHub documents for
45//! self-hosted runners.
46//!
47//! # Concurrency
48//!
49//! Concurrent runners of one policy share a namespace. Every cache marked
50//! [`Sharing::Namespace`] in [`TOOLS`] is documented as safe for concurrent
51//! processes (cacache, the pnpm store, NuGet with a shared scratch folder, the
52//! Go caches, Yarn Berry, uv, Playwright's lock). The rest are
53//! [`Sharing::Slot`]: each runner leases the lowest free slot of its namespace
54//! and gets that slot's own directory. The `@actions/tool-cache` library is the
55//! important one: `cacheDir` deletes a version's folder before copying into it,
56//! with no lock, so two cold jobs filling the same Node version would delete
57//! each other's toolchain.
58//!
59//! A lease is held while its attempt's runtime still holds the runner package
60//! (`<runtime>/bin`), which is removed by the attempt's cleanup in both
61//! workspace modes. A crashed daemon therefore keeps a lease for as long as the
62//! attempt it belongs to is not cleaned, which is exactly as long as a runner
63//! may still be using the slot.
64//!
65//! # Disk
66//!
67//! The daemon measures the root periodically and, over the cap, removes whole
68//! namespaces in least-recently-used order, never one with a live lease. The
69//! check is repeated under [`LockKind::DependencyCache`] immediately before
70//! the namespace is renamed aside, and a launch takes its lease under the same
71//! lock, so a prune cannot take a namespace a runner is about to use.
72//!
73//! # Precedence
74//!
75//! Platform defaults < these variables < `runner.env` < the per-attempt
76//! `TMPDIR`/`TEMP`/`TMP`. A variable an operator sets in `runner.env` always
77//! wins, and so does one the service's own environment already carries (a
78//! systemd drop-in, a launchd plist, a machine-wide variable on Windows): a
79//! cache variable only fills in what nobody set ([`without_inherited`]).
80
81use std::collections::BTreeMap;
82use std::ffi::OsString;
83use std::fs;
84use std::io;
85use std::path::{Path, PathBuf};
86use std::time::{Duration, SystemTime};
87
88use chrono::{DateTime, Utc};
89use runner_manager_domain::model::ScaleTarget;
90use serde::{Deserialize, Serialize};
91
92use sha2::Digest as _;
93
94use crate::lock::{HostLock, LockKind};
95use crate::paths::AppPaths;
96use crate::runner_env::RunnerPlatform;
97
98/// The configuration file's name inside the configuration directory.
99pub const CONFIG_FILE: &str = "caches.toml";
100
101/// The default cache root's name inside the host runner root.
102pub const DEFAULT_ROOT_NAME: &str = "_cache";
103
104/// The default cap on the whole cache root.
105pub const DEFAULT_MAX_SIZE_GIB: u64 = 20;
106
107/// How often the daemon measures and prunes.
108pub const PRUNE_INTERVAL: Duration = Duration::from_secs(30 * 60);
109
110/// How long a launch or a prune waits for [`LockKind::DependencyCache`].
111pub const LOCK_WAIT: Duration = Duration::from_secs(30);
112
113/// The daemon's last measurement, inside the cache root.
114pub const USAGE_FILE: &str = ".usage.json";
115
116const TRASH_DIR: &str = ".trash";
117const SLOTS_DIR: &str = "_slots";
118const LEASE_EXTENSION: &str = "lease";
119const LAST_USED_FILE: &str = ".last-used";
120const ORG_PREFIX: &str = "_org";
121const SHARED_PREFIX: &str = "_shared";
122const GIB: u64 = 1024 * 1024 * 1024;
123
124// ---------------------------------------------------------------------------
125// The table
126// ---------------------------------------------------------------------------
127
128/// Which platforms a variable is set on.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub struct Platforms {
131    pub windows: bool,
132    pub macos: bool,
133    pub linux: bool,
134}
135
136impl Platforms {
137    pub const ALL: Self = Self {
138        windows: true,
139        macos: true,
140        linux: true,
141    };
142    pub const UNIX: Self = Self {
143        windows: false,
144        macos: true,
145        linux: true,
146    };
147
148    #[must_use]
149    pub const fn includes(self, platform: RunnerPlatform) -> bool {
150        match platform {
151            RunnerPlatform::Windows => self.windows,
152            RunnerPlatform::MacOs => self.macos,
153            RunnerPlatform::Linux => self.linux,
154        }
155    }
156}
157
158/// Whether concurrent runners of one namespace share a cache directory.
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub enum Sharing {
161    /// One directory for every runner of the namespace: the tool is designed
162    /// for concurrent processes.
163    Namespace,
164    /// One directory per leased slot: the tool is not, or is not known to be.
165    Slot,
166}
167
168/// What a variable is set to.
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum VarValue {
171    /// The tool's directory.
172    Dir,
173    /// A subdirectory of the tool's directory.
174    Sub(&'static str),
175    /// A fixed value.
176    Literal(&'static str),
177    /// A value with `{dir}` replaced by the tool's directory. The cache root
178    /// never contains whitespace, so the result is one shell word.
179    Template(&'static str),
180}
181
182/// One variable a tool reads.
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub struct CacheVariable {
185    pub name: &'static str,
186    pub value: VarValue,
187    pub platforms: Platforms,
188}
189
190const fn all(name: &'static str, value: VarValue) -> CacheVariable {
191    CacheVariable {
192        name,
193        value,
194        platforms: Platforms::ALL,
195    }
196}
197
198const fn unix(name: &'static str, value: VarValue) -> CacheVariable {
199    CacheVariable {
200        name,
201        value,
202        platforms: Platforms::UNIX,
203    }
204}
205
206/// One tool, or one family of variables that is switched on and off together.
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208pub struct CacheTool {
209    /// The name `host cache set-tool` and `caches.toml` use.
210    pub id: &'static str,
211    /// The directory under the namespace (or the slot) the variables point at.
212    pub dir: &'static str,
213    pub sharing: Sharing,
214    /// Whether a host that says nothing about this tool gets it.
215    pub default_enabled: bool,
216    pub variables: &'static [CacheVariable],
217    /// One line for `host cache show`: what it covers or why it is opt-in.
218    pub note: &'static str,
219}
220
221impl CacheTool {
222    /// The variables this tool sets on `platform`.
223    pub fn variables_on(&self, platform: RunnerPlatform) -> impl Iterator<Item = &CacheVariable> {
224        self.variables
225            .iter()
226            .filter(move |variable| variable.platforms.includes(platform))
227    }
228
229    /// Whether the tool sets anything on `platform`.
230    #[must_use]
231    pub fn applies_to(&self, platform: RunnerPlatform) -> bool {
232        self.variables_on(platform).next().is_some()
233    }
234}
235
236/// Every cache this module knows. Adding a tool is one entry.
237///
238/// Each variable name below was checked against the tool's own documentation
239/// or source; `docs`-less choices are explained in `note`. The README carries
240/// the same table with its sources.
241pub const TOOLS: &[CacheTool] = &[
242    CacheTool {
243        id: "xdg",
244        dir: "xdg",
245        sharing: Sharing::Namespace,
246        default_enabled: true,
247        variables: &[unix("XDG_CACHE_HOME", VarValue::Dir)],
248        note: "catch-all for Unix tools that follow the XDG base directory specification; not set \
249               on Windows, where pnpm, Bun and Deno would also move global state",
250    },
251    CacheTool {
252        id: "tool-cache",
253        dir: "tool-cache",
254        sharing: Sharing::Slot,
255        default_enabled: true,
256        variables: &[
257            all("RUNNER_TOOL_CACHE", VarValue::Dir),
258            all("AGENT_TOOLSDIRECTORY", VarValue::Dir),
259        ],
260        note: "toolchains setup-node, setup-go and setup-java download; per slot because \
261               @actions/tool-cache deletes a version's folder before filling it",
262    },
263    CacheTool {
264        id: "dotnet",
265        dir: "dotnet",
266        sharing: Sharing::Slot,
267        default_enabled: true,
268        variables: &[all("DOTNET_INSTALL_DIR", VarValue::Dir)],
269        note: "SDKs setup-dotnet installs; per slot because an install replaces the shared dotnet \
270               host",
271    },
272    CacheTool {
273        id: "npm",
274        dir: "npm",
275        sharing: Sharing::Namespace,
276        default_enabled: true,
277        variables: &[all("npm_config_cache", VarValue::Dir)],
278        note: "npm's cacache and npx",
279    },
280    CacheTool {
281        id: "pnpm",
282        dir: "pnpm-store",
283        sharing: Sharing::Namespace,
284        default_enabled: true,
285        variables: &[
286            all("npm_config_store_dir", VarValue::Dir),
287            all("PNPM_CONFIG_STORE_DIR", VarValue::Dir),
288        ],
289        note: "the content-addressed store: pnpm 9 and 10 read npm_config_store_dir, pnpm 11 and \
290               later PNPM_CONFIG_STORE_DIR; npm 12 warns that store-dir is unknown",
291    },
292    CacheTool {
293        id: "yarn",
294        dir: "yarn",
295        sharing: Sharing::Namespace,
296        default_enabled: true,
297        variables: &[all("YARN_GLOBAL_FOLDER", VarValue::Dir)],
298        note: "Yarn 2+ global cache; YARN_CACHE_FOLDER is left alone because it would override a \
299               zero-install project's committed cache",
300    },
301    CacheTool {
302        id: "bun",
303        dir: "bun",
304        sharing: Sharing::Namespace,
305        default_enabled: true,
306        variables: &[all("BUN_INSTALL_CACHE_DIR", VarValue::Dir)],
307        note: "Bun's package cache",
308    },
309    CacheTool {
310        id: "deno",
311        dir: "deno",
312        sharing: Sharing::Namespace,
313        default_enabled: true,
314        variables: &[all("DENO_DIR", VarValue::Dir)],
315        note: "Deno's module and npm cache (it also keeps localStorage there)",
316    },
317    CacheTool {
318        id: "node-gyp",
319        dir: "node-gyp",
320        sharing: Sharing::Namespace,
321        default_enabled: true,
322        variables: &[
323            all("npm_config_devdir", VarValue::Dir),
324            all("npm_package_config_node_gyp_devdir", VarValue::Dir),
325        ],
326        note: "Node headers node-gyp downloads for native modules",
327    },
328    CacheTool {
329        id: "electron",
330        dir: "electron",
331        sharing: Sharing::Namespace,
332        default_enabled: true,
333        variables: &[
334            // `electron`'s postinstall reads this exact lower-case name;
335            // electron-builder's app-builder reads `ELECTRON_CACHE`.
336            all("electron_config_cache", VarValue::Dir),
337            all("ELECTRON_CACHE", VarValue::Dir),
338        ],
339        note: "Electron release downloads",
340    },
341    CacheTool {
342        id: "electron-builder",
343        dir: "electron-builder",
344        sharing: Sharing::Namespace,
345        default_enabled: true,
346        variables: &[all("ELECTRON_BUILDER_CACHE", VarValue::Dir)],
347        note: "NSIS, winCodeSign, AppImage and other packaging tools",
348    },
349    CacheTool {
350        id: "playwright",
351        dir: "ms-playwright",
352        sharing: Sharing::Namespace,
353        default_enabled: true,
354        variables: &[
355            all("PLAYWRIGHT_BROWSERS_PATH", VarValue::Dir),
356            // Install otherwise deletes every browser no live checkout links
357            // to, and every earlier checkout is gone; pruning bounds the size.
358            all("PLAYWRIGHT_SKIP_BROWSER_GC", VarValue::Literal("1")),
359        ],
360        note: "Playwright browsers",
361    },
362    CacheTool {
363        id: "cypress",
364        dir: "cypress",
365        sharing: Sharing::Slot,
366        default_enabled: true,
367        variables: &[all("CYPRESS_CACHE_FOLDER", VarValue::Dir)],
368        note: "Cypress binaries; per slot because cypress install takes no lock",
369    },
370    CacheTool {
371        id: "nuget",
372        dir: "nuget",
373        sharing: Sharing::Namespace,
374        default_enabled: true,
375        variables: &[
376            all("NUGET_PACKAGES", VarValue::Sub("packages")),
377            all("NUGET_HTTP_CACHE_PATH", VarValue::Sub("http-cache")),
378            all("NUGET_PLUGINS_CACHE_PATH", VarValue::Sub("plugins-cache")),
379            // NuGet's cross-process locks live in the scratch folder, which
380            // defaults to the per-attempt temporary directory; processes must
381            // share it to share the packages folder safely.
382            all("NUGET_SCRATCH", VarValue::Sub("scratch")),
383        ],
384        note: "global packages and HTTP cache",
385    },
386    CacheTool {
387        id: "pip",
388        dir: "pip",
389        sharing: Sharing::Namespace,
390        default_enabled: true,
391        variables: &[all("PIP_CACHE_DIR", VarValue::Dir)],
392        note: "pip's HTTP and wheel cache",
393    },
394    CacheTool {
395        id: "uv",
396        dir: "uv",
397        sharing: Sharing::Namespace,
398        default_enabled: true,
399        variables: &[all("UV_CACHE_DIR", VarValue::Dir)],
400        note: "uv's cache",
401    },
402    CacheTool {
403        id: "go",
404        dir: "go",
405        sharing: Sharing::Namespace,
406        default_enabled: true,
407        variables: &[
408            all("GOMODCACHE", VarValue::Sub("mod")),
409            all("GOCACHE", VarValue::Sub("build")),
410        ],
411        note: "Go module and build caches",
412    },
413    CacheTool {
414        id: "composer",
415        dir: "composer",
416        sharing: Sharing::Namespace,
417        default_enabled: true,
418        variables: &[all("COMPOSER_CACHE_DIR", VarValue::Dir)],
419        note: "Composer's download cache",
420    },
421    CacheTool {
422        id: "cocoapods",
423        dir: "cocoapods",
424        sharing: Sharing::Namespace,
425        default_enabled: true,
426        variables: &[unix("CP_CACHE_DIR", VarValue::Dir)],
427        note: "CocoaPods download cache",
428    },
429    CacheTool {
430        id: "ccache",
431        dir: "ccache",
432        sharing: Sharing::Namespace,
433        default_enabled: true,
434        variables: &[all("CCACHE_DIR", VarValue::Dir)],
435        note: "ccache objects",
436    },
437    CacheTool {
438        id: "poetry",
439        dir: "poetry",
440        sharing: Sharing::Slot,
441        default_enabled: false,
442        variables: &[all("POETRY_CACHE_DIR", VarValue::Dir)],
443        note: "opt-in: Poetry also keeps virtualenvs in its cache directory, one per workspace path",
444    },
445    CacheTool {
446        id: "cargo",
447        dir: "cargo",
448        sharing: Sharing::Namespace,
449        default_enabled: false,
450        variables: &[all("CARGO_HOME", VarValue::Dir)],
451        note: "opt-in: CARGO_HOME also holds config, registry credentials and installed binaries",
452    },
453    CacheTool {
454        id: "gradle",
455        dir: "gradle",
456        sharing: Sharing::Namespace,
457        default_enabled: false,
458        variables: &[all("GRADLE_USER_HOME", VarValue::Dir)],
459        note: "opt-in: GRADLE_USER_HOME also holds gradle.properties, init scripts and daemons",
460    },
461    CacheTool {
462        id: "maven",
463        dir: "maven",
464        sharing: Sharing::Slot,
465        default_enabled: false,
466        variables: &[all(
467            "MAVEN_ARGS",
468            VarValue::Template("-Dmaven.repo.local={dir}"),
469        )],
470        note: "opt-in: Maven 3.9+ local repository via MAVEN_ARGS, which a workflow's own \
471               MAVEN_ARGS replaces",
472    },
473    CacheTool {
474        id: "pub",
475        dir: "pub",
476        sharing: Sharing::Slot,
477        default_enabled: false,
478        variables: &[all("PUB_CACHE", VarValue::Dir)],
479        note: "opt-in: PUB_CACHE also holds globally activated Dart tools",
480    },
481    CacheTool {
482        id: "bundler",
483        dir: "bundler",
484        sharing: Sharing::Slot,
485        default_enabled: false,
486        variables: &[all("BUNDLE_USER_CACHE", VarValue::Dir)],
487        note: "opt-in: Bundler's download cache",
488    },
489];
490
491/// The tool named `id`.
492#[must_use]
493pub fn tool(id: &str) -> Option<&'static CacheTool> {
494    TOOLS.iter().find(|tool| tool.id == id)
495}
496
497// ---------------------------------------------------------------------------
498// Errors
499// ---------------------------------------------------------------------------
500
501/// Why the configuration could not be used, or a cache operation failed.
502#[derive(Debug, thiserror::Error)]
503pub enum CacheError {
504    #[error("{} is not valid TOML for caches: {message}", path.display())]
505    Parse { path: PathBuf, message: String },
506    #[error("`{0}` is not a cache this version knows; `host cache show` lists them")]
507    UnknownTool(String),
508    #[error(
509        "`{0}` is not a valid shared namespace name; use 1 to 64 lower-case letters, digits, \
510         `.`, `_` or `-`, not starting with `.`"
511    )]
512    InvalidNamespace(String),
513    #[error("`{0}` is not a repository (OWNER/REPO) or an organization")]
514    InvalidTarget(String),
515    #[error("the cache root {0} must be an absolute path without spaces")]
516    InvalidRoot(String),
517    #[error("cannot {action} {}: {source}", path.display())]
518    Io {
519        action: &'static str,
520        path: PathBuf,
521        #[source]
522        source: io::Error,
523    },
524    #[error("{0}")]
525    Lock(String),
526}
527
528fn io_error(action: &'static str, path: &Path) -> impl FnOnce(io::Error) -> CacheError + use<> {
529    let path = path.to_path_buf();
530    move |source| CacheError::Io {
531        action,
532        path,
533        source,
534    }
535}
536
537// ---------------------------------------------------------------------------
538// Configuration
539// ---------------------------------------------------------------------------
540
541/// `caches.toml`: what an operator changed from the defaults. Everything is
542/// optional; an absent file is every default.
543#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
544#[serde(deny_unknown_fields)]
545pub struct CacheConfig {
546    /// `false` turns caches off for every policy on this host.
547    #[serde(default, skip_serializing_if = "Option::is_none")]
548    pub enabled: Option<bool>,
549    /// The cache root, replacing `<runner root>/_cache`.
550    #[serde(default, skip_serializing_if = "Option::is_none")]
551    pub root: Option<String>,
552    /// The cap on the whole root; `0` means no cap.
553    #[serde(default, skip_serializing_if = "Option::is_none")]
554    pub max_size_gib: Option<u64>,
555    /// Tools switched on or off for every policy, by id.
556    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
557    pub tools: BTreeMap<String, bool>,
558    /// Per-target settings, keyed by `owner/repo` or the organization, in
559    /// lower case.
560    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
561    pub targets: BTreeMap<String, TargetCacheConfig>,
562}
563
564/// One repository's or organization's settings.
565#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
566#[serde(deny_unknown_fields)]
567pub struct TargetCacheConfig {
568    #[serde(default, skip_serializing_if = "Option::is_none")]
569    pub enabled: Option<bool>,
570    /// A shared namespace name, instead of the target's own.
571    #[serde(default, skip_serializing_if = "Option::is_none")]
572    pub namespace: Option<String>,
573    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
574    pub tools: BTreeMap<String, bool>,
575}
576
577impl TargetCacheConfig {
578    fn is_empty(&self) -> bool {
579        self.enabled.is_none() && self.namespace.is_none() && self.tools.is_empty()
580    }
581}
582
583/// The `caches.toml` path for a configuration directory.
584#[must_use]
585pub fn config_path_in(config_dir: &Path) -> PathBuf {
586    config_dir.join(CONFIG_FILE)
587}
588
589/// The key a target's settings are stored under.
590#[must_use]
591pub fn target_key(target: &ScaleTarget) -> String {
592    target.slug().to_ascii_lowercase()
593}
594
595impl CacheConfig {
596    /// Parses and validates the file's text.
597    ///
598    /// # Errors
599    /// Malformed TOML, an unknown key or tool, an invalid namespace, target or
600    /// root.
601    pub fn parse(text: &str, path: &Path) -> Result<Self, CacheError> {
602        let config: Self = toml::from_str(text).map_err(|error| CacheError::Parse {
603            path: path.to_path_buf(),
604            message: error.message().to_owned(),
605        })?;
606        config.validate()?;
607        Ok(config)
608    }
609
610    /// Reads `path`; a file that does not exist is every default.
611    ///
612    /// # Errors
613    /// As [`Self::parse`], and [`CacheError::Io`] when it cannot be read.
614    pub fn load(path: &Path) -> Result<Self, CacheError> {
615        match fs::read_to_string(path) {
616            Ok(text) => Self::parse(&text, path),
617            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
618            Err(source) => Err(CacheError::Io {
619                action: "read",
620                path: path.to_path_buf(),
621                source,
622            }),
623        }
624    }
625
626    fn validate(&self) -> Result<(), CacheError> {
627        if let Some(root) = &self.root {
628            validate_root_text(root)?;
629        }
630        let tool_ids = self
631            .tools
632            .keys()
633            .chain(self.targets.values().flat_map(|target| target.tools.keys()));
634        for id in tool_ids {
635            if tool(id).is_none() {
636                return Err(CacheError::UnknownTool(id.clone()));
637            }
638        }
639        for (key, target) in &self.targets {
640            ScaleTarget::repository(key)
641                .or_else(|_| ScaleTarget::organization(key))
642                .map_err(|_| CacheError::InvalidTarget(key.clone()))?;
643            if let Some(name) = &target.namespace {
644                validate_namespace_name(name)?;
645            }
646        }
647        Ok(())
648    }
649
650    /// Writes the file atomically, after validating it, removing target
651    /// sections that no longer say anything.
652    ///
653    /// # Errors
654    /// [`CacheError::Io`], or a value that would not load back.
655    pub fn save(&self, path: &Path) -> Result<(), CacheError> {
656        let mut config = self.clone();
657        config.targets.retain(|_, target| !target.is_empty());
658        config.validate()?;
659        let mut text = String::from(
660            "# Dependency caches for native runners. Edited by `runner-manager host cache`,\n\
661             # `repo cache` and `org cache`; see `host cache show`.\n",
662        );
663        text.push_str(
664            &toml::to_string(&config).map_err(|error| CacheError::Parse {
665                path: path.to_path_buf(),
666                message: error.to_string(),
667            })?,
668        );
669        write_atomically(path, text.as_bytes())
670    }
671
672    /// The settings for `target`, if any.
673    #[must_use]
674    pub fn target(&self, target: &ScaleTarget) -> Option<&TargetCacheConfig> {
675        self.targets.get(&target_key(target))
676    }
677
678    /// The settings for `target`, created empty if absent.
679    pub fn target_mut(&mut self, target: &ScaleTarget) -> &mut TargetCacheConfig {
680        self.targets.entry(target_key(target)).or_default()
681    }
682
683    /// The cap in bytes; `None` when there is none.
684    #[must_use]
685    pub fn max_bytes(&self) -> Option<u64> {
686        match self.max_size_gib.unwrap_or(DEFAULT_MAX_SIZE_GIB) {
687            0 => None,
688            gib => Some(gib.saturating_mul(GIB)),
689        }
690    }
691
692    /// Whether caches are on for this host at all.
693    #[must_use]
694    pub fn host_enabled(&self) -> bool {
695        self.enabled.unwrap_or(true)
696    }
697}
698
699fn write_atomically(path: &Path, bytes: &[u8]) -> Result<(), CacheError> {
700    crate::host_fitness::write_atomically(path, bytes).map_err(io_error("write", path))
701}
702
703/// A cache root an operator may configure: absolute and free of whitespace.
704///
705/// Whitespace is refused on every platform, not only where the default would
706/// have one: a value such as `MAVEN_ARGS` is split on spaces, and unquoted
707/// paths in tool installers break on them (actions/python-versions' setup
708/// scripts are one).
709///
710/// # Errors
711/// [`CacheError::InvalidRoot`].
712pub fn validate_root_text(root: &str) -> Result<(), CacheError> {
713    // A UNC or device path is a network share or a namespace no tool expects;
714    // `host cache set-root` refuses the first through the runner-root checks,
715    // and a hand-edited file must too.
716    let network = root.starts_with(r"\\") || root.starts_with("//");
717    if network || root.chars().any(char::is_whitespace) || !Path::new(root).is_absolute() {
718        return Err(CacheError::InvalidRoot(root.to_owned()));
719    }
720    Ok(())
721}
722
723/// A shared namespace name: a single, portable, lower-case path segment.
724///
725/// # Errors
726/// [`CacheError::InvalidNamespace`].
727pub fn validate_namespace_name(name: &str) -> Result<(), CacheError> {
728    let valid = !name.is_empty()
729        && name.len() <= 64
730        && !name.starts_with('.')
731        && name
732            .chars()
733            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-'))
734        && !is_windows_device_name(name);
735    if valid {
736        Ok(())
737    } else {
738        Err(CacheError::InvalidNamespace(name.to_owned()))
739    }
740}
741
742/// `CON`, `NUL`, `COM1` and the rest, which Windows will not create as a
743/// directory whatever follows a dot.
744fn is_windows_device_name(segment: &str) -> bool {
745    let stem = segment
746        .split('.')
747        .next()
748        .unwrap_or(segment)
749        .to_ascii_uppercase();
750    matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
751        || (stem.len() == 4
752            && (stem.starts_with("COM") || stem.starts_with("LPT"))
753            && stem.as_bytes()[3].is_ascii_digit())
754}
755
756// ---------------------------------------------------------------------------
757// Root resolution
758// ---------------------------------------------------------------------------
759
760/// Where the cache root came from.
761#[derive(Debug, Clone, Copy, PartialEq, Eq)]
762pub enum RootSource {
763    /// `host cache set-root`.
764    Configured,
765    /// `<runner root>/_cache`.
766    RunnerRoot,
767    /// The runner root has whitespace in it, so a platform location stood in.
768    PlatformFallback,
769}
770
771impl RootSource {
772    #[must_use]
773    pub const fn as_token(self) -> &'static str {
774        match self {
775            Self::Configured => "configured",
776            Self::RunnerRoot => "runner-root",
777            Self::PlatformFallback => "platform-fallback",
778        }
779    }
780}
781
782/// The resolved cache root.
783#[derive(Debug, Clone, PartialEq, Eq)]
784pub struct ResolvedRoot {
785    pub path: PathBuf,
786    pub source: RootSource,
787}
788
789/// The cache root for a host whose effective runner root is `runner_root`.
790///
791/// `fallback` is used when `<runner root>/_cache` would contain whitespace (the
792/// macOS platform-default runner root does: `Application Support`); see
793/// [`platform_fallback_root`]. `None` when nothing usable resolves, with the
794/// reason.
795///
796/// # Errors
797/// The sentence an operator reads about why there is no cache root.
798pub fn resolve_root(
799    config: &CacheConfig,
800    runner_root: Option<&Path>,
801    fallback: Option<PathBuf>,
802) -> Result<ResolvedRoot, String> {
803    if let Some(root) = &config.root {
804        return Ok(ResolvedRoot {
805            path: PathBuf::from(root),
806            source: RootSource::Configured,
807        });
808    }
809    let usable = |path: &Path| !path.to_string_lossy().chars().any(char::is_whitespace);
810    if let Some(runner_root) = runner_root {
811        let candidate = runner_root.join(DEFAULT_ROOT_NAME);
812        if usable(&candidate) {
813            return Ok(ResolvedRoot {
814                path: candidate,
815                source: RootSource::RunnerRoot,
816            });
817        }
818    }
819    match fallback.filter(|path| usable(path) && path.is_absolute()) {
820        Some(path) => Ok(ResolvedRoot {
821            path,
822            source: RootSource::PlatformFallback,
823        }),
824        None => Err(
825            "no cache root without spaces could be derived; set one with \
826             `runner-manager host cache set-root --path <PATH>`"
827                .to_owned(),
828        ),
829    }
830}
831
832/// The platform location used when the runner root has whitespace in it.
833///
834/// Windows: `<system drive>\rman\_cache`, short and inside the default runner
835/// root, so one Defender exclusion covers both. macOS: the `Library/Caches`
836/// beside the `Library/Application Support` the application data is in, so a
837/// boot-mode daemon running as root and the operator's CLI, which share those
838/// directories, resolve the same root; then this account's cache directory.
839/// Linux: this account's cache directory. Each plus `runner-manager`.
840#[must_use]
841pub fn platform_fallback_root(app_paths: &AppPaths) -> Option<PathBuf> {
842    if cfg!(windows) {
843        crate::runner_root::default_runner_root(app_paths)
844            .ok()
845            .map(|root| root.as_path().join(DEFAULT_ROOT_NAME))
846    } else {
847        library_caches_beside(app_paths.runtime_dir())
848            .or_else(|| directories::BaseDirs::new().map(|dirs| dirs.cache_dir().to_path_buf()))
849            .map(|caches| caches.join("runner-manager"))
850    }
851}
852
853/// `<home>/Library/Caches` for a path under `<home>/Library/Application
854/// Support`, the macOS layout of the application data.
855fn library_caches_beside(path: &Path) -> Option<PathBuf> {
856    path.ancestors().find_map(|ancestor| {
857        let library = ancestor.parent()?;
858        (ancestor.file_name()? == "Application Support" && library.file_name()? == "Library")
859            .then(|| library.join("Caches"))
860    })
861}
862
863// ---------------------------------------------------------------------------
864// Selection: what one target gets
865// ---------------------------------------------------------------------------
866
867/// Whose cache a runner uses.
868#[derive(Debug, Clone, PartialEq, Eq)]
869pub enum Namespace {
870    Repository { owner: String, repo: String },
871    Organization(String),
872    Shared(String),
873}
874
875impl Namespace {
876    /// The target's own namespace.
877    #[must_use]
878    pub fn of(target: &ScaleTarget) -> Self {
879        match target {
880            ScaleTarget::Repository(repository) => Self::Repository {
881                owner: repository.owner().to_ascii_lowercase(),
882                repo: repository.repo().to_ascii_lowercase(),
883            },
884            ScaleTarget::Organization(org) => Self::Organization(org.as_str().to_ascii_lowercase()),
885        }
886    }
887
888    /// The two path segments below the cache root.
889    #[must_use]
890    pub fn segments(&self) -> [String; 2] {
891        match self {
892            Self::Repository { owner, repo } => [segment(owner), segment(repo)],
893            Self::Organization(org) => [ORG_PREFIX.to_owned(), segment(org)],
894            Self::Shared(name) => [SHARED_PREFIX.to_owned(), segment(name)],
895        }
896    }
897
898    /// The namespace's directory under `root`.
899    #[must_use]
900    pub fn dir(&self, root: &Path) -> PathBuf {
901        let [first, second] = self.segments();
902        root.join(first).join(second)
903    }
904
905    /// How an operator reads it: `owner/repo`, `_org/name`, `_shared/name`.
906    #[must_use]
907    pub fn label(&self) -> String {
908        self.segments().join("/")
909    }
910}
911
912/// One path segment, made safe for every platform. GitHub names already use
913/// only letters, digits, `.`, `-` and `_`, so the character mapping is
914/// defence in depth. What does matter: a Windows device name gets a `_`, and
915/// so does a trailing `.`, which Windows strips, so that `owner/foo.` and
916/// `owner/foo` cannot share one directory.
917fn segment(raw: &str) -> String {
918    let cleaned = portable_segment(raw);
919    if cleaned.len() <= MAX_SEGMENT_LEN {
920        return cleaned;
921    }
922    // GitHub allows a 100-character repository name; a cache path that long
923    // would spend most of the Windows path budget pnpm's 130-character store
924    // file names need. A shortened name keeps a digest of the full one, so two
925    // cannot collide.
926    let digest = hex::encode(sha2::Sha256::digest(raw.as_bytes()));
927    format!(
928        "{}-{}",
929        &cleaned[..MAX_SEGMENT_LEN - SEGMENT_DIGEST_LEN - 1],
930        &digest[..SEGMENT_DIGEST_LEN]
931    )
932}
933
934/// The longest namespace path segment.
935const MAX_SEGMENT_LEN: usize = 32;
936const SEGMENT_DIGEST_LEN: usize = 8;
937
938fn portable_segment(raw: &str) -> String {
939    let cleaned: String = raw
940        .chars()
941        .map(|c| {
942            if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
943                c
944            } else {
945                '_'
946            }
947        })
948        .collect();
949    let cleaned = match cleaned.as_str() {
950        "" | "." | ".." => format!("_{cleaned}"),
951        _ => cleaned,
952    };
953    if is_windows_device_name(&cleaned) || cleaned.ends_with('.') {
954        format!("{cleaned}_")
955    } else {
956        cleaned
957    }
958}
959
960/// Where one tool's on/off state came from.
961#[derive(Debug, Clone, Copy, PartialEq, Eq)]
962pub enum ToolSource {
963    Default,
964    Host,
965    Target,
966    /// `runner.env` or the service's environment sets one of its variables.
967    Operator,
968}
969
970impl ToolSource {
971    #[must_use]
972    pub const fn as_token(self) -> &'static str {
973        match self {
974            Self::Default => "default",
975            Self::Host => "host",
976            Self::Target => "this target",
977            Self::Operator => "set by runner.env or the service",
978        }
979    }
980}
981
982/// One tool's state for a target.
983#[derive(Debug, Clone, Copy, PartialEq, Eq)]
984pub struct ToolState {
985    pub tool: &'static CacheTool,
986    pub enabled: bool,
987    pub source: ToolSource,
988}
989
990/// What a target gets.
991#[derive(Debug, Clone, PartialEq, Eq)]
992pub struct Selection {
993    /// `None` when caches are on; otherwise why not.
994    pub disabled: Option<&'static str>,
995    pub namespace: Namespace,
996    /// Every tool that applies to the platform, on or off.
997    pub tools: Vec<ToolState>,
998}
999
1000impl Selection {
1001    /// The tools that are on.
1002    pub fn enabled_tools(&self) -> impl Iterator<Item = &'static CacheTool> + '_ {
1003        self.tools
1004            .iter()
1005            .filter(|state| state.enabled)
1006            .map(|state| state.tool)
1007    }
1008}
1009
1010/// The reason an organization policy has no cache until an operator opts in.
1011pub const ORGANIZATION_OPT_IN: &str = "organization policies accept jobs from every repository \
1012     in the organization, so they get a cache only with `org cache set-enabled ORG --enabled \
1013     true`";
1014
1015/// What `target` gets on `platform` under `config`. Host-wide tool settings
1016/// override the defaults and the target's override both.
1017#[must_use]
1018pub fn select(config: &CacheConfig, target: &ScaleTarget, platform: RunnerPlatform) -> Selection {
1019    let own = config.target(target);
1020    let namespace = own
1021        .and_then(|own| own.namespace.clone())
1022        .map_or_else(|| Namespace::of(target), Namespace::Shared);
1023    let disabled = if !config.host_enabled() {
1024        Some("caches are turned off on this host")
1025    } else {
1026        match (own.and_then(|own| own.enabled), target) {
1027            (Some(true), _) => None,
1028            (Some(false), _) => Some("caches are turned off for this target"),
1029            (None, ScaleTarget::Repository(_)) => None,
1030            (None, ScaleTarget::Organization(_)) => Some(ORGANIZATION_OPT_IN),
1031        }
1032    };
1033    let tools = TOOLS
1034        .iter()
1035        .filter(|tool| tool.applies_to(platform))
1036        .map(|tool| {
1037            let mut state = ToolState {
1038                tool,
1039                enabled: tool.default_enabled,
1040                source: ToolSource::Default,
1041            };
1042            if let Some(enabled) = config.tools.get(tool.id) {
1043                state.enabled = *enabled;
1044                state.source = ToolSource::Host;
1045            }
1046            if let Some(enabled) = own.and_then(|own| own.tools.get(tool.id)) {
1047                state.enabled = *enabled;
1048                state.source = ToolSource::Target;
1049            }
1050            state
1051        })
1052        .collect();
1053    Selection {
1054        disabled,
1055        namespace,
1056        tools,
1057    }
1058}
1059
1060// ---------------------------------------------------------------------------
1061// The environment
1062// ---------------------------------------------------------------------------
1063
1064/// The directory a tool uses: under the namespace, or under the slot.
1065#[must_use]
1066fn tool_dir(tool: &CacheTool, namespace_dir: &Path, slot_dir: &Path) -> PathBuf {
1067    match tool.sharing {
1068        Sharing::Namespace => namespace_dir.join(tool.dir),
1069        Sharing::Slot => slot_dir.join(tool.dir),
1070    }
1071}
1072
1073/// One variable's value, and the directory it names if it names one.
1074fn render(variable: &CacheVariable, dir: &Path) -> (OsString, Option<PathBuf>) {
1075    match variable.value {
1076        VarValue::Dir => (dir.as_os_str().to_owned(), Some(dir.to_path_buf())),
1077        VarValue::Sub(sub) => {
1078            let path = dir.join(sub);
1079            (path.as_os_str().to_owned(), Some(path))
1080        }
1081        VarValue::Literal(value) => (OsString::from(value), None),
1082        VarValue::Template(template) => (
1083            OsString::from(template.replace("{dir}", &dir.to_string_lossy())),
1084            Some(dir.to_path_buf()),
1085        ),
1086    }
1087}
1088
1089/// The variables `tools` set on `platform`, and the directories they name.
1090#[must_use]
1091pub fn environment<'a>(
1092    tools: impl IntoIterator<Item = &'a CacheTool>,
1093    namespace_dir: &Path,
1094    slot_dir: &Path,
1095    platform: RunnerPlatform,
1096) -> (Vec<(&'static str, OsString)>, Vec<PathBuf>) {
1097    let mut variables = Vec::new();
1098    let mut dirs = Vec::new();
1099    for tool in tools {
1100        let dir = tool_dir(tool, namespace_dir, slot_dir);
1101        for variable in tool.variables_on(platform) {
1102            let (value, named) = render(variable, &dir);
1103            variables.push((variable.name, value));
1104            dirs.extend(named);
1105        }
1106    }
1107    (variables, dirs)
1108}
1109
1110impl Selection {
1111    /// Turns off every tool any of whose variables `is_set` says somebody
1112    /// already set: in `runner.env`, or in the service's own environment (a
1113    /// systemd drop-in, the launchd plist, a machine-wide Windows variable).
1114    ///
1115    /// The whole tool, not the one name: an operator's `RUNNER_TOOL_CACHE`
1116    /// next to this module's `AGENT_TOOLSDIRECTORY` would split one tool cache
1117    /// in two (setup-python prefers the latter), and their
1118    /// `PLAYWRIGHT_BROWSERS_PATH` would get a `PLAYWRIGHT_SKIP_BROWSER_GC` for
1119    /// a directory nothing prunes.
1120    pub fn defer_to_operator(&mut self, platform: RunnerPlatform, is_set: impl Fn(&str) -> bool) {
1121        for state in &mut self.tools {
1122            if state.enabled && state.tool.variables_on(platform).any(|v| is_set(v.name)) {
1123                state.enabled = false;
1124                state.source = ToolSource::Operator;
1125            }
1126        }
1127    }
1128}
1129
1130/// Whether this process's environment sets a variable named `name`, compared
1131/// without regard to case: npm reads `npm_config_*` in any case, so the
1132/// service's `NPM_CONFIG_CACHE` and this module's `npm_config_cache` are one
1133/// setting even on Unix, and the later one would win.
1134#[must_use]
1135pub fn inherited(name: &str) -> bool {
1136    std::env::vars_os().any(|(key, value)| {
1137        !value.is_empty()
1138            && key
1139                .to_str()
1140                .is_some_and(|key| key.eq_ignore_ascii_case(name))
1141    })
1142}
1143
1144// ---------------------------------------------------------------------------
1145// Launch: lease a slot, create the directories
1146// ---------------------------------------------------------------------------
1147
1148/// Whether the attempt whose runtime is `runtime` may still be running a
1149/// runner: its runner package is still there. Cleanup removes it in both
1150/// workspace modes.
1151#[must_use]
1152pub fn runtime_holds_runner(runtime: &Path) -> bool {
1153    // Fails closed: only "it is not there" releases a lease. A runtime this
1154    // account cannot inspect (`host cache prune` run as another user than the
1155    // service) is treated as held.
1156    match fs::metadata(runtime.join("bin")) {
1157        Ok(metadata) => metadata.is_dir(),
1158        Err(error) => error.kind() != io::ErrorKind::NotFound,
1159    }
1160}
1161
1162/// What one launch gets.
1163#[derive(Debug, Clone, PartialEq, Eq)]
1164pub struct LaunchCaches {
1165    pub namespace_dir: PathBuf,
1166    pub slot: u32,
1167    pub variables: Vec<(&'static str, OsString)>,
1168}
1169
1170fn lock_cache_root(root: &Path) -> Result<HostLock, CacheError> {
1171    HostLock::acquire_at(
1172        &root.join(LockKind::DependencyCache.file_name()),
1173        LockKind::DependencyCache,
1174        LOCK_WAIT,
1175    )
1176    .map_err(|error| CacheError::Lock(error.to_string()))
1177}
1178
1179/// Leases a slot in `selection`'s namespace for the attempt at `runtime`,
1180/// creates every directory its variables name, and marks the namespace used.
1181///
1182/// `holds_runner` decides whether an existing lease is still held; production
1183/// passes [`runtime_holds_runner`].
1184///
1185/// # Errors
1186/// The lock could not be taken, or a directory or lease could not be written.
1187pub fn prepare_launch(
1188    root: &Path,
1189    selection: &Selection,
1190    runtime: &Path,
1191    platform: RunnerPlatform,
1192    holds_runner: &dyn Fn(&Path) -> bool,
1193) -> Result<LaunchCaches, CacheError> {
1194    fs::create_dir_all(root).map_err(io_error("create", root))?;
1195    let namespace_dir = selection.namespace.dir(root);
1196    let slots = namespace_dir.join(SLOTS_DIR);
1197    let slot = {
1198        let _lock = lock_cache_root(root)?;
1199        fs::create_dir_all(&slots).map_err(io_error("create", &slots))?;
1200        let slot = free_slot(&slots, runtime, holds_runner);
1201        let lease = slots.join(format!("{slot}.{LEASE_EXTENSION}"));
1202        fs::write(&lease, runtime.to_string_lossy().as_bytes())
1203            .map_err(io_error("write", &lease))?;
1204        let marker = namespace_dir.join(LAST_USED_FILE);
1205        fs::write(&marker, Utc::now().to_rfc3339().as_bytes())
1206            .map_err(io_error("write", &marker))?;
1207        slot
1208    };
1209    let slot_dir = slots.join(slot.to_string());
1210    let (variables, dirs) = environment(
1211        selection.enabled_tools(),
1212        &namespace_dir,
1213        &slot_dir,
1214        platform,
1215    );
1216    for dir in dirs {
1217        fs::create_dir_all(&dir).map_err(io_error("create", &dir))?;
1218    }
1219    Ok(LaunchCaches {
1220        namespace_dir,
1221        slot,
1222        variables,
1223    })
1224}
1225
1226/// The lowest slot whose lease is absent, is this attempt's own, or belongs to
1227/// an attempt that no longer holds a runner.
1228fn free_slot(slots: &Path, runtime: &Path, holds_runner: &dyn Fn(&Path) -> bool) -> u32 {
1229    let own = runtime.to_string_lossy();
1230    (1..)
1231        .find(
1232            |slot| match fs::read_to_string(slots.join(format!("{slot}.{LEASE_EXTENSION}"))) {
1233                Err(error) => error.kind() == io::ErrorKind::NotFound,
1234                Ok(holder) => holder == own || !holds_runner(Path::new(&holder)),
1235            },
1236        )
1237        .expect("an unbounded range has a free slot")
1238}
1239
1240/// Whether any lease in the namespace is still held.
1241fn namespace_in_use(namespace_dir: &Path, holds_runner: &dyn Fn(&Path) -> bool) -> bool {
1242    let Ok(entries) = fs::read_dir(namespace_dir.join(SLOTS_DIR)) else {
1243        return false;
1244    };
1245    entries.flatten().any(|entry| {
1246        let path = entry.path();
1247        path.extension().is_some_and(|ext| ext == LEASE_EXTENSION)
1248            && fs::read_to_string(&path).map_or(true, |holder| holds_runner(Path::new(&holder)))
1249    })
1250}
1251
1252// ---------------------------------------------------------------------------
1253// Measurement and pruning
1254// ---------------------------------------------------------------------------
1255
1256/// One namespace's size and age.
1257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1258pub struct NamespaceUsage {
1259    /// `owner/repo`, `_org/name` or `_shared/name`.
1260    pub name: String,
1261    pub bytes: u64,
1262    pub last_used: Option<DateTime<Utc>>,
1263    pub in_use: bool,
1264}
1265
1266/// The whole root, as last measured.
1267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1268pub struct CacheUsage {
1269    pub measured_at: DateTime<Utc>,
1270    pub total_bytes: u64,
1271    pub max_bytes: Option<u64>,
1272    pub namespaces: Vec<NamespaceUsage>,
1273    /// Namespaces this pass removed.
1274    pub pruned: Vec<String>,
1275}
1276
1277impl CacheUsage {
1278    /// The daemon's last measurement of `root`, if it wrote one.
1279    #[must_use]
1280    pub fn read(root: &Path) -> Option<Self> {
1281        let bytes = fs::read(root.join(USAGE_FILE)).ok()?;
1282        serde_json::from_slice(&bytes).ok()
1283    }
1284}
1285
1286/// The total size of the regular files under `path`, without following links.
1287fn tree_size(path: &Path) -> u64 {
1288    let mut total = 0_u64;
1289    let mut pending = vec![path.to_path_buf()];
1290    while let Some(dir) = pending.pop() {
1291        let Ok(entries) = fs::read_dir(&dir) else {
1292            continue;
1293        };
1294        for entry in entries.flatten() {
1295            let Ok(metadata) = entry.metadata() else {
1296                continue;
1297            };
1298            if metadata.is_dir() {
1299                pending.push(entry.path());
1300            } else if metadata.is_file() {
1301                total = total.saturating_add(metadata.len());
1302            }
1303        }
1304    }
1305    total
1306}
1307
1308fn modified(path: &Path) -> Option<SystemTime> {
1309    fs::metadata(path).and_then(|m| m.modified()).ok()
1310}
1311
1312/// Every namespace directory under `root`: exactly two levels down, below a
1313/// first level that does not start with `.`, and marked as one by a launch
1314/// (`.last-used` or `_slots`). A directory nothing here created is never
1315/// measured, so never pruned, whatever else shares the root.
1316fn namespace_dirs(root: &Path) -> Vec<(String, PathBuf)> {
1317    let mut found = Vec::new();
1318    let Ok(first) = fs::read_dir(root) else {
1319        return found;
1320    };
1321    for outer in first.flatten() {
1322        let outer_name = outer.file_name().to_string_lossy().into_owned();
1323        if outer_name.starts_with('.') || !outer.file_type().is_ok_and(|t| t.is_dir()) {
1324            continue;
1325        }
1326        let Ok(second) = fs::read_dir(outer.path()) else {
1327            continue;
1328        };
1329        for inner in second.flatten() {
1330            let path = inner.path();
1331            let marked = path.join(LAST_USED_FILE).is_file() || path.join(SLOTS_DIR).is_dir();
1332            if marked && inner.file_type().is_ok_and(|t| t.is_dir()) {
1333                let name = format!("{outer_name}/{}", inner.file_name().to_string_lossy());
1334                found.push((name, path));
1335            }
1336        }
1337    }
1338    found.sort();
1339    found
1340}
1341
1342/// Measures every namespace under `root`.
1343#[must_use]
1344pub(crate) fn measure(root: &Path, holds_runner: &dyn Fn(&Path) -> bool) -> Vec<NamespaceUsage> {
1345    namespace_dirs(root)
1346        .into_iter()
1347        .map(|(name, dir)| NamespaceUsage {
1348            bytes: tree_size(&dir),
1349            last_used: modified(&dir.join(LAST_USED_FILE))
1350                .or_else(|| modified(&dir))
1351                .map(DateTime::<Utc>::from),
1352            in_use: namespace_in_use(&dir, holds_runner),
1353            name,
1354        })
1355        .collect()
1356}
1357
1358/// Which namespaces to remove to bring the total within `max_bytes`: those
1359/// not in use, least recently used first, until the total fits. In-use
1360/// namespaces are never chosen, even if the total cannot fit without them.
1361#[must_use]
1362pub(crate) fn plan_prune(namespaces: &[NamespaceUsage], max_bytes: Option<u64>) -> Vec<String> {
1363    let Some(max) = max_bytes else {
1364        return Vec::new();
1365    };
1366    let mut total: u64 = namespaces.iter().map(|ns| ns.bytes).sum();
1367    let mut idle: Vec<&NamespaceUsage> = namespaces.iter().filter(|ns| !ns.in_use).collect();
1368    // Never-used (no marker, no mtime) sorts first: nothing can be relying on it.
1369    idle.sort_by(|a, b| a.last_used.cmp(&b.last_used).then(a.name.cmp(&b.name)));
1370    let mut chosen = Vec::new();
1371    for namespace in idle {
1372        if total <= max {
1373            break;
1374        }
1375        total = total.saturating_sub(namespace.bytes);
1376        chosen.push(namespace.name.clone());
1377    }
1378    chosen
1379}
1380
1381/// Measures `root`, removes least-recently-used idle namespaces until it fits
1382/// under `max_bytes`, and records the result in [`USAGE_FILE`].
1383///
1384/// Each chosen namespace is re-checked under [`LockKind::DependencyCache`]
1385/// and renamed into `.trash` before anything is deleted, so a launch that
1386/// leased it after the measurement keeps it.
1387///
1388/// # Errors
1389/// The lock could not be taken or the usage file could not be written. A
1390/// namespace that cannot be moved or deleted is skipped, not an error.
1391pub fn prune(
1392    root: &Path,
1393    max_bytes: Option<u64>,
1394    holds_runner: &dyn Fn(&Path) -> bool,
1395) -> Result<CacheUsage, CacheError> {
1396    empty_trash(root);
1397    let mut namespaces = measure(root, holds_runner);
1398    let planned = plan_prune(&namespaces, max_bytes);
1399    let mut pruned = Vec::new();
1400    if !planned.is_empty() {
1401        let trash = root.join(TRASH_DIR);
1402        fs::create_dir_all(&trash).map_err(io_error("create", &trash))?;
1403        let _lock = lock_cache_root(root)?;
1404        for name in planned {
1405            let dir = root.join(&name);
1406            if namespace_in_use(&dir, holds_runner) {
1407                continue;
1408            }
1409            let aside = trash.join(uuid::Uuid::new_v4().simple().to_string());
1410            if fs::rename(&dir, &aside).is_ok() {
1411                pruned.push(name);
1412            }
1413        }
1414    }
1415    empty_trash(root);
1416    namespaces.retain(|ns| !pruned.contains(&ns.name));
1417    let usage = CacheUsage {
1418        measured_at: Utc::now(),
1419        total_bytes: namespaces.iter().map(|ns| ns.bytes).sum(),
1420        max_bytes,
1421        namespaces,
1422        pruned,
1423    };
1424    let json = serde_json::to_vec_pretty(&usage).map_err(|error| CacheError::Io {
1425        action: "encode",
1426        path: root.join(USAGE_FILE),
1427        source: io::Error::other(error),
1428    })?;
1429    write_atomically(&root.join(USAGE_FILE), &json)?;
1430    Ok(usage)
1431}
1432
1433/// Deletes whatever is in `.trash`. Best effort: what cannot go now goes on a
1434/// later pass.
1435fn empty_trash(root: &Path) {
1436    let Ok(entries) = fs::read_dir(root.join(TRASH_DIR)) else {
1437        return;
1438    };
1439    for entry in entries.flatten() {
1440        let _ = remove_tree(&entry.path());
1441    }
1442}
1443
1444/// Removes a pruned namespace without following links.
1445///
1446/// Windows uses the `remove_dir_all` crate, which clears the read-only
1447/// attribute Go puts on its module cache. Unix uses `std`, as the attempt
1448/// cleanup does (the crate can block on a FIFO there), and only after a
1449/// refusal opens the read-only directories Go leaves and tries once more.
1450fn remove_tree(path: &Path) -> io::Result<()> {
1451    #[cfg(windows)]
1452    {
1453        remove_dir_all::remove_dir_all(path)
1454    }
1455    #[cfg(not(windows))]
1456    {
1457        match fs::remove_dir_all(path) {
1458            Err(error) if error.kind() == io::ErrorKind::PermissionDenied => {
1459                open_directories(path);
1460                fs::remove_dir_all(path)
1461            }
1462            other => other,
1463        }
1464    }
1465}
1466
1467/// Gives the owner `rwx` on every directory under `path`, not following links.
1468#[cfg(not(windows))]
1469fn open_directories(path: &Path) {
1470    use std::os::unix::fs::PermissionsExt as _;
1471    let mut pending = vec![path.to_path_buf()];
1472    while let Some(current) = pending.pop() {
1473        let Ok(metadata) = fs::symlink_metadata(&current) else {
1474            continue;
1475        };
1476        if !metadata.is_dir() {
1477            continue;
1478        }
1479        let mode = metadata.permissions().mode();
1480        if mode & 0o700 != 0o700 {
1481            let _ = fs::set_permissions(&current, fs::Permissions::from_mode(mode | 0o700));
1482        }
1483        if let Ok(entries) = fs::read_dir(&current) {
1484            pending.extend(entries.flatten().map(|entry| entry.path()));
1485        }
1486    }
1487}
1488
1489/// Every tool id, in table order, for help text and errors.
1490#[must_use]
1491pub fn tool_ids() -> Vec<&'static str> {
1492    TOOLS.iter().map(|tool| tool.id).collect()
1493}
1494
1495/// A size for an operator: `1.5 GiB`, `320 MiB`.
1496#[must_use]
1497pub fn human_bytes(bytes: u64) -> String {
1498    #[allow(clippy::cast_precision_loss)]
1499    let value = bytes as f64;
1500    if bytes >= GIB {
1501        format!("{:.1} GiB", value / GIB as f64)
1502    } else if bytes >= 1024 * 1024 {
1503        format!("{:.0} MiB", value / (1024.0 * 1024.0))
1504    } else {
1505        format!("{:.0} KiB", value / 1024.0)
1506    }
1507}
1508
1509#[cfg(test)]
1510mod tests;