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