1use 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
98pub const CONFIG_FILE: &str = "caches.toml";
100
101pub const DEFAULT_ROOT_NAME: &str = "_cache";
103
104pub const DEFAULT_MAX_SIZE_GIB: u64 = 20;
106
107pub const PRUNE_INTERVAL: Duration = Duration::from_secs(30 * 60);
109
110pub const LOCK_WAIT: Duration = Duration::from_secs(30);
112
113pub const USAGE_FILE: &str = ".usage.json";
115
116const TRASH_DIR: &str = ".trash";
117const SLOTS_DIR: &str = "_slots";
118const LEASE_EXTENSION: &str = "lease";
119const LAST_USED_FILE: &str = ".last-used";
120const ORG_PREFIX: &str = "_org";
121const SHARED_PREFIX: &str = "_shared";
122const GIB: u64 = 1024 * 1024 * 1024;
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub struct Platforms {
131 pub windows: bool,
132 pub macos: bool,
133 pub linux: bool,
134}
135
136impl Platforms {
137 pub const ALL: Self = Self {
138 windows: true,
139 macos: true,
140 linux: true,
141 };
142 pub const UNIX: Self = Self {
143 windows: false,
144 macos: true,
145 linux: true,
146 };
147
148 #[must_use]
149 pub const fn includes(self, platform: RunnerPlatform) -> bool {
150 match platform {
151 RunnerPlatform::Windows => self.windows,
152 RunnerPlatform::MacOs => self.macos,
153 RunnerPlatform::Linux => self.linux,
154 }
155 }
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub enum Sharing {
161 Namespace,
164 Slot,
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum VarValue {
171 Dir,
173 Sub(&'static str),
175 Literal(&'static str),
177 Template(&'static str),
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub struct CacheVariable {
185 pub name: &'static str,
186 pub value: VarValue,
187 pub platforms: Platforms,
188}
189
190const fn all(name: &'static str, value: VarValue) -> CacheVariable {
191 CacheVariable {
192 name,
193 value,
194 platforms: Platforms::ALL,
195 }
196}
197
198const fn unix(name: &'static str, value: VarValue) -> CacheVariable {
199 CacheVariable {
200 name,
201 value,
202 platforms: Platforms::UNIX,
203 }
204}
205
206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208pub struct CacheTool {
209 pub id: &'static str,
211 pub dir: &'static str,
213 pub sharing: Sharing,
214 pub default_enabled: bool,
216 pub variables: &'static [CacheVariable],
217 pub note: &'static str,
219}
220
221impl CacheTool {
222 pub fn variables_on(&self, platform: RunnerPlatform) -> impl Iterator<Item = &CacheVariable> {
224 self.variables
225 .iter()
226 .filter(move |variable| variable.platforms.includes(platform))
227 }
228
229 #[must_use]
231 pub fn applies_to(&self, platform: RunnerPlatform) -> bool {
232 self.variables_on(platform).next().is_some()
233 }
234}
235
236pub const TOOLS: &[CacheTool] = &[
242 CacheTool {
243 id: "xdg",
244 dir: "xdg",
245 sharing: Sharing::Namespace,
246 default_enabled: true,
247 variables: &[unix("XDG_CACHE_HOME", VarValue::Dir)],
248 note: "catch-all for Unix tools that follow the XDG base directory specification; not set \
249 on Windows, where pnpm, Bun and Deno would also move global state",
250 },
251 CacheTool {
252 id: "tool-cache",
253 dir: "tool-cache",
254 sharing: Sharing::Slot,
255 default_enabled: true,
256 variables: &[
257 all("RUNNER_TOOL_CACHE", VarValue::Dir),
258 all("AGENT_TOOLSDIRECTORY", VarValue::Dir),
259 ],
260 note: "toolchains setup-node, setup-go and setup-java download; per slot because \
261 @actions/tool-cache deletes a version's folder before filling it",
262 },
263 CacheTool {
264 id: "dotnet",
265 dir: "dotnet",
266 sharing: Sharing::Slot,
267 default_enabled: true,
268 variables: &[all("DOTNET_INSTALL_DIR", VarValue::Dir)],
269 note: "SDKs setup-dotnet installs; per slot because an install replaces the shared dotnet \
270 host",
271 },
272 CacheTool {
273 id: "npm",
274 dir: "npm",
275 sharing: Sharing::Namespace,
276 default_enabled: true,
277 variables: &[all("npm_config_cache", VarValue::Dir)],
278 note: "npm's cacache and npx",
279 },
280 CacheTool {
281 id: "pnpm",
282 dir: "pnpm-store",
283 sharing: Sharing::Namespace,
284 default_enabled: true,
285 variables: &[
286 all("npm_config_store_dir", VarValue::Dir),
287 all("PNPM_CONFIG_STORE_DIR", VarValue::Dir),
288 ],
289 note: "the content-addressed store: pnpm 9 and 10 read npm_config_store_dir, pnpm 11 and \
290 later PNPM_CONFIG_STORE_DIR; npm 12 warns that store-dir is unknown",
291 },
292 CacheTool {
293 id: "yarn",
294 dir: "yarn",
295 sharing: Sharing::Namespace,
296 default_enabled: true,
297 variables: &[all("YARN_GLOBAL_FOLDER", VarValue::Dir)],
298 note: "Yarn 2+ global cache; YARN_CACHE_FOLDER is left alone because it would override a \
299 zero-install project's committed cache",
300 },
301 CacheTool {
302 id: "bun",
303 dir: "bun",
304 sharing: Sharing::Namespace,
305 default_enabled: true,
306 variables: &[all("BUN_INSTALL_CACHE_DIR", VarValue::Dir)],
307 note: "Bun's package cache",
308 },
309 CacheTool {
310 id: "deno",
311 dir: "deno",
312 sharing: Sharing::Namespace,
313 default_enabled: true,
314 variables: &[all("DENO_DIR", VarValue::Dir)],
315 note: "Deno's module and npm cache (it also keeps localStorage there)",
316 },
317 CacheTool {
318 id: "node-gyp",
319 dir: "node-gyp",
320 sharing: Sharing::Namespace,
321 default_enabled: true,
322 variables: &[
323 all("npm_config_devdir", VarValue::Dir),
324 all("npm_package_config_node_gyp_devdir", VarValue::Dir),
325 ],
326 note: "Node headers node-gyp downloads for native modules",
327 },
328 CacheTool {
329 id: "electron",
330 dir: "electron",
331 sharing: Sharing::Namespace,
332 default_enabled: true,
333 variables: &[
334 all("electron_config_cache", VarValue::Dir),
337 all("ELECTRON_CACHE", VarValue::Dir),
338 ],
339 note: "Electron release downloads",
340 },
341 CacheTool {
342 id: "electron-builder",
343 dir: "electron-builder",
344 sharing: Sharing::Namespace,
345 default_enabled: true,
346 variables: &[all("ELECTRON_BUILDER_CACHE", VarValue::Dir)],
347 note: "NSIS, winCodeSign, AppImage and other packaging tools",
348 },
349 CacheTool {
350 id: "playwright",
351 dir: "ms-playwright",
352 sharing: Sharing::Namespace,
353 default_enabled: true,
354 variables: &[
355 all("PLAYWRIGHT_BROWSERS_PATH", VarValue::Dir),
356 all("PLAYWRIGHT_SKIP_BROWSER_GC", VarValue::Literal("1")),
359 ],
360 note: "Playwright browsers",
361 },
362 CacheTool {
363 id: "cypress",
364 dir: "cypress",
365 sharing: Sharing::Slot,
366 default_enabled: true,
367 variables: &[all("CYPRESS_CACHE_FOLDER", VarValue::Dir)],
368 note: "Cypress binaries; per slot because cypress install takes no lock",
369 },
370 CacheTool {
371 id: "nuget",
372 dir: "nuget",
373 sharing: Sharing::Namespace,
374 default_enabled: true,
375 variables: &[
376 all("NUGET_PACKAGES", VarValue::Sub("packages")),
377 all("NUGET_HTTP_CACHE_PATH", VarValue::Sub("http-cache")),
378 all("NUGET_PLUGINS_CACHE_PATH", VarValue::Sub("plugins-cache")),
379 all("NUGET_SCRATCH", VarValue::Sub("scratch")),
383 ],
384 note: "global packages and HTTP cache",
385 },
386 CacheTool {
387 id: "pip",
388 dir: "pip",
389 sharing: Sharing::Namespace,
390 default_enabled: true,
391 variables: &[all("PIP_CACHE_DIR", VarValue::Dir)],
392 note: "pip's HTTP and wheel cache",
393 },
394 CacheTool {
395 id: "uv",
396 dir: "uv",
397 sharing: Sharing::Namespace,
398 default_enabled: true,
399 variables: &[all("UV_CACHE_DIR", VarValue::Dir)],
400 note: "uv's cache",
401 },
402 CacheTool {
403 id: "go",
404 dir: "go",
405 sharing: Sharing::Namespace,
406 default_enabled: true,
407 variables: &[
408 all("GOMODCACHE", VarValue::Sub("mod")),
409 all("GOCACHE", VarValue::Sub("build")),
410 ],
411 note: "Go module and build caches",
412 },
413 CacheTool {
414 id: "composer",
415 dir: "composer",
416 sharing: Sharing::Namespace,
417 default_enabled: true,
418 variables: &[all("COMPOSER_CACHE_DIR", VarValue::Dir)],
419 note: "Composer's download cache",
420 },
421 CacheTool {
422 id: "cocoapods",
423 dir: "cocoapods",
424 sharing: Sharing::Namespace,
425 default_enabled: true,
426 variables: &[unix("CP_CACHE_DIR", VarValue::Dir)],
427 note: "CocoaPods download cache",
428 },
429 CacheTool {
430 id: "ccache",
431 dir: "ccache",
432 sharing: Sharing::Namespace,
433 default_enabled: true,
434 variables: &[all("CCACHE_DIR", VarValue::Dir)],
435 note: "ccache objects",
436 },
437 CacheTool {
438 id: "poetry",
439 dir: "poetry",
440 sharing: Sharing::Slot,
441 default_enabled: false,
442 variables: &[all("POETRY_CACHE_DIR", VarValue::Dir)],
443 note: "opt-in: Poetry also keeps virtualenvs in its cache directory, one per workspace path",
444 },
445 CacheTool {
446 id: "cargo",
447 dir: "cargo",
448 sharing: Sharing::Namespace,
449 default_enabled: false,
450 variables: &[all("CARGO_HOME", VarValue::Dir)],
451 note: "opt-in: CARGO_HOME also holds config, registry credentials and installed binaries",
452 },
453 CacheTool {
454 id: "gradle",
455 dir: "gradle",
456 sharing: Sharing::Namespace,
457 default_enabled: false,
458 variables: &[all("GRADLE_USER_HOME", VarValue::Dir)],
459 note: "opt-in: GRADLE_USER_HOME also holds gradle.properties, init scripts and daemons",
460 },
461 CacheTool {
462 id: "maven",
463 dir: "maven",
464 sharing: Sharing::Slot,
465 default_enabled: false,
466 variables: &[all(
467 "MAVEN_ARGS",
468 VarValue::Template("-Dmaven.repo.local={dir}"),
469 )],
470 note: "opt-in: Maven 3.9+ local repository via MAVEN_ARGS, which a workflow's own \
471 MAVEN_ARGS replaces",
472 },
473 CacheTool {
474 id: "pub",
475 dir: "pub",
476 sharing: Sharing::Slot,
477 default_enabled: false,
478 variables: &[all("PUB_CACHE", VarValue::Dir)],
479 note: "opt-in: PUB_CACHE also holds globally activated Dart tools",
480 },
481 CacheTool {
482 id: "bundler",
483 dir: "bundler",
484 sharing: Sharing::Slot,
485 default_enabled: false,
486 variables: &[all("BUNDLE_USER_CACHE", VarValue::Dir)],
487 note: "opt-in: Bundler's download cache",
488 },
489];
490
491#[must_use]
493pub fn tool(id: &str) -> Option<&'static CacheTool> {
494 TOOLS.iter().find(|tool| tool.id == id)
495}
496
497#[derive(Debug, thiserror::Error)]
503pub enum CacheError {
504 #[error("{} is not valid TOML for caches: {message}", path.display())]
505 Parse { path: PathBuf, message: String },
506 #[error("`{0}` is not a cache this version knows; `host cache show` lists them")]
507 UnknownTool(String),
508 #[error(
509 "`{0}` is not a valid shared namespace name; use 1 to 64 lower-case letters, digits, \
510 `.`, `_` or `-`, not starting with `.`"
511 )]
512 InvalidNamespace(String),
513 #[error("`{0}` is not a repository (OWNER/REPO) or an organization")]
514 InvalidTarget(String),
515 #[error("the cache root {0} must be an absolute path without spaces")]
516 InvalidRoot(String),
517 #[error("cannot {action} {}: {source}", path.display())]
518 Io {
519 action: &'static str,
520 path: PathBuf,
521 #[source]
522 source: io::Error,
523 },
524 #[error("{0}")]
525 Lock(String),
526}
527
528fn io_error(action: &'static str, path: &Path) -> impl FnOnce(io::Error) -> CacheError + use<> {
529 let path = path.to_path_buf();
530 move |source| CacheError::Io {
531 action,
532 path,
533 source,
534 }
535}
536
537#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
544#[serde(deny_unknown_fields)]
545pub struct CacheConfig {
546 #[serde(default, skip_serializing_if = "Option::is_none")]
548 pub enabled: Option<bool>,
549 #[serde(default, skip_serializing_if = "Option::is_none")]
551 pub root: Option<String>,
552 #[serde(default, skip_serializing_if = "Option::is_none")]
554 pub max_size_gib: Option<u64>,
555 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
557 pub tools: BTreeMap<String, bool>,
558 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
561 pub targets: BTreeMap<String, TargetCacheConfig>,
562}
563
564#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
566#[serde(deny_unknown_fields)]
567pub struct TargetCacheConfig {
568 #[serde(default, skip_serializing_if = "Option::is_none")]
569 pub enabled: Option<bool>,
570 #[serde(default, skip_serializing_if = "Option::is_none")]
572 pub namespace: Option<String>,
573 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
574 pub tools: BTreeMap<String, bool>,
575}
576
577impl TargetCacheConfig {
578 fn is_empty(&self) -> bool {
579 self.enabled.is_none() && self.namespace.is_none() && self.tools.is_empty()
580 }
581}
582
583#[must_use]
585pub fn config_path_in(config_dir: &Path) -> PathBuf {
586 config_dir.join(CONFIG_FILE)
587}
588
589#[must_use]
591pub fn target_key(target: &ScaleTarget) -> String {
592 target.slug().to_ascii_lowercase()
593}
594
595impl CacheConfig {
596 pub fn parse(text: &str, path: &Path) -> Result<Self, CacheError> {
602 let config: Self = toml::from_str(text).map_err(|error| CacheError::Parse {
603 path: path.to_path_buf(),
604 message: error.message().to_owned(),
605 })?;
606 config.validate()?;
607 Ok(config)
608 }
609
610 pub fn load(path: &Path) -> Result<Self, CacheError> {
615 match fs::read_to_string(path) {
616 Ok(text) => Self::parse(&text, path),
617 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
618 Err(source) => Err(CacheError::Io {
619 action: "read",
620 path: path.to_path_buf(),
621 source,
622 }),
623 }
624 }
625
626 fn validate(&self) -> Result<(), CacheError> {
627 if let Some(root) = &self.root {
628 validate_root_text(root)?;
629 }
630 let tool_ids = self
631 .tools
632 .keys()
633 .chain(self.targets.values().flat_map(|target| target.tools.keys()));
634 for id in tool_ids {
635 if tool(id).is_none() {
636 return Err(CacheError::UnknownTool(id.clone()));
637 }
638 }
639 for (key, target) in &self.targets {
640 ScaleTarget::repository(key)
641 .or_else(|_| ScaleTarget::organization(key))
642 .map_err(|_| CacheError::InvalidTarget(key.clone()))?;
643 if let Some(name) = &target.namespace {
644 validate_namespace_name(name)?;
645 }
646 }
647 Ok(())
648 }
649
650 pub fn save(&self, path: &Path) -> Result<(), CacheError> {
656 let mut config = self.clone();
657 config.targets.retain(|_, target| !target.is_empty());
658 config.validate()?;
659 let mut text = String::from(
660 "# Dependency caches for native runners. Edited by `runner-manager host cache`,\n\
661 # `repo cache` and `org cache`; see `host cache show`.\n",
662 );
663 text.push_str(
664 &toml::to_string(&config).map_err(|error| CacheError::Parse {
665 path: path.to_path_buf(),
666 message: error.to_string(),
667 })?,
668 );
669 write_atomically(path, text.as_bytes())
670 }
671
672 #[must_use]
674 pub fn target(&self, target: &ScaleTarget) -> Option<&TargetCacheConfig> {
675 self.targets.get(&target_key(target))
676 }
677
678 pub fn target_mut(&mut self, target: &ScaleTarget) -> &mut TargetCacheConfig {
680 self.targets.entry(target_key(target)).or_default()
681 }
682
683 #[must_use]
685 pub fn max_bytes(&self) -> Option<u64> {
686 match self.max_size_gib.unwrap_or(DEFAULT_MAX_SIZE_GIB) {
687 0 => None,
688 gib => Some(gib.saturating_mul(GIB)),
689 }
690 }
691
692 #[must_use]
694 pub fn host_enabled(&self) -> bool {
695 self.enabled.unwrap_or(true)
696 }
697}
698
699fn write_atomically(path: &Path, bytes: &[u8]) -> Result<(), CacheError> {
700 crate::host_fitness::write_atomically(path, bytes).map_err(io_error("write", path))
701}
702
703pub fn validate_root_text(root: &str) -> Result<(), CacheError> {
713 let network = root.starts_with(r"\\") || root.starts_with("//");
717 if network || root.chars().any(char::is_whitespace) || !Path::new(root).is_absolute() {
718 return Err(CacheError::InvalidRoot(root.to_owned()));
719 }
720 Ok(())
721}
722
723pub fn validate_namespace_name(name: &str) -> Result<(), CacheError> {
728 let valid = !name.is_empty()
729 && name.len() <= 64
730 && !name.starts_with('.')
731 && name
732 .chars()
733 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-'))
734 && !is_windows_device_name(name);
735 if valid {
736 Ok(())
737 } else {
738 Err(CacheError::InvalidNamespace(name.to_owned()))
739 }
740}
741
742fn is_windows_device_name(segment: &str) -> bool {
745 let stem = segment
746 .split('.')
747 .next()
748 .unwrap_or(segment)
749 .to_ascii_uppercase();
750 matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
751 || (stem.len() == 4
752 && (stem.starts_with("COM") || stem.starts_with("LPT"))
753 && stem.as_bytes()[3].is_ascii_digit())
754}
755
756#[derive(Debug, Clone, Copy, PartialEq, Eq)]
762pub enum RootSource {
763 Configured,
765 RunnerRoot,
767 PlatformFallback,
769}
770
771impl RootSource {
772 #[must_use]
773 pub const fn as_token(self) -> &'static str {
774 match self {
775 Self::Configured => "configured",
776 Self::RunnerRoot => "runner-root",
777 Self::PlatformFallback => "platform-fallback",
778 }
779 }
780}
781
782#[derive(Debug, Clone, PartialEq, Eq)]
784pub struct ResolvedRoot {
785 pub path: PathBuf,
786 pub source: RootSource,
787}
788
789pub fn resolve_root(
799 config: &CacheConfig,
800 runner_root: Option<&Path>,
801 fallback: Option<PathBuf>,
802) -> Result<ResolvedRoot, String> {
803 if let Some(root) = &config.root {
804 return Ok(ResolvedRoot {
805 path: PathBuf::from(root),
806 source: RootSource::Configured,
807 });
808 }
809 let usable = |path: &Path| !path.to_string_lossy().chars().any(char::is_whitespace);
810 if let Some(runner_root) = runner_root {
811 let candidate = runner_root.join(DEFAULT_ROOT_NAME);
812 if usable(&candidate) {
813 return Ok(ResolvedRoot {
814 path: candidate,
815 source: RootSource::RunnerRoot,
816 });
817 }
818 }
819 match fallback.filter(|path| usable(path) && path.is_absolute()) {
820 Some(path) => Ok(ResolvedRoot {
821 path,
822 source: RootSource::PlatformFallback,
823 }),
824 None => Err(
825 "no cache root without spaces could be derived; set one with \
826 `runner-manager host cache set-root --path <PATH>`"
827 .to_owned(),
828 ),
829 }
830}
831
832#[must_use]
841pub fn platform_fallback_root(app_paths: &AppPaths) -> Option<PathBuf> {
842 if cfg!(windows) {
843 crate::runner_root::default_runner_root(app_paths)
844 .ok()
845 .map(|root| root.as_path().join(DEFAULT_ROOT_NAME))
846 } else {
847 library_caches_beside(app_paths.runtime_dir())
848 .or_else(|| directories::BaseDirs::new().map(|dirs| dirs.cache_dir().to_path_buf()))
849 .map(|caches| caches.join("runner-manager"))
850 }
851}
852
853fn library_caches_beside(path: &Path) -> Option<PathBuf> {
856 path.ancestors().find_map(|ancestor| {
857 let library = ancestor.parent()?;
858 (ancestor.file_name()? == "Application Support" && library.file_name()? == "Library")
859 .then(|| library.join("Caches"))
860 })
861}
862
863#[derive(Debug, Clone, PartialEq, Eq)]
869pub enum Namespace {
870 Repository { owner: String, repo: String },
871 Organization(String),
872 Shared(String),
873}
874
875impl Namespace {
876 #[must_use]
878 pub fn of(target: &ScaleTarget) -> Self {
879 match target {
880 ScaleTarget::Repository(repository) => Self::Repository {
881 owner: repository.owner().to_ascii_lowercase(),
882 repo: repository.repo().to_ascii_lowercase(),
883 },
884 ScaleTarget::Organization(org) => Self::Organization(org.as_str().to_ascii_lowercase()),
885 }
886 }
887
888 #[must_use]
890 pub fn segments(&self) -> [String; 2] {
891 match self {
892 Self::Repository { owner, repo } => [segment(owner), segment(repo)],
893 Self::Organization(org) => [ORG_PREFIX.to_owned(), segment(org)],
894 Self::Shared(name) => [SHARED_PREFIX.to_owned(), segment(name)],
895 }
896 }
897
898 #[must_use]
900 pub fn dir(&self, root: &Path) -> PathBuf {
901 let [first, second] = self.segments();
902 root.join(first).join(second)
903 }
904
905 #[must_use]
907 pub fn label(&self) -> String {
908 self.segments().join("/")
909 }
910}
911
912fn segment(raw: &str) -> String {
918 let cleaned = portable_segment(raw);
919 if cleaned.len() <= MAX_SEGMENT_LEN {
920 return cleaned;
921 }
922 let digest = hex::encode(sha2::Sha256::digest(raw.as_bytes()));
927 format!(
928 "{}-{}",
929 &cleaned[..MAX_SEGMENT_LEN - SEGMENT_DIGEST_LEN - 1],
930 &digest[..SEGMENT_DIGEST_LEN]
931 )
932}
933
934const MAX_SEGMENT_LEN: usize = 32;
936const SEGMENT_DIGEST_LEN: usize = 8;
937
938fn portable_segment(raw: &str) -> String {
939 let cleaned: String = raw
940 .chars()
941 .map(|c| {
942 if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
943 c
944 } else {
945 '_'
946 }
947 })
948 .collect();
949 let cleaned = match cleaned.as_str() {
950 "" | "." | ".." => format!("_{cleaned}"),
951 _ => cleaned,
952 };
953 if is_windows_device_name(&cleaned) || cleaned.ends_with('.') {
954 format!("{cleaned}_")
955 } else {
956 cleaned
957 }
958}
959
960#[derive(Debug, Clone, Copy, PartialEq, Eq)]
962pub enum ToolSource {
963 Default,
964 Host,
965 Target,
966 Operator,
968}
969
970impl ToolSource {
971 #[must_use]
972 pub const fn as_token(self) -> &'static str {
973 match self {
974 Self::Default => "default",
975 Self::Host => "host",
976 Self::Target => "this target",
977 Self::Operator => "set by runner.env or the service",
978 }
979 }
980}
981
982#[derive(Debug, Clone, Copy, PartialEq, Eq)]
984pub struct ToolState {
985 pub tool: &'static CacheTool,
986 pub enabled: bool,
987 pub source: ToolSource,
988}
989
990#[derive(Debug, Clone, PartialEq, Eq)]
992pub struct Selection {
993 pub disabled: Option<&'static str>,
995 pub namespace: Namespace,
996 pub tools: Vec<ToolState>,
998}
999
1000impl Selection {
1001 pub fn enabled_tools(&self) -> impl Iterator<Item = &'static CacheTool> + '_ {
1003 self.tools
1004 .iter()
1005 .filter(|state| state.enabled)
1006 .map(|state| state.tool)
1007 }
1008}
1009
1010pub const ORGANIZATION_OPT_IN: &str = "organization policies accept jobs from every repository \
1012 in the organization, so they get a cache only with `org cache set-enabled ORG --enabled \
1013 true`";
1014
1015#[must_use]
1018pub fn select(config: &CacheConfig, target: &ScaleTarget, platform: RunnerPlatform) -> Selection {
1019 let own = config.target(target);
1020 let namespace = own
1021 .and_then(|own| own.namespace.clone())
1022 .map_or_else(|| Namespace::of(target), Namespace::Shared);
1023 let disabled = if !config.host_enabled() {
1024 Some("caches are turned off on this host")
1025 } else {
1026 match (own.and_then(|own| own.enabled), target) {
1027 (Some(true), _) => None,
1028 (Some(false), _) => Some("caches are turned off for this target"),
1029 (None, ScaleTarget::Repository(_)) => None,
1030 (None, ScaleTarget::Organization(_)) => Some(ORGANIZATION_OPT_IN),
1031 }
1032 };
1033 let tools = TOOLS
1034 .iter()
1035 .filter(|tool| tool.applies_to(platform))
1036 .map(|tool| {
1037 let mut state = ToolState {
1038 tool,
1039 enabled: tool.default_enabled,
1040 source: ToolSource::Default,
1041 };
1042 if let Some(enabled) = config.tools.get(tool.id) {
1043 state.enabled = *enabled;
1044 state.source = ToolSource::Host;
1045 }
1046 if let Some(enabled) = own.and_then(|own| own.tools.get(tool.id)) {
1047 state.enabled = *enabled;
1048 state.source = ToolSource::Target;
1049 }
1050 state
1051 })
1052 .collect();
1053 Selection {
1054 disabled,
1055 namespace,
1056 tools,
1057 }
1058}
1059
1060#[must_use]
1066fn tool_dir(tool: &CacheTool, namespace_dir: &Path, slot_dir: &Path) -> PathBuf {
1067 match tool.sharing {
1068 Sharing::Namespace => namespace_dir.join(tool.dir),
1069 Sharing::Slot => slot_dir.join(tool.dir),
1070 }
1071}
1072
1073fn render(variable: &CacheVariable, dir: &Path) -> (OsString, Option<PathBuf>) {
1075 match variable.value {
1076 VarValue::Dir => (dir.as_os_str().to_owned(), Some(dir.to_path_buf())),
1077 VarValue::Sub(sub) => {
1078 let path = dir.join(sub);
1079 (path.as_os_str().to_owned(), Some(path))
1080 }
1081 VarValue::Literal(value) => (OsString::from(value), None),
1082 VarValue::Template(template) => (
1083 OsString::from(template.replace("{dir}", &dir.to_string_lossy())),
1084 Some(dir.to_path_buf()),
1085 ),
1086 }
1087}
1088
1089#[must_use]
1091pub fn environment<'a>(
1092 tools: impl IntoIterator<Item = &'a CacheTool>,
1093 namespace_dir: &Path,
1094 slot_dir: &Path,
1095 platform: RunnerPlatform,
1096) -> (Vec<(&'static str, OsString)>, Vec<PathBuf>) {
1097 let mut variables = Vec::new();
1098 let mut dirs = Vec::new();
1099 for tool in tools {
1100 let dir = tool_dir(tool, namespace_dir, slot_dir);
1101 for variable in tool.variables_on(platform) {
1102 let (value, named) = render(variable, &dir);
1103 variables.push((variable.name, value));
1104 dirs.extend(named);
1105 }
1106 }
1107 (variables, dirs)
1108}
1109
1110impl Selection {
1111 pub fn defer_to_operator(&mut self, platform: RunnerPlatform, is_set: impl Fn(&str) -> bool) {
1121 for state in &mut self.tools {
1122 if state.enabled && state.tool.variables_on(platform).any(|v| is_set(v.name)) {
1123 state.enabled = false;
1124 state.source = ToolSource::Operator;
1125 }
1126 }
1127 }
1128}
1129
1130#[must_use]
1135pub fn inherited(name: &str) -> bool {
1136 std::env::vars_os().any(|(key, value)| {
1137 !value.is_empty()
1138 && key
1139 .to_str()
1140 .is_some_and(|key| key.eq_ignore_ascii_case(name))
1141 })
1142}
1143
1144#[must_use]
1152pub fn runtime_holds_runner(runtime: &Path) -> bool {
1153 match fs::metadata(runtime.join("bin")) {
1157 Ok(metadata) => metadata.is_dir(),
1158 Err(error) => error.kind() != io::ErrorKind::NotFound,
1159 }
1160}
1161
1162#[derive(Debug, Clone, PartialEq, Eq)]
1164pub struct LaunchCaches {
1165 pub namespace_dir: PathBuf,
1166 pub slot: u32,
1167 pub variables: Vec<(&'static str, OsString)>,
1168}
1169
1170fn lock_cache_root(root: &Path) -> Result<HostLock, CacheError> {
1171 HostLock::acquire_at(
1172 &root.join(LockKind::DependencyCache.file_name()),
1173 LockKind::DependencyCache,
1174 LOCK_WAIT,
1175 )
1176 .map_err(|error| CacheError::Lock(error.to_string()))
1177}
1178
1179pub fn prepare_launch(
1188 root: &Path,
1189 selection: &Selection,
1190 runtime: &Path,
1191 platform: RunnerPlatform,
1192 holds_runner: &dyn Fn(&Path) -> bool,
1193) -> Result<LaunchCaches, CacheError> {
1194 fs::create_dir_all(root).map_err(io_error("create", root))?;
1195 let namespace_dir = selection.namespace.dir(root);
1196 let slots = namespace_dir.join(SLOTS_DIR);
1197 let slot = {
1198 let _lock = lock_cache_root(root)?;
1199 fs::create_dir_all(&slots).map_err(io_error("create", &slots))?;
1200 let slot = free_slot(&slots, runtime, holds_runner);
1201 let lease = slots.join(format!("{slot}.{LEASE_EXTENSION}"));
1202 fs::write(&lease, runtime.to_string_lossy().as_bytes())
1203 .map_err(io_error("write", &lease))?;
1204 let marker = namespace_dir.join(LAST_USED_FILE);
1205 fs::write(&marker, Utc::now().to_rfc3339().as_bytes())
1206 .map_err(io_error("write", &marker))?;
1207 slot
1208 };
1209 let slot_dir = slots.join(slot.to_string());
1210 let (variables, dirs) = environment(
1211 selection.enabled_tools(),
1212 &namespace_dir,
1213 &slot_dir,
1214 platform,
1215 );
1216 for dir in dirs {
1217 fs::create_dir_all(&dir).map_err(io_error("create", &dir))?;
1218 }
1219 Ok(LaunchCaches {
1220 namespace_dir,
1221 slot,
1222 variables,
1223 })
1224}
1225
1226fn free_slot(slots: &Path, runtime: &Path, holds_runner: &dyn Fn(&Path) -> bool) -> u32 {
1229 let own = runtime.to_string_lossy();
1230 (1..)
1231 .find(
1232 |slot| match fs::read_to_string(slots.join(format!("{slot}.{LEASE_EXTENSION}"))) {
1233 Err(error) => error.kind() == io::ErrorKind::NotFound,
1234 Ok(holder) => holder == own || !holds_runner(Path::new(&holder)),
1235 },
1236 )
1237 .expect("an unbounded range has a free slot")
1238}
1239
1240fn namespace_in_use(namespace_dir: &Path, holds_runner: &dyn Fn(&Path) -> bool) -> bool {
1242 let Ok(entries) = fs::read_dir(namespace_dir.join(SLOTS_DIR)) else {
1243 return false;
1244 };
1245 entries.flatten().any(|entry| {
1246 let path = entry.path();
1247 path.extension().is_some_and(|ext| ext == LEASE_EXTENSION)
1248 && fs::read_to_string(&path).map_or(true, |holder| holds_runner(Path::new(&holder)))
1249 })
1250}
1251
1252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1258pub struct NamespaceUsage {
1259 pub name: String,
1261 pub bytes: u64,
1262 pub last_used: Option<DateTime<Utc>>,
1263 pub in_use: bool,
1264}
1265
1266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1268pub struct CacheUsage {
1269 pub measured_at: DateTime<Utc>,
1270 pub total_bytes: u64,
1271 pub max_bytes: Option<u64>,
1272 pub namespaces: Vec<NamespaceUsage>,
1273 pub pruned: Vec<String>,
1275}
1276
1277impl CacheUsage {
1278 #[must_use]
1280 pub fn read(root: &Path) -> Option<Self> {
1281 let bytes = fs::read(root.join(USAGE_FILE)).ok()?;
1282 serde_json::from_slice(&bytes).ok()
1283 }
1284}
1285
1286fn tree_size(path: &Path) -> u64 {
1288 let mut total = 0_u64;
1289 let mut pending = vec![path.to_path_buf()];
1290 while let Some(dir) = pending.pop() {
1291 let Ok(entries) = fs::read_dir(&dir) else {
1292 continue;
1293 };
1294 for entry in entries.flatten() {
1295 let Ok(metadata) = entry.metadata() else {
1296 continue;
1297 };
1298 if metadata.is_dir() {
1299 pending.push(entry.path());
1300 } else if metadata.is_file() {
1301 total = total.saturating_add(metadata.len());
1302 }
1303 }
1304 }
1305 total
1306}
1307
1308fn modified(path: &Path) -> Option<SystemTime> {
1309 fs::metadata(path).and_then(|m| m.modified()).ok()
1310}
1311
1312fn namespace_dirs(root: &Path) -> Vec<(String, PathBuf)> {
1317 let mut found = Vec::new();
1318 let Ok(first) = fs::read_dir(root) else {
1319 return found;
1320 };
1321 for outer in first.flatten() {
1322 let outer_name = outer.file_name().to_string_lossy().into_owned();
1323 if outer_name.starts_with('.') || !outer.file_type().is_ok_and(|t| t.is_dir()) {
1324 continue;
1325 }
1326 let Ok(second) = fs::read_dir(outer.path()) else {
1327 continue;
1328 };
1329 for inner in second.flatten() {
1330 let path = inner.path();
1331 let marked = path.join(LAST_USED_FILE).is_file() || path.join(SLOTS_DIR).is_dir();
1332 if marked && inner.file_type().is_ok_and(|t| t.is_dir()) {
1333 let name = format!("{outer_name}/{}", inner.file_name().to_string_lossy());
1334 found.push((name, path));
1335 }
1336 }
1337 }
1338 found.sort();
1339 found
1340}
1341
1342#[must_use]
1344pub(crate) fn measure(root: &Path, holds_runner: &dyn Fn(&Path) -> bool) -> Vec<NamespaceUsage> {
1345 namespace_dirs(root)
1346 .into_iter()
1347 .map(|(name, dir)| NamespaceUsage {
1348 bytes: tree_size(&dir),
1349 last_used: modified(&dir.join(LAST_USED_FILE))
1350 .or_else(|| modified(&dir))
1351 .map(DateTime::<Utc>::from),
1352 in_use: namespace_in_use(&dir, holds_runner),
1353 name,
1354 })
1355 .collect()
1356}
1357
1358#[must_use]
1362pub(crate) fn plan_prune(namespaces: &[NamespaceUsage], max_bytes: Option<u64>) -> Vec<String> {
1363 let Some(max) = max_bytes else {
1364 return Vec::new();
1365 };
1366 let mut total: u64 = namespaces.iter().map(|ns| ns.bytes).sum();
1367 let mut idle: Vec<&NamespaceUsage> = namespaces.iter().filter(|ns| !ns.in_use).collect();
1368 idle.sort_by(|a, b| a.last_used.cmp(&b.last_used).then(a.name.cmp(&b.name)));
1370 let mut chosen = Vec::new();
1371 for namespace in idle {
1372 if total <= max {
1373 break;
1374 }
1375 total = total.saturating_sub(namespace.bytes);
1376 chosen.push(namespace.name.clone());
1377 }
1378 chosen
1379}
1380
1381pub fn prune(
1392 root: &Path,
1393 max_bytes: Option<u64>,
1394 holds_runner: &dyn Fn(&Path) -> bool,
1395) -> Result<CacheUsage, CacheError> {
1396 empty_trash(root);
1397 let mut namespaces = measure(root, holds_runner);
1398 let planned = plan_prune(&namespaces, max_bytes);
1399 let mut pruned = Vec::new();
1400 if !planned.is_empty() {
1401 let trash = root.join(TRASH_DIR);
1402 fs::create_dir_all(&trash).map_err(io_error("create", &trash))?;
1403 let _lock = lock_cache_root(root)?;
1404 for name in planned {
1405 let dir = root.join(&name);
1406 if namespace_in_use(&dir, holds_runner) {
1407 continue;
1408 }
1409 let aside = trash.join(uuid::Uuid::new_v4().simple().to_string());
1410 if fs::rename(&dir, &aside).is_ok() {
1411 pruned.push(name);
1412 }
1413 }
1414 }
1415 empty_trash(root);
1416 namespaces.retain(|ns| !pruned.contains(&ns.name));
1417 let usage = CacheUsage {
1418 measured_at: Utc::now(),
1419 total_bytes: namespaces.iter().map(|ns| ns.bytes).sum(),
1420 max_bytes,
1421 namespaces,
1422 pruned,
1423 };
1424 let json = serde_json::to_vec_pretty(&usage).map_err(|error| CacheError::Io {
1425 action: "encode",
1426 path: root.join(USAGE_FILE),
1427 source: io::Error::other(error),
1428 })?;
1429 write_atomically(&root.join(USAGE_FILE), &json)?;
1430 Ok(usage)
1431}
1432
1433fn empty_trash(root: &Path) {
1436 let Ok(entries) = fs::read_dir(root.join(TRASH_DIR)) else {
1437 return;
1438 };
1439 for entry in entries.flatten() {
1440 let _ = remove_tree(&entry.path());
1441 }
1442}
1443
1444fn remove_tree(path: &Path) -> io::Result<()> {
1451 #[cfg(windows)]
1452 {
1453 remove_dir_all::remove_dir_all(path)
1454 }
1455 #[cfg(not(windows))]
1456 {
1457 match fs::remove_dir_all(path) {
1458 Err(error) if error.kind() == io::ErrorKind::PermissionDenied => {
1459 open_directories(path);
1460 fs::remove_dir_all(path)
1461 }
1462 other => other,
1463 }
1464 }
1465}
1466
1467#[cfg(not(windows))]
1469fn open_directories(path: &Path) {
1470 use std::os::unix::fs::PermissionsExt as _;
1471 let mut pending = vec![path.to_path_buf()];
1472 while let Some(current) = pending.pop() {
1473 let Ok(metadata) = fs::symlink_metadata(¤t) else {
1474 continue;
1475 };
1476 if !metadata.is_dir() {
1477 continue;
1478 }
1479 let mode = metadata.permissions().mode();
1480 if mode & 0o700 != 0o700 {
1481 let _ = fs::set_permissions(¤t, fs::Permissions::from_mode(mode | 0o700));
1482 }
1483 if let Ok(entries) = fs::read_dir(¤t) {
1484 pending.extend(entries.flatten().map(|entry| entry.path()));
1485 }
1486 }
1487}
1488
1489#[must_use]
1491pub fn tool_ids() -> Vec<&'static str> {
1492 TOOLS.iter().map(|tool| tool.id).collect()
1493}
1494
1495#[must_use]
1497pub fn human_bytes(bytes: u64) -> String {
1498 #[allow(clippy::cast_precision_loss)]
1499 let value = bytes as f64;
1500 if bytes >= GIB {
1501 format!("{:.1} GiB", value / GIB as f64)
1502 } else if bytes >= 1024 * 1024 {
1503 format!("{:.0} MiB", value / (1024.0 * 1024.0))
1504 } else {
1505 format!("{:.0} KiB", value / 1024.0)
1506 }
1507}
1508
1509#[cfg(test)]
1510mod tests;