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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244pub enum Sharing {
245 Namespace,
248 Slot,
250}
251
252#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254pub enum VarValue {
255 Dir,
257 Sub(&'static str),
259 Literal(&'static str),
261 Template(&'static str),
264}
265
266#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
300pub struct CacheTool {
301 pub id: &'static str,
303 pub dir: &'static str,
305 pub sharing: Sharing,
306 pub default_enabled: bool,
308 pub variables: &'static [CacheVariable],
309 pub note: &'static str,
311}
312
313impl CacheTool {
314 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 #[must_use]
323 pub fn applies_to(&self, platform: RunnerPlatform) -> bool {
324 self.variables_on(platform).next().is_some()
325 }
326}
327
328const PNPM_IMPORT_METHOD: VarValue = VarValue::Literal("clone-or-copy");
332
333pub 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 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 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 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 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#[must_use]
612pub fn tool(id: &str) -> Option<&'static CacheTool> {
613 TOOLS.iter().find(|tool| tool.id == id)
614}
615
616#[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 #[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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
672#[serde(deny_unknown_fields)]
673pub struct CacheConfig {
674 #[serde(default, skip_serializing_if = "Option::is_none")]
676 pub enabled: Option<bool>,
677 #[serde(default, skip_serializing_if = "Option::is_none")]
679 pub root: Option<String>,
680 #[serde(default, skip_serializing_if = "Option::is_none")]
682 pub max_size_gib: Option<u64>,
683 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
685 pub tools: BTreeMap<String, bool>,
686 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
689 pub targets: BTreeMap<String, TargetCacheConfig>,
690}
691
692#[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 #[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#[must_use]
713pub fn config_path_in(config_dir: &Path) -> PathBuf {
714 config_dir.join(CONFIG_FILE)
715}
716
717#[must_use]
719pub fn target_key(target: &ScaleTarget) -> String {
720 target.slug().to_ascii_lowercase()
721}
722
723impl CacheConfig {
724 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 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 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 #[must_use]
802 pub fn target(&self, target: &ScaleTarget) -> Option<&TargetCacheConfig> {
803 self.targets.get(&target_key(target))
804 }
805
806 pub fn target_mut(&mut self, target: &ScaleTarget) -> &mut TargetCacheConfig {
808 self.targets.entry(target_key(target)).or_default()
809 }
810
811 #[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 #[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
831pub fn validate_root_text(root: &str) -> Result<(), CacheError> {
841 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
851pub 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
870fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
890pub enum RootSource {
891 Configured,
893 RunnerRoot,
895 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#[derive(Debug, Clone, PartialEq, Eq)]
912pub struct ResolvedRoot {
913 pub path: PathBuf,
914 pub source: RootSource,
915}
916
917pub 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#[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
981fn 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#[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 #[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 #[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 #[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 #[must_use]
1035 pub fn label(&self) -> String {
1036 self.segments().join("/")
1037 }
1038}
1039
1040fn segment(raw: &str) -> String {
1046 let cleaned = portable_segment(raw);
1047 if cleaned.len() <= MAX_SEGMENT_LEN {
1048 return cleaned;
1049 }
1050 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
1062const 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1090pub enum ToolSource {
1091 Default,
1092 Host,
1093 Target,
1094 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
1120pub struct Selection {
1121 pub disabled: Option<&'static str>,
1123 pub namespace: Namespace,
1124 pub tools: Vec<ToolState>,
1126}
1127
1128impl Selection {
1129 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
1138pub 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#[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#[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
1204fn 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#[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 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#[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#[must_use]
1283pub fn runtime_holds_runner(runtime: &Path) -> bool {
1284 match fs::metadata(runtime.join("bin")) {
1288 Ok(metadata) => metadata.is_dir(),
1289 Err(error) => error.kind() != io::ErrorKind::NotFound,
1290 }
1291}
1292
1293#[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 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
1320pub 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
1367fn 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
1381fn 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1399pub struct NamespaceUsage {
1400 pub name: String,
1402 pub bytes: u64,
1403 pub last_used: Option<DateTime<Utc>>,
1404 pub in_use: bool,
1405}
1406
1407#[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 pub pruned: Vec<String>,
1416}
1417
1418impl CacheUsage {
1419 #[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
1427fn 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
1453fn 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#[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#[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 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
1522pub 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
1574fn 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
1585fn 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#[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(¤t) 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(¤t, fs::Permissions::from_mode(mode | 0o700));
1623 }
1624 if let Ok(entries) = fs::read_dir(¤t) {
1625 pending.extend(entries.flatten().map(|entry| entry.path()));
1626 }
1627 }
1628}
1629
1630#[must_use]
1632pub fn tool_ids() -> Vec<&'static str> {
1633 TOOLS.iter().map(|tool| tool.id).collect()
1634}
1635
1636#[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;