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