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