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
116pub const PRUNE_REQUEST_FILE: &str = "cache-prune.request";
132
133pub const PRUNE_RESULT_FILE: &str = "cache-prune.result";
135
136pub const PRUNE_REQUEST_POLL: Duration = Duration::from_secs(5);
140
141#[must_use]
143pub fn prune_request_path(state_dir: &Path) -> PathBuf {
144 crate::service_request::CACHE_PRUNE.request_path(state_dir)
145}
146
147#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct PruneResult {
162 pub request: String,
164 pub outcome: Result<CacheUsage, String>,
166}
167
168impl PruneResult {
169 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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub struct Platforms {
210 pub windows: bool,
211 pub macos: bool,
212 pub linux: bool,
213}
214
215impl Platforms {
216 pub const ALL: Self = Self {
217 windows: true,
218 macos: true,
219 linux: true,
220 };
221 pub const UNIX: Self = Self {
222 windows: false,
223 macos: true,
224 linux: true,
225 };
226
227 #[must_use]
228 pub const fn includes(self, platform: RunnerPlatform) -> bool {
229 match platform {
230 RunnerPlatform::Windows => self.windows,
231 RunnerPlatform::MacOs => self.macos,
232 RunnerPlatform::Linux => self.linux,
233 }
234 }
235}
236
237#[derive(Debug, Clone, Copy, PartialEq, Eq)]
239pub enum Sharing {
240 Namespace,
243 Slot,
245}
246
247#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249pub enum VarValue {
250 Dir,
252 Sub(&'static str),
254 Literal(&'static str),
256 Template(&'static str),
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263pub struct CacheVariable {
264 pub name: &'static str,
265 pub value: VarValue,
266 pub platforms: Platforms,
267}
268
269const fn all(name: &'static str, value: VarValue) -> CacheVariable {
270 CacheVariable {
271 name,
272 value,
273 platforms: Platforms::ALL,
274 }
275}
276
277const fn unix(name: &'static str, value: VarValue) -> CacheVariable {
278 CacheVariable {
279 name,
280 value,
281 platforms: Platforms::UNIX,
282 }
283}
284
285#[derive(Debug, Clone, Copy, PartialEq, Eq)]
287pub struct CacheTool {
288 pub id: &'static str,
290 pub dir: &'static str,
292 pub sharing: Sharing,
293 pub default_enabled: bool,
295 pub variables: &'static [CacheVariable],
296 pub note: &'static str,
298}
299
300impl CacheTool {
301 pub fn variables_on(&self, platform: RunnerPlatform) -> impl Iterator<Item = &CacheVariable> {
303 self.variables
304 .iter()
305 .filter(move |variable| variable.platforms.includes(platform))
306 }
307
308 #[must_use]
310 pub fn applies_to(&self, platform: RunnerPlatform) -> bool {
311 self.variables_on(platform).next().is_some()
312 }
313}
314
315pub const TOOLS: &[CacheTool] = &[
321 CacheTool {
322 id: "git",
323 dir: "git",
324 sharing: Sharing::Namespace,
325 default_enabled: false,
326 variables: &[all("RUNNER_MANAGER_GIT_CACHE_DIR", VarValue::Dir)],
327 note: "Independent Git object snapshots for standard HTTPS checkout in native repository runners; no credentials, worktree files, LFS or submodule cache.",
328 },
329 CacheTool {
330 id: "xdg",
331 dir: "xdg",
332 sharing: Sharing::Namespace,
333 default_enabled: true,
334 variables: &[unix("XDG_CACHE_HOME", VarValue::Dir)],
335 note: "catch-all for Unix tools that follow the XDG base directory specification; not set \
336 on Windows, where pnpm, Bun and Deno would also move global state",
337 },
338 CacheTool {
339 id: "tool-cache",
340 dir: "tool-cache",
341 sharing: Sharing::Slot,
342 default_enabled: true,
343 variables: &[
344 all("RUNNER_TOOL_CACHE", VarValue::Dir),
345 all("AGENT_TOOLSDIRECTORY", VarValue::Dir),
346 ],
347 note: "toolchains setup-node, setup-go and setup-java download; per slot because \
348 @actions/tool-cache deletes a version's folder before filling it",
349 },
350 CacheTool {
351 id: "dotnet",
352 dir: "dotnet",
353 sharing: Sharing::Slot,
354 default_enabled: true,
355 variables: &[all("DOTNET_INSTALL_DIR", VarValue::Dir)],
356 note: "SDKs setup-dotnet installs; per slot because an install replaces the shared dotnet \
357 host",
358 },
359 CacheTool {
360 id: "npm",
361 dir: "npm",
362 sharing: Sharing::Namespace,
363 default_enabled: true,
364 variables: &[all("npm_config_cache", VarValue::Dir)],
365 note: "npm's cacache and npx",
366 },
367 CacheTool {
368 id: "pnpm",
369 dir: "pnpm-store",
370 sharing: Sharing::Namespace,
371 default_enabled: true,
372 variables: &[
373 all("npm_config_store_dir", VarValue::Dir),
374 all("PNPM_CONFIG_STORE_DIR", VarValue::Dir),
375 ],
376 note: "the content-addressed store: pnpm 9 and 10 read npm_config_store_dir, pnpm 11 and \
377 later PNPM_CONFIG_STORE_DIR; npm 12 warns that store-dir is unknown",
378 },
379 CacheTool {
380 id: "yarn",
381 dir: "yarn",
382 sharing: Sharing::Namespace,
383 default_enabled: true,
384 variables: &[all("YARN_GLOBAL_FOLDER", VarValue::Dir)],
385 note: "Yarn 2+ global cache; YARN_CACHE_FOLDER is left alone because it would override a \
386 zero-install project's committed cache",
387 },
388 CacheTool {
389 id: "bun",
390 dir: "bun",
391 sharing: Sharing::Namespace,
392 default_enabled: true,
393 variables: &[all("BUN_INSTALL_CACHE_DIR", VarValue::Dir)],
394 note: "Bun's package cache",
395 },
396 CacheTool {
397 id: "deno",
398 dir: "deno",
399 sharing: Sharing::Namespace,
400 default_enabled: true,
401 variables: &[all("DENO_DIR", VarValue::Dir)],
402 note: "Deno's module and npm cache (it also keeps localStorage there)",
403 },
404 CacheTool {
405 id: "node-gyp",
406 dir: "node-gyp",
407 sharing: Sharing::Namespace,
408 default_enabled: true,
409 variables: &[
410 all("npm_config_devdir", VarValue::Dir),
411 all("npm_package_config_node_gyp_devdir", VarValue::Dir),
412 ],
413 note: "Node headers node-gyp downloads for native modules",
414 },
415 CacheTool {
416 id: "electron",
417 dir: "electron",
418 sharing: Sharing::Namespace,
419 default_enabled: true,
420 variables: &[
421 all("electron_config_cache", VarValue::Dir),
424 all("ELECTRON_CACHE", VarValue::Dir),
425 ],
426 note: "Electron release downloads",
427 },
428 CacheTool {
429 id: "electron-builder",
430 dir: "electron-builder",
431 sharing: Sharing::Namespace,
432 default_enabled: true,
433 variables: &[all("ELECTRON_BUILDER_CACHE", VarValue::Dir)],
434 note: "NSIS, winCodeSign, AppImage and other packaging tools",
435 },
436 CacheTool {
437 id: "playwright",
438 dir: "ms-playwright",
439 sharing: Sharing::Namespace,
440 default_enabled: true,
441 variables: &[
442 all("PLAYWRIGHT_BROWSERS_PATH", VarValue::Dir),
443 all("PLAYWRIGHT_SKIP_BROWSER_GC", VarValue::Literal("1")),
446 ],
447 note: "Playwright browsers",
448 },
449 CacheTool {
450 id: "cypress",
451 dir: "cypress",
452 sharing: Sharing::Slot,
453 default_enabled: true,
454 variables: &[all("CYPRESS_CACHE_FOLDER", VarValue::Dir)],
455 note: "Cypress binaries; per slot because cypress install takes no lock",
456 },
457 CacheTool {
458 id: "nuget",
459 dir: "nuget",
460 sharing: Sharing::Namespace,
461 default_enabled: true,
462 variables: &[
463 all("NUGET_PACKAGES", VarValue::Sub("packages")),
464 all("NUGET_HTTP_CACHE_PATH", VarValue::Sub("http-cache")),
465 all("NUGET_PLUGINS_CACHE_PATH", VarValue::Sub("plugins-cache")),
466 all("NUGET_SCRATCH", VarValue::Sub("scratch")),
470 ],
471 note: "global packages and HTTP cache",
472 },
473 CacheTool {
474 id: "pip",
475 dir: "pip",
476 sharing: Sharing::Namespace,
477 default_enabled: true,
478 variables: &[all("PIP_CACHE_DIR", VarValue::Dir)],
479 note: "pip's HTTP and wheel cache",
480 },
481 CacheTool {
482 id: "uv",
483 dir: "uv",
484 sharing: Sharing::Namespace,
485 default_enabled: true,
486 variables: &[all("UV_CACHE_DIR", VarValue::Dir)],
487 note: "uv's cache",
488 },
489 CacheTool {
490 id: "go",
491 dir: "go",
492 sharing: Sharing::Namespace,
493 default_enabled: true,
494 variables: &[
495 all("GOMODCACHE", VarValue::Sub("mod")),
496 all("GOCACHE", VarValue::Sub("build")),
497 ],
498 note: "Go module and build caches",
499 },
500 CacheTool {
501 id: "composer",
502 dir: "composer",
503 sharing: Sharing::Namespace,
504 default_enabled: true,
505 variables: &[all("COMPOSER_CACHE_DIR", VarValue::Dir)],
506 note: "Composer's download cache",
507 },
508 CacheTool {
509 id: "cocoapods",
510 dir: "cocoapods",
511 sharing: Sharing::Namespace,
512 default_enabled: true,
513 variables: &[unix("CP_CACHE_DIR", VarValue::Dir)],
514 note: "CocoaPods download cache",
515 },
516 CacheTool {
517 id: "ccache",
518 dir: "ccache",
519 sharing: Sharing::Namespace,
520 default_enabled: true,
521 variables: &[all("CCACHE_DIR", VarValue::Dir)],
522 note: "ccache objects",
523 },
524 CacheTool {
525 id: "poetry",
526 dir: "poetry",
527 sharing: Sharing::Slot,
528 default_enabled: false,
529 variables: &[all("POETRY_CACHE_DIR", VarValue::Dir)],
530 note: "opt-in: Poetry also keeps virtualenvs in its cache directory, one per workspace path",
531 },
532 CacheTool {
533 id: "cargo",
534 dir: "cargo",
535 sharing: Sharing::Namespace,
536 default_enabled: false,
537 variables: &[all("CARGO_HOME", VarValue::Dir)],
538 note: "opt-in: CARGO_HOME also holds config, registry credentials and installed binaries",
539 },
540 CacheTool {
541 id: "gradle",
542 dir: "gradle",
543 sharing: Sharing::Namespace,
544 default_enabled: false,
545 variables: &[all("GRADLE_USER_HOME", VarValue::Dir)],
546 note: "opt-in: GRADLE_USER_HOME also holds gradle.properties, init scripts and daemons",
547 },
548 CacheTool {
549 id: "maven",
550 dir: "maven",
551 sharing: Sharing::Slot,
552 default_enabled: false,
553 variables: &[all(
554 "MAVEN_ARGS",
555 VarValue::Template("-Dmaven.repo.local={dir}"),
556 )],
557 note: "opt-in: Maven 3.9+ local repository via MAVEN_ARGS, which a workflow's own \
558 MAVEN_ARGS replaces",
559 },
560 CacheTool {
561 id: "pub",
562 dir: "pub",
563 sharing: Sharing::Slot,
564 default_enabled: false,
565 variables: &[all("PUB_CACHE", VarValue::Dir)],
566 note: "opt-in: PUB_CACHE also holds globally activated Dart tools",
567 },
568 CacheTool {
569 id: "bundler",
570 dir: "bundler",
571 sharing: Sharing::Slot,
572 default_enabled: false,
573 variables: &[all("BUNDLE_USER_CACHE", VarValue::Dir)],
574 note: "opt-in: Bundler's download cache",
575 },
576];
577
578#[must_use]
580pub fn tool(id: &str) -> Option<&'static CacheTool> {
581 TOOLS.iter().find(|tool| tool.id == id)
582}
583
584#[derive(Debug, thiserror::Error)]
590pub enum CacheError {
591 #[error("{} is not valid TOML for caches: {message}", path.display())]
592 Parse { path: PathBuf, message: String },
593 #[error("`{0}` is not a cache this version knows; `host cache show` lists them")]
594 UnknownTool(String),
595 #[error(
596 "`{0}` is not a valid shared namespace name; use 1 to 64 lower-case letters, digits, \
597 `.`, `_` or `-`, not starting with `.`"
598 )]
599 InvalidNamespace(String),
600 #[error("`{0}` is not a repository (OWNER/REPO) or an organization")]
601 InvalidTarget(String),
602 #[error("the cache root {0} must be an absolute path without spaces")]
603 InvalidRoot(String),
604 #[error("cannot {action} {}: {source}", path.display())]
605 Io {
606 action: &'static str,
607 path: PathBuf,
608 #[source]
609 source: io::Error,
610 },
611 #[error("{0}")]
612 Lock(String),
613}
614
615impl CacheError {
616 #[must_use]
619 pub fn is_permission_denied(&self) -> bool {
620 matches!(self, Self::Io { source, .. } if source.kind() == io::ErrorKind::PermissionDenied)
621 }
622}
623
624fn io_error(action: &'static str, path: &Path) -> impl FnOnce(io::Error) -> CacheError + use<> {
625 let path = path.to_path_buf();
626 move |source| CacheError::Io {
627 action,
628 path,
629 source,
630 }
631}
632
633#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
640#[serde(deny_unknown_fields)]
641pub struct CacheConfig {
642 #[serde(default, skip_serializing_if = "Option::is_none")]
644 pub enabled: Option<bool>,
645 #[serde(default, skip_serializing_if = "Option::is_none")]
647 pub root: Option<String>,
648 #[serde(default, skip_serializing_if = "Option::is_none")]
650 pub max_size_gib: Option<u64>,
651 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
653 pub tools: BTreeMap<String, bool>,
654 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
657 pub targets: BTreeMap<String, TargetCacheConfig>,
658}
659
660#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
662#[serde(deny_unknown_fields)]
663pub struct TargetCacheConfig {
664 #[serde(default, skip_serializing_if = "Option::is_none")]
665 pub enabled: Option<bool>,
666 #[serde(default, skip_serializing_if = "Option::is_none")]
668 pub namespace: Option<String>,
669 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
670 pub tools: BTreeMap<String, bool>,
671}
672
673impl TargetCacheConfig {
674 fn is_empty(&self) -> bool {
675 self.enabled.is_none() && self.namespace.is_none() && self.tools.is_empty()
676 }
677}
678
679#[must_use]
681pub fn config_path_in(config_dir: &Path) -> PathBuf {
682 config_dir.join(CONFIG_FILE)
683}
684
685#[must_use]
687pub fn target_key(target: &ScaleTarget) -> String {
688 target.slug().to_ascii_lowercase()
689}
690
691impl CacheConfig {
692 pub fn parse(text: &str, path: &Path) -> Result<Self, CacheError> {
698 let config: Self = toml::from_str(text).map_err(|error| CacheError::Parse {
699 path: path.to_path_buf(),
700 message: error.message().to_owned(),
701 })?;
702 config.validate()?;
703 Ok(config)
704 }
705
706 pub fn load(path: &Path) -> Result<Self, CacheError> {
711 match fs::read_to_string(path) {
712 Ok(text) => Self::parse(&text, path),
713 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
714 Err(source) => Err(CacheError::Io {
715 action: "read",
716 path: path.to_path_buf(),
717 source,
718 }),
719 }
720 }
721
722 fn validate(&self) -> Result<(), CacheError> {
723 if let Some(root) = &self.root {
724 validate_root_text(root)?;
725 }
726 let tool_ids = self
727 .tools
728 .keys()
729 .chain(self.targets.values().flat_map(|target| target.tools.keys()));
730 for id in tool_ids {
731 if tool(id).is_none() {
732 return Err(CacheError::UnknownTool(id.clone()));
733 }
734 }
735 for (key, target) in &self.targets {
736 ScaleTarget::repository(key)
737 .or_else(|_| ScaleTarget::organization(key))
738 .map_err(|_| CacheError::InvalidTarget(key.clone()))?;
739 if let Some(name) = &target.namespace {
740 validate_namespace_name(name)?;
741 }
742 }
743 Ok(())
744 }
745
746 pub fn save(&self, path: &Path) -> Result<(), CacheError> {
752 let mut config = self.clone();
753 config.targets.retain(|_, target| !target.is_empty());
754 config.validate()?;
755 let mut text = String::from(
756 "# Dependency caches for native runners. Edited by `runner-manager host cache`,\n\
757 # `repo cache` and `org cache`; see `host cache show`.\n",
758 );
759 text.push_str(
760 &toml::to_string(&config).map_err(|error| CacheError::Parse {
761 path: path.to_path_buf(),
762 message: error.to_string(),
763 })?,
764 );
765 write_atomically(path, text.as_bytes())
766 }
767
768 #[must_use]
770 pub fn target(&self, target: &ScaleTarget) -> Option<&TargetCacheConfig> {
771 self.targets.get(&target_key(target))
772 }
773
774 pub fn target_mut(&mut self, target: &ScaleTarget) -> &mut TargetCacheConfig {
776 self.targets.entry(target_key(target)).or_default()
777 }
778
779 #[must_use]
781 pub fn max_bytes(&self) -> Option<u64> {
782 match self.max_size_gib.unwrap_or(DEFAULT_MAX_SIZE_GIB) {
783 0 => None,
784 gib => Some(gib.saturating_mul(GIB)),
785 }
786 }
787
788 #[must_use]
790 pub fn host_enabled(&self) -> bool {
791 self.enabled.unwrap_or(true)
792 }
793}
794
795fn write_atomically(path: &Path, bytes: &[u8]) -> Result<(), CacheError> {
796 crate::host_fitness::write_atomically(path, bytes).map_err(io_error("write", path))
797}
798
799pub fn validate_root_text(root: &str) -> Result<(), CacheError> {
809 let network = root.starts_with(r"\\") || root.starts_with("//");
813 if network || root.chars().any(char::is_whitespace) || !Path::new(root).is_absolute() {
814 return Err(CacheError::InvalidRoot(root.to_owned()));
815 }
816 Ok(())
817}
818
819pub fn validate_namespace_name(name: &str) -> Result<(), CacheError> {
824 let valid = !name.is_empty()
825 && name.len() <= 64
826 && !name.starts_with('.')
827 && name
828 .chars()
829 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-'))
830 && !is_windows_device_name(name);
831 if valid {
832 Ok(())
833 } else {
834 Err(CacheError::InvalidNamespace(name.to_owned()))
835 }
836}
837
838fn is_windows_device_name(segment: &str) -> bool {
841 let stem = segment
842 .split('.')
843 .next()
844 .unwrap_or(segment)
845 .to_ascii_uppercase();
846 matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
847 || (stem.len() == 4
848 && (stem.starts_with("COM") || stem.starts_with("LPT"))
849 && stem.as_bytes()[3].is_ascii_digit())
850}
851
852#[derive(Debug, Clone, Copy, PartialEq, Eq)]
858pub enum RootSource {
859 Configured,
861 RunnerRoot,
863 PlatformFallback,
865}
866
867impl RootSource {
868 #[must_use]
869 pub const fn as_token(self) -> &'static str {
870 match self {
871 Self::Configured => "configured",
872 Self::RunnerRoot => "runner-root",
873 Self::PlatformFallback => "platform-fallback",
874 }
875 }
876}
877
878#[derive(Debug, Clone, PartialEq, Eq)]
880pub struct ResolvedRoot {
881 pub path: PathBuf,
882 pub source: RootSource,
883}
884
885pub fn resolve_root(
895 config: &CacheConfig,
896 runner_root: Option<&Path>,
897 fallback: Option<PathBuf>,
898) -> Result<ResolvedRoot, String> {
899 if let Some(root) = &config.root {
900 return Ok(ResolvedRoot {
901 path: PathBuf::from(root),
902 source: RootSource::Configured,
903 });
904 }
905 let usable = |path: &Path| !path.to_string_lossy().chars().any(char::is_whitespace);
906 if let Some(runner_root) = runner_root {
907 let candidate = runner_root.join(DEFAULT_ROOT_NAME);
908 if usable(&candidate) {
909 return Ok(ResolvedRoot {
910 path: candidate,
911 source: RootSource::RunnerRoot,
912 });
913 }
914 }
915 match fallback.filter(|path| usable(path) && path.is_absolute()) {
916 Some(path) => Ok(ResolvedRoot {
917 path,
918 source: RootSource::PlatformFallback,
919 }),
920 None => Err(
921 "no cache root without spaces could be derived; set one with \
922 `runner-manager host cache set-root --path <PATH>`"
923 .to_owned(),
924 ),
925 }
926}
927
928#[must_use]
937pub fn platform_fallback_root(app_paths: &AppPaths) -> Option<PathBuf> {
938 if cfg!(windows) {
939 crate::runner_root::default_runner_root(app_paths)
940 .ok()
941 .map(|root| root.as_path().join(DEFAULT_ROOT_NAME))
942 } else {
943 library_caches_beside(app_paths.runtime_dir())
944 .or_else(|| directories::BaseDirs::new().map(|dirs| dirs.cache_dir().to_path_buf()))
945 .map(|caches| caches.join("runner-manager"))
946 }
947}
948
949fn library_caches_beside(path: &Path) -> Option<PathBuf> {
952 path.ancestors().find_map(|ancestor| {
953 let library = ancestor.parent()?;
954 (ancestor.file_name()? == "Application Support" && library.file_name()? == "Library")
955 .then(|| library.join("Caches"))
956 })
957}
958
959#[derive(Debug, Clone, PartialEq, Eq)]
965pub enum Namespace {
966 Repository { owner: String, repo: String },
967 Organization(String),
968 Shared(String),
969}
970
971impl Namespace {
972 #[must_use]
974 pub fn of(target: &ScaleTarget) -> Self {
975 match target {
976 ScaleTarget::Repository(repository) => Self::Repository {
977 owner: repository.owner().to_ascii_lowercase(),
978 repo: repository.repo().to_ascii_lowercase(),
979 },
980 ScaleTarget::Organization(org) => Self::Organization(org.as_str().to_ascii_lowercase()),
981 }
982 }
983
984 #[must_use]
986 pub fn segments(&self) -> [String; 2] {
987 match self {
988 Self::Repository { owner, repo } => [segment(owner), segment(repo)],
989 Self::Organization(org) => [ORG_PREFIX.to_owned(), segment(org)],
990 Self::Shared(name) => [SHARED_PREFIX.to_owned(), segment(name)],
991 }
992 }
993
994 #[must_use]
996 pub fn dir(&self, root: &Path) -> PathBuf {
997 let [first, second] = self.segments();
998 root.join(first).join(second)
999 }
1000
1001 #[must_use]
1003 pub fn label(&self) -> String {
1004 self.segments().join("/")
1005 }
1006}
1007
1008fn segment(raw: &str) -> String {
1014 let cleaned = portable_segment(raw);
1015 if cleaned.len() <= MAX_SEGMENT_LEN {
1016 return cleaned;
1017 }
1018 let digest = hex::encode(sha2::Sha256::digest(raw.as_bytes()));
1023 format!(
1024 "{}-{}",
1025 &cleaned[..MAX_SEGMENT_LEN - SEGMENT_DIGEST_LEN - 1],
1026 &digest[..SEGMENT_DIGEST_LEN]
1027 )
1028}
1029
1030const MAX_SEGMENT_LEN: usize = 32;
1032const SEGMENT_DIGEST_LEN: usize = 8;
1033
1034fn portable_segment(raw: &str) -> String {
1035 let cleaned: String = raw
1036 .chars()
1037 .map(|c| {
1038 if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
1039 c
1040 } else {
1041 '_'
1042 }
1043 })
1044 .collect();
1045 let cleaned = match cleaned.as_str() {
1046 "" | "." | ".." => format!("_{cleaned}"),
1047 _ => cleaned,
1048 };
1049 if is_windows_device_name(&cleaned) || cleaned.ends_with('.') {
1050 format!("{cleaned}_")
1051 } else {
1052 cleaned
1053 }
1054}
1055
1056#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1058pub enum ToolSource {
1059 Default,
1060 Host,
1061 Target,
1062 Operator,
1064}
1065
1066impl ToolSource {
1067 #[must_use]
1068 pub const fn as_token(self) -> &'static str {
1069 match self {
1070 Self::Default => "default",
1071 Self::Host => "host",
1072 Self::Target => "this target",
1073 Self::Operator => "set by runner.env or the service",
1074 }
1075 }
1076}
1077
1078#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1080pub struct ToolState {
1081 pub tool: &'static CacheTool,
1082 pub enabled: bool,
1083 pub source: ToolSource,
1084}
1085
1086#[derive(Debug, Clone, PartialEq, Eq)]
1088pub struct Selection {
1089 pub disabled: Option<&'static str>,
1091 pub namespace: Namespace,
1092 pub tools: Vec<ToolState>,
1094}
1095
1096impl Selection {
1097 pub fn enabled_tools(&self) -> impl Iterator<Item = &'static CacheTool> + '_ {
1099 self.tools
1100 .iter()
1101 .filter(|state| state.enabled)
1102 .map(|state| state.tool)
1103 }
1104}
1105
1106pub const ORGANIZATION_OPT_IN: &str = "organization policies accept jobs from every repository \
1108 in the organization, so they get a cache only with `org cache set-enabled ORG --enabled \
1109 true`";
1110
1111#[must_use]
1114pub fn select(config: &CacheConfig, target: &ScaleTarget, platform: RunnerPlatform) -> Selection {
1115 let own = config.target(target);
1116 let namespace = own
1117 .and_then(|own| own.namespace.clone())
1118 .map_or_else(|| Namespace::of(target), Namespace::Shared);
1119 let disabled = if !config.host_enabled() {
1120 Some("caches are turned off on this host")
1121 } else {
1122 match (own.and_then(|own| own.enabled), target) {
1123 (Some(true), _) => None,
1124 (Some(false), _) => Some("caches are turned off for this target"),
1125 (None, ScaleTarget::Repository(_)) => None,
1126 (None, ScaleTarget::Organization(_)) => Some(ORGANIZATION_OPT_IN),
1127 }
1128 };
1129 let tools = TOOLS
1130 .iter()
1131 .filter(|tool| {
1132 tool.applies_to(platform)
1133 && (tool.id != "git" || matches!(target, ScaleTarget::Repository(_)))
1134 })
1135 .map(|tool| {
1136 let mut state = ToolState {
1137 tool,
1138 enabled: tool.default_enabled,
1139 source: ToolSource::Default,
1140 };
1141 if let Some(enabled) = config.tools.get(tool.id) {
1142 state.enabled = *enabled;
1143 state.source = ToolSource::Host;
1144 }
1145 if let Some(enabled) = own.and_then(|own| own.tools.get(tool.id)) {
1146 state.enabled = *enabled;
1147 state.source = ToolSource::Target;
1148 }
1149 state
1150 })
1151 .collect();
1152 Selection {
1153 disabled,
1154 namespace,
1155 tools,
1156 }
1157}
1158
1159#[must_use]
1165fn tool_dir(tool: &CacheTool, namespace_dir: &Path, slot_dir: &Path) -> PathBuf {
1166 match tool.sharing {
1167 Sharing::Namespace => namespace_dir.join(tool.dir),
1168 Sharing::Slot => slot_dir.join(tool.dir),
1169 }
1170}
1171
1172fn render(variable: &CacheVariable, dir: &Path) -> (OsString, Option<PathBuf>) {
1174 match variable.value {
1175 VarValue::Dir => (dir.as_os_str().to_owned(), Some(dir.to_path_buf())),
1176 VarValue::Sub(sub) => {
1177 let path = dir.join(sub);
1178 (path.as_os_str().to_owned(), Some(path))
1179 }
1180 VarValue::Literal(value) => (OsString::from(value), None),
1181 VarValue::Template(template) => (
1182 OsString::from(template.replace("{dir}", &dir.to_string_lossy())),
1183 Some(dir.to_path_buf()),
1184 ),
1185 }
1186}
1187
1188#[must_use]
1190pub fn environment<'a>(
1191 tools: impl IntoIterator<Item = &'a CacheTool>,
1192 namespace_dir: &Path,
1193 slot_dir: &Path,
1194 platform: RunnerPlatform,
1195) -> (Vec<(&'static str, OsString)>, Vec<PathBuf>) {
1196 let mut variables = Vec::new();
1197 let mut dirs = Vec::new();
1198 for tool in tools {
1199 let dir = tool_dir(tool, namespace_dir, slot_dir);
1200 for variable in tool.variables_on(platform) {
1201 let (value, named) = render(variable, &dir);
1202 variables.push((variable.name, value));
1203 dirs.extend(named);
1204 }
1205 }
1206 (variables, dirs)
1207}
1208
1209impl Selection {
1210 pub fn defer_to_operator(&mut self, platform: RunnerPlatform, is_set: impl Fn(&str) -> bool) {
1220 for state in &mut self.tools {
1221 if state.enabled && state.tool.variables_on(platform).any(|v| is_set(v.name)) {
1222 state.enabled = false;
1223 state.source = ToolSource::Operator;
1224 }
1225 }
1226 }
1227}
1228
1229#[must_use]
1234pub fn inherited(name: &str) -> bool {
1235 std::env::vars_os().any(|(key, value)| {
1236 !value.is_empty()
1237 && key
1238 .to_str()
1239 .is_some_and(|key| key.eq_ignore_ascii_case(name))
1240 })
1241}
1242
1243#[must_use]
1251pub fn runtime_holds_runner(runtime: &Path) -> bool {
1252 match fs::metadata(runtime.join("bin")) {
1256 Ok(metadata) => metadata.is_dir(),
1257 Err(error) => error.kind() != io::ErrorKind::NotFound,
1258 }
1259}
1260
1261#[derive(Debug, Clone, PartialEq, Eq)]
1263pub struct LaunchCaches {
1264 pub namespace_dir: PathBuf,
1265 pub slot: u32,
1266 pub variables: Vec<(&'static str, OsString)>,
1267}
1268
1269fn lock_cache_root(root: &Path) -> Result<HostLock, CacheError> {
1270 HostLock::acquire_at(
1271 &root.join(LockKind::DependencyCache.file_name()),
1272 LockKind::DependencyCache,
1273 LOCK_WAIT,
1274 )
1275 .map_err(|error| match error {
1276 crate::lock::LockError::Io { path, source } => CacheError::Io {
1280 action: "lock",
1281 path,
1282 source,
1283 },
1284 other => CacheError::Lock(other.to_string()),
1285 })
1286}
1287
1288pub fn prepare_launch(
1297 root: &Path,
1298 selection: &Selection,
1299 runtime: &Path,
1300 platform: RunnerPlatform,
1301 holds_runner: &dyn Fn(&Path) -> bool,
1302) -> Result<LaunchCaches, CacheError> {
1303 fs::create_dir_all(root).map_err(io_error("create", root))?;
1304 let namespace_dir = selection.namespace.dir(root);
1305 let slots = namespace_dir.join(SLOTS_DIR);
1306 let slot = {
1307 let _lock = lock_cache_root(root)?;
1308 fs::create_dir_all(&slots).map_err(io_error("create", &slots))?;
1309 let slot = free_slot(&slots, runtime, holds_runner);
1310 let lease = slots.join(format!("{slot}.{LEASE_EXTENSION}"));
1311 fs::write(&lease, runtime.to_string_lossy().as_bytes())
1312 .map_err(io_error("write", &lease))?;
1313 let marker = namespace_dir.join(LAST_USED_FILE);
1314 fs::write(&marker, Utc::now().to_rfc3339().as_bytes())
1315 .map_err(io_error("write", &marker))?;
1316 slot
1317 };
1318 let slot_dir = slots.join(slot.to_string());
1319 let (variables, dirs) = environment(
1320 selection.enabled_tools(),
1321 &namespace_dir,
1322 &slot_dir,
1323 platform,
1324 );
1325 for dir in dirs {
1326 fs::create_dir_all(&dir).map_err(io_error("create", &dir))?;
1327 }
1328 Ok(LaunchCaches {
1329 namespace_dir,
1330 slot,
1331 variables,
1332 })
1333}
1334
1335fn free_slot(slots: &Path, runtime: &Path, holds_runner: &dyn Fn(&Path) -> bool) -> u32 {
1338 let own = runtime.to_string_lossy();
1339 (1..)
1340 .find(
1341 |slot| match fs::read_to_string(slots.join(format!("{slot}.{LEASE_EXTENSION}"))) {
1342 Err(error) => error.kind() == io::ErrorKind::NotFound,
1343 Ok(holder) => holder == own || !holds_runner(Path::new(&holder)),
1344 },
1345 )
1346 .expect("an unbounded range has a free slot")
1347}
1348
1349fn namespace_in_use(namespace_dir: &Path, holds_runner: &dyn Fn(&Path) -> bool) -> bool {
1351 let Ok(entries) = fs::read_dir(namespace_dir.join(SLOTS_DIR)) else {
1352 return false;
1353 };
1354 entries.flatten().any(|entry| {
1355 let path = entry.path();
1356 path.extension().is_some_and(|ext| ext == LEASE_EXTENSION)
1357 && fs::read_to_string(&path).map_or(true, |holder| holds_runner(Path::new(&holder)))
1358 })
1359}
1360
1361#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1367pub struct NamespaceUsage {
1368 pub name: String,
1370 pub bytes: u64,
1371 pub last_used: Option<DateTime<Utc>>,
1372 pub in_use: bool,
1373}
1374
1375#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1377pub struct CacheUsage {
1378 pub measured_at: DateTime<Utc>,
1379 pub total_bytes: u64,
1380 pub max_bytes: Option<u64>,
1381 pub namespaces: Vec<NamespaceUsage>,
1382 pub pruned: Vec<String>,
1384}
1385
1386impl CacheUsage {
1387 #[must_use]
1389 pub fn read(root: &Path) -> Option<Self> {
1390 let bytes = fs::read(root.join(USAGE_FILE)).ok()?;
1391 serde_json::from_slice(&bytes).ok()
1392 }
1393}
1394
1395fn tree_size(path: &Path) -> u64 {
1397 let mut total = 0_u64;
1398 let mut pending = vec![path.to_path_buf()];
1399 while let Some(dir) = pending.pop() {
1400 let Ok(entries) = fs::read_dir(&dir) else {
1401 continue;
1402 };
1403 for entry in entries.flatten() {
1404 let Ok(metadata) = entry.metadata() else {
1405 continue;
1406 };
1407 if metadata.is_dir() {
1408 pending.push(entry.path());
1409 } else if metadata.is_file() {
1410 total = total.saturating_add(metadata.len());
1411 }
1412 }
1413 }
1414 total
1415}
1416
1417fn modified(path: &Path) -> Option<SystemTime> {
1418 fs::metadata(path).and_then(|m| m.modified()).ok()
1419}
1420
1421fn namespace_dirs(root: &Path) -> Vec<(String, PathBuf)> {
1426 let mut found = Vec::new();
1427 let Ok(first) = fs::read_dir(root) else {
1428 return found;
1429 };
1430 for outer in first.flatten() {
1431 let outer_name = outer.file_name().to_string_lossy().into_owned();
1432 if outer_name.starts_with('.') || !outer.file_type().is_ok_and(|t| t.is_dir()) {
1433 continue;
1434 }
1435 let Ok(second) = fs::read_dir(outer.path()) else {
1436 continue;
1437 };
1438 for inner in second.flatten() {
1439 let path = inner.path();
1440 let marked = path.join(LAST_USED_FILE).is_file() || path.join(SLOTS_DIR).is_dir();
1441 if marked && inner.file_type().is_ok_and(|t| t.is_dir()) {
1442 let name = format!("{outer_name}/{}", inner.file_name().to_string_lossy());
1443 found.push((name, path));
1444 }
1445 }
1446 }
1447 found.sort();
1448 found
1449}
1450
1451#[must_use]
1453pub(crate) fn measure(root: &Path, holds_runner: &dyn Fn(&Path) -> bool) -> Vec<NamespaceUsage> {
1454 namespace_dirs(root)
1455 .into_iter()
1456 .map(|(name, dir)| NamespaceUsage {
1457 bytes: tree_size(&dir),
1458 last_used: modified(&dir.join(LAST_USED_FILE))
1459 .or_else(|| modified(&dir))
1460 .map(DateTime::<Utc>::from),
1461 in_use: namespace_in_use(&dir, holds_runner),
1462 name,
1463 })
1464 .collect()
1465}
1466
1467#[must_use]
1471pub(crate) fn plan_prune(namespaces: &[NamespaceUsage], max_bytes: Option<u64>) -> Vec<String> {
1472 let Some(max) = max_bytes else {
1473 return Vec::new();
1474 };
1475 let mut total: u64 = namespaces.iter().map(|ns| ns.bytes).sum();
1476 let mut idle: Vec<&NamespaceUsage> = namespaces.iter().filter(|ns| !ns.in_use).collect();
1477 idle.sort_by(|a, b| a.last_used.cmp(&b.last_used).then(a.name.cmp(&b.name)));
1479 let mut chosen = Vec::new();
1480 for namespace in idle {
1481 if total <= max {
1482 break;
1483 }
1484 total = total.saturating_sub(namespace.bytes);
1485 chosen.push(namespace.name.clone());
1486 }
1487 chosen
1488}
1489
1490pub fn prune(
1501 root: &Path,
1502 max_bytes: Option<u64>,
1503 holds_runner: &dyn Fn(&Path) -> bool,
1504) -> Result<CacheUsage, CacheError> {
1505 empty_trash(root);
1506 let mut namespaces = measure(root, holds_runner);
1507 let planned = plan_prune(&namespaces, max_bytes);
1508 let mut pruned = Vec::new();
1509 if !planned.is_empty() {
1510 let trash = root.join(TRASH_DIR);
1511 fs::create_dir_all(&trash).map_err(io_error("create", &trash))?;
1512 let _lock = lock_cache_root(root)?;
1513 for name in planned {
1514 let dir = root.join(&name);
1515 if namespace_in_use(&dir, holds_runner) {
1516 continue;
1517 }
1518 let aside = trash.join(uuid::Uuid::new_v4().simple().to_string());
1519 if fs::rename(&dir, &aside).is_ok() {
1520 pruned.push(name);
1521 }
1522 }
1523 }
1524 empty_trash(root);
1525 namespaces.retain(|ns| !pruned.contains(&ns.name));
1526 let usage = CacheUsage {
1527 measured_at: Utc::now(),
1528 total_bytes: namespaces.iter().map(|ns| ns.bytes).sum(),
1529 max_bytes,
1530 namespaces,
1531 pruned,
1532 };
1533 let json = serde_json::to_vec_pretty(&usage).map_err(|error| CacheError::Io {
1534 action: "encode",
1535 path: root.join(USAGE_FILE),
1536 source: io::Error::other(error),
1537 })?;
1538 write_atomically(&root.join(USAGE_FILE), &json)?;
1539 Ok(usage)
1540}
1541
1542fn empty_trash(root: &Path) {
1545 let Ok(entries) = fs::read_dir(root.join(TRASH_DIR)) else {
1546 return;
1547 };
1548 for entry in entries.flatten() {
1549 let _ = remove_tree(&entry.path());
1550 }
1551}
1552
1553fn remove_tree(path: &Path) -> io::Result<()> {
1560 #[cfg(windows)]
1561 {
1562 remove_dir_all::remove_dir_all(path)
1563 }
1564 #[cfg(not(windows))]
1565 {
1566 match fs::remove_dir_all(path) {
1567 Err(error) if error.kind() == io::ErrorKind::PermissionDenied => {
1568 open_directories(path);
1569 fs::remove_dir_all(path)
1570 }
1571 other => other,
1572 }
1573 }
1574}
1575
1576#[cfg(not(windows))]
1578fn open_directories(path: &Path) {
1579 use std::os::unix::fs::PermissionsExt as _;
1580 let mut pending = vec![path.to_path_buf()];
1581 while let Some(current) = pending.pop() {
1582 let Ok(metadata) = fs::symlink_metadata(¤t) else {
1583 continue;
1584 };
1585 if !metadata.is_dir() {
1586 continue;
1587 }
1588 let mode = metadata.permissions().mode();
1589 if mode & 0o700 != 0o700 {
1590 let _ = fs::set_permissions(¤t, fs::Permissions::from_mode(mode | 0o700));
1591 }
1592 if let Ok(entries) = fs::read_dir(¤t) {
1593 pending.extend(entries.flatten().map(|entry| entry.path()));
1594 }
1595 }
1596}
1597
1598#[must_use]
1600pub fn tool_ids() -> Vec<&'static str> {
1601 TOOLS.iter().map(|tool| tool.id).collect()
1602}
1603
1604#[must_use]
1606pub fn human_bytes(bytes: u64) -> String {
1607 #[allow(clippy::cast_precision_loss)]
1608 let value = bytes as f64;
1609 if bytes >= GIB {
1610 format!("{:.1} GiB", value / GIB as f64)
1611 } else if bytes >= 1024 * 1024 {
1612 format!("{:.0} MiB", value / (1024.0 * 1024.0))
1613 } else {
1614 format!("{:.0} KiB", value / 1024.0)
1615 }
1616}
1617
1618#[cfg(test)]
1619mod tests;