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