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: "xdg",
323 dir: "xdg",
324 sharing: Sharing::Namespace,
325 default_enabled: true,
326 variables: &[unix("XDG_CACHE_HOME", VarValue::Dir)],
327 note: "catch-all for Unix tools that follow the XDG base directory specification; not set \
328 on Windows, where pnpm, Bun and Deno would also move global state",
329 },
330 CacheTool {
331 id: "tool-cache",
332 dir: "tool-cache",
333 sharing: Sharing::Slot,
334 default_enabled: true,
335 variables: &[
336 all("RUNNER_TOOL_CACHE", VarValue::Dir),
337 all("AGENT_TOOLSDIRECTORY", VarValue::Dir),
338 ],
339 note: "toolchains setup-node, setup-go and setup-java download; per slot because \
340 @actions/tool-cache deletes a version's folder before filling it",
341 },
342 CacheTool {
343 id: "dotnet",
344 dir: "dotnet",
345 sharing: Sharing::Slot,
346 default_enabled: true,
347 variables: &[all("DOTNET_INSTALL_DIR", VarValue::Dir)],
348 note: "SDKs setup-dotnet installs; per slot because an install replaces the shared dotnet \
349 host",
350 },
351 CacheTool {
352 id: "npm",
353 dir: "npm",
354 sharing: Sharing::Namespace,
355 default_enabled: true,
356 variables: &[all("npm_config_cache", VarValue::Dir)],
357 note: "npm's cacache and npx",
358 },
359 CacheTool {
360 id: "pnpm",
361 dir: "pnpm-store",
362 sharing: Sharing::Namespace,
363 default_enabled: true,
364 variables: &[
365 all("npm_config_store_dir", VarValue::Dir),
366 all("PNPM_CONFIG_STORE_DIR", VarValue::Dir),
367 ],
368 note: "the content-addressed store: pnpm 9 and 10 read npm_config_store_dir, pnpm 11 and \
369 later PNPM_CONFIG_STORE_DIR; npm 12 warns that store-dir is unknown",
370 },
371 CacheTool {
372 id: "yarn",
373 dir: "yarn",
374 sharing: Sharing::Namespace,
375 default_enabled: true,
376 variables: &[all("YARN_GLOBAL_FOLDER", VarValue::Dir)],
377 note: "Yarn 2+ global cache; YARN_CACHE_FOLDER is left alone because it would override a \
378 zero-install project's committed cache",
379 },
380 CacheTool {
381 id: "bun",
382 dir: "bun",
383 sharing: Sharing::Namespace,
384 default_enabled: true,
385 variables: &[all("BUN_INSTALL_CACHE_DIR", VarValue::Dir)],
386 note: "Bun's package cache",
387 },
388 CacheTool {
389 id: "deno",
390 dir: "deno",
391 sharing: Sharing::Namespace,
392 default_enabled: true,
393 variables: &[all("DENO_DIR", VarValue::Dir)],
394 note: "Deno's module and npm cache (it also keeps localStorage there)",
395 },
396 CacheTool {
397 id: "node-gyp",
398 dir: "node-gyp",
399 sharing: Sharing::Namespace,
400 default_enabled: true,
401 variables: &[
402 all("npm_config_devdir", VarValue::Dir),
403 all("npm_package_config_node_gyp_devdir", VarValue::Dir),
404 ],
405 note: "Node headers node-gyp downloads for native modules",
406 },
407 CacheTool {
408 id: "electron",
409 dir: "electron",
410 sharing: Sharing::Namespace,
411 default_enabled: true,
412 variables: &[
413 all("electron_config_cache", VarValue::Dir),
416 all("ELECTRON_CACHE", VarValue::Dir),
417 ],
418 note: "Electron release downloads",
419 },
420 CacheTool {
421 id: "electron-builder",
422 dir: "electron-builder",
423 sharing: Sharing::Namespace,
424 default_enabled: true,
425 variables: &[all("ELECTRON_BUILDER_CACHE", VarValue::Dir)],
426 note: "NSIS, winCodeSign, AppImage and other packaging tools",
427 },
428 CacheTool {
429 id: "playwright",
430 dir: "ms-playwright",
431 sharing: Sharing::Namespace,
432 default_enabled: true,
433 variables: &[
434 all("PLAYWRIGHT_BROWSERS_PATH", VarValue::Dir),
435 all("PLAYWRIGHT_SKIP_BROWSER_GC", VarValue::Literal("1")),
438 ],
439 note: "Playwright browsers",
440 },
441 CacheTool {
442 id: "cypress",
443 dir: "cypress",
444 sharing: Sharing::Slot,
445 default_enabled: true,
446 variables: &[all("CYPRESS_CACHE_FOLDER", VarValue::Dir)],
447 note: "Cypress binaries; per slot because cypress install takes no lock",
448 },
449 CacheTool {
450 id: "nuget",
451 dir: "nuget",
452 sharing: Sharing::Namespace,
453 default_enabled: true,
454 variables: &[
455 all("NUGET_PACKAGES", VarValue::Sub("packages")),
456 all("NUGET_HTTP_CACHE_PATH", VarValue::Sub("http-cache")),
457 all("NUGET_PLUGINS_CACHE_PATH", VarValue::Sub("plugins-cache")),
458 all("NUGET_SCRATCH", VarValue::Sub("scratch")),
462 ],
463 note: "global packages and HTTP cache",
464 },
465 CacheTool {
466 id: "pip",
467 dir: "pip",
468 sharing: Sharing::Namespace,
469 default_enabled: true,
470 variables: &[all("PIP_CACHE_DIR", VarValue::Dir)],
471 note: "pip's HTTP and wheel cache",
472 },
473 CacheTool {
474 id: "uv",
475 dir: "uv",
476 sharing: Sharing::Namespace,
477 default_enabled: true,
478 variables: &[all("UV_CACHE_DIR", VarValue::Dir)],
479 note: "uv's cache",
480 },
481 CacheTool {
482 id: "go",
483 dir: "go",
484 sharing: Sharing::Namespace,
485 default_enabled: true,
486 variables: &[
487 all("GOMODCACHE", VarValue::Sub("mod")),
488 all("GOCACHE", VarValue::Sub("build")),
489 ],
490 note: "Go module and build caches",
491 },
492 CacheTool {
493 id: "composer",
494 dir: "composer",
495 sharing: Sharing::Namespace,
496 default_enabled: true,
497 variables: &[all("COMPOSER_CACHE_DIR", VarValue::Dir)],
498 note: "Composer's download cache",
499 },
500 CacheTool {
501 id: "cocoapods",
502 dir: "cocoapods",
503 sharing: Sharing::Namespace,
504 default_enabled: true,
505 variables: &[unix("CP_CACHE_DIR", VarValue::Dir)],
506 note: "CocoaPods download cache",
507 },
508 CacheTool {
509 id: "ccache",
510 dir: "ccache",
511 sharing: Sharing::Namespace,
512 default_enabled: true,
513 variables: &[all("CCACHE_DIR", VarValue::Dir)],
514 note: "ccache objects",
515 },
516 CacheTool {
517 id: "poetry",
518 dir: "poetry",
519 sharing: Sharing::Slot,
520 default_enabled: false,
521 variables: &[all("POETRY_CACHE_DIR", VarValue::Dir)],
522 note: "opt-in: Poetry also keeps virtualenvs in its cache directory, one per workspace path",
523 },
524 CacheTool {
525 id: "cargo",
526 dir: "cargo",
527 sharing: Sharing::Namespace,
528 default_enabled: false,
529 variables: &[all("CARGO_HOME", VarValue::Dir)],
530 note: "opt-in: CARGO_HOME also holds config, registry credentials and installed binaries",
531 },
532 CacheTool {
533 id: "gradle",
534 dir: "gradle",
535 sharing: Sharing::Namespace,
536 default_enabled: false,
537 variables: &[all("GRADLE_USER_HOME", VarValue::Dir)],
538 note: "opt-in: GRADLE_USER_HOME also holds gradle.properties, init scripts and daemons",
539 },
540 CacheTool {
541 id: "maven",
542 dir: "maven",
543 sharing: Sharing::Slot,
544 default_enabled: false,
545 variables: &[all(
546 "MAVEN_ARGS",
547 VarValue::Template("-Dmaven.repo.local={dir}"),
548 )],
549 note: "opt-in: Maven 3.9+ local repository via MAVEN_ARGS, which a workflow's own \
550 MAVEN_ARGS replaces",
551 },
552 CacheTool {
553 id: "pub",
554 dir: "pub",
555 sharing: Sharing::Slot,
556 default_enabled: false,
557 variables: &[all("PUB_CACHE", VarValue::Dir)],
558 note: "opt-in: PUB_CACHE also holds globally activated Dart tools",
559 },
560 CacheTool {
561 id: "bundler",
562 dir: "bundler",
563 sharing: Sharing::Slot,
564 default_enabled: false,
565 variables: &[all("BUNDLE_USER_CACHE", VarValue::Dir)],
566 note: "opt-in: Bundler's download cache",
567 },
568];
569
570#[must_use]
572pub fn tool(id: &str) -> Option<&'static CacheTool> {
573 TOOLS.iter().find(|tool| tool.id == id)
574}
575
576#[derive(Debug, thiserror::Error)]
582pub enum CacheError {
583 #[error("{} is not valid TOML for caches: {message}", path.display())]
584 Parse { path: PathBuf, message: String },
585 #[error("`{0}` is not a cache this version knows; `host cache show` lists them")]
586 UnknownTool(String),
587 #[error(
588 "`{0}` is not a valid shared namespace name; use 1 to 64 lower-case letters, digits, \
589 `.`, `_` or `-`, not starting with `.`"
590 )]
591 InvalidNamespace(String),
592 #[error("`{0}` is not a repository (OWNER/REPO) or an organization")]
593 InvalidTarget(String),
594 #[error("the cache root {0} must be an absolute path without spaces")]
595 InvalidRoot(String),
596 #[error("cannot {action} {}: {source}", path.display())]
597 Io {
598 action: &'static str,
599 path: PathBuf,
600 #[source]
601 source: io::Error,
602 },
603 #[error("{0}")]
604 Lock(String),
605}
606
607impl CacheError {
608 #[must_use]
611 pub fn is_permission_denied(&self) -> bool {
612 matches!(self, Self::Io { source, .. } if source.kind() == io::ErrorKind::PermissionDenied)
613 }
614}
615
616fn io_error(action: &'static str, path: &Path) -> impl FnOnce(io::Error) -> CacheError + use<> {
617 let path = path.to_path_buf();
618 move |source| CacheError::Io {
619 action,
620 path,
621 source,
622 }
623}
624
625#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
632#[serde(deny_unknown_fields)]
633pub struct CacheConfig {
634 #[serde(default, skip_serializing_if = "Option::is_none")]
636 pub enabled: Option<bool>,
637 #[serde(default, skip_serializing_if = "Option::is_none")]
639 pub root: Option<String>,
640 #[serde(default, skip_serializing_if = "Option::is_none")]
642 pub max_size_gib: Option<u64>,
643 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
645 pub tools: BTreeMap<String, bool>,
646 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
649 pub targets: BTreeMap<String, TargetCacheConfig>,
650}
651
652#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
654#[serde(deny_unknown_fields)]
655pub struct TargetCacheConfig {
656 #[serde(default, skip_serializing_if = "Option::is_none")]
657 pub enabled: Option<bool>,
658 #[serde(default, skip_serializing_if = "Option::is_none")]
660 pub namespace: Option<String>,
661 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
662 pub tools: BTreeMap<String, bool>,
663}
664
665impl TargetCacheConfig {
666 fn is_empty(&self) -> bool {
667 self.enabled.is_none() && self.namespace.is_none() && self.tools.is_empty()
668 }
669}
670
671#[must_use]
673pub fn config_path_in(config_dir: &Path) -> PathBuf {
674 config_dir.join(CONFIG_FILE)
675}
676
677#[must_use]
679pub fn target_key(target: &ScaleTarget) -> String {
680 target.slug().to_ascii_lowercase()
681}
682
683impl CacheConfig {
684 pub fn parse(text: &str, path: &Path) -> Result<Self, CacheError> {
690 let config: Self = toml::from_str(text).map_err(|error| CacheError::Parse {
691 path: path.to_path_buf(),
692 message: error.message().to_owned(),
693 })?;
694 config.validate()?;
695 Ok(config)
696 }
697
698 pub fn load(path: &Path) -> Result<Self, CacheError> {
703 match fs::read_to_string(path) {
704 Ok(text) => Self::parse(&text, path),
705 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
706 Err(source) => Err(CacheError::Io {
707 action: "read",
708 path: path.to_path_buf(),
709 source,
710 }),
711 }
712 }
713
714 fn validate(&self) -> Result<(), CacheError> {
715 if let Some(root) = &self.root {
716 validate_root_text(root)?;
717 }
718 let tool_ids = self
719 .tools
720 .keys()
721 .chain(self.targets.values().flat_map(|target| target.tools.keys()));
722 for id in tool_ids {
723 if tool(id).is_none() {
724 return Err(CacheError::UnknownTool(id.clone()));
725 }
726 }
727 for (key, target) in &self.targets {
728 ScaleTarget::repository(key)
729 .or_else(|_| ScaleTarget::organization(key))
730 .map_err(|_| CacheError::InvalidTarget(key.clone()))?;
731 if let Some(name) = &target.namespace {
732 validate_namespace_name(name)?;
733 }
734 }
735 Ok(())
736 }
737
738 pub fn save(&self, path: &Path) -> Result<(), CacheError> {
744 let mut config = self.clone();
745 config.targets.retain(|_, target| !target.is_empty());
746 config.validate()?;
747 let mut text = String::from(
748 "# Dependency caches for native runners. Edited by `runner-manager host cache`,\n\
749 # `repo cache` and `org cache`; see `host cache show`.\n",
750 );
751 text.push_str(
752 &toml::to_string(&config).map_err(|error| CacheError::Parse {
753 path: path.to_path_buf(),
754 message: error.to_string(),
755 })?,
756 );
757 write_atomically(path, text.as_bytes())
758 }
759
760 #[must_use]
762 pub fn target(&self, target: &ScaleTarget) -> Option<&TargetCacheConfig> {
763 self.targets.get(&target_key(target))
764 }
765
766 pub fn target_mut(&mut self, target: &ScaleTarget) -> &mut TargetCacheConfig {
768 self.targets.entry(target_key(target)).or_default()
769 }
770
771 #[must_use]
773 pub fn max_bytes(&self) -> Option<u64> {
774 match self.max_size_gib.unwrap_or(DEFAULT_MAX_SIZE_GIB) {
775 0 => None,
776 gib => Some(gib.saturating_mul(GIB)),
777 }
778 }
779
780 #[must_use]
782 pub fn host_enabled(&self) -> bool {
783 self.enabled.unwrap_or(true)
784 }
785}
786
787fn write_atomically(path: &Path, bytes: &[u8]) -> Result<(), CacheError> {
788 crate::host_fitness::write_atomically(path, bytes).map_err(io_error("write", path))
789}
790
791pub fn validate_root_text(root: &str) -> Result<(), CacheError> {
801 let network = root.starts_with(r"\\") || root.starts_with("//");
805 if network || root.chars().any(char::is_whitespace) || !Path::new(root).is_absolute() {
806 return Err(CacheError::InvalidRoot(root.to_owned()));
807 }
808 Ok(())
809}
810
811pub fn validate_namespace_name(name: &str) -> Result<(), CacheError> {
816 let valid = !name.is_empty()
817 && name.len() <= 64
818 && !name.starts_with('.')
819 && name
820 .chars()
821 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-'))
822 && !is_windows_device_name(name);
823 if valid {
824 Ok(())
825 } else {
826 Err(CacheError::InvalidNamespace(name.to_owned()))
827 }
828}
829
830fn is_windows_device_name(segment: &str) -> bool {
833 let stem = segment
834 .split('.')
835 .next()
836 .unwrap_or(segment)
837 .to_ascii_uppercase();
838 matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
839 || (stem.len() == 4
840 && (stem.starts_with("COM") || stem.starts_with("LPT"))
841 && stem.as_bytes()[3].is_ascii_digit())
842}
843
844#[derive(Debug, Clone, Copy, PartialEq, Eq)]
850pub enum RootSource {
851 Configured,
853 RunnerRoot,
855 PlatformFallback,
857}
858
859impl RootSource {
860 #[must_use]
861 pub const fn as_token(self) -> &'static str {
862 match self {
863 Self::Configured => "configured",
864 Self::RunnerRoot => "runner-root",
865 Self::PlatformFallback => "platform-fallback",
866 }
867 }
868}
869
870#[derive(Debug, Clone, PartialEq, Eq)]
872pub struct ResolvedRoot {
873 pub path: PathBuf,
874 pub source: RootSource,
875}
876
877pub fn resolve_root(
887 config: &CacheConfig,
888 runner_root: Option<&Path>,
889 fallback: Option<PathBuf>,
890) -> Result<ResolvedRoot, String> {
891 if let Some(root) = &config.root {
892 return Ok(ResolvedRoot {
893 path: PathBuf::from(root),
894 source: RootSource::Configured,
895 });
896 }
897 let usable = |path: &Path| !path.to_string_lossy().chars().any(char::is_whitespace);
898 if let Some(runner_root) = runner_root {
899 let candidate = runner_root.join(DEFAULT_ROOT_NAME);
900 if usable(&candidate) {
901 return Ok(ResolvedRoot {
902 path: candidate,
903 source: RootSource::RunnerRoot,
904 });
905 }
906 }
907 match fallback.filter(|path| usable(path) && path.is_absolute()) {
908 Some(path) => Ok(ResolvedRoot {
909 path,
910 source: RootSource::PlatformFallback,
911 }),
912 None => Err(
913 "no cache root without spaces could be derived; set one with \
914 `runner-manager host cache set-root --path <PATH>`"
915 .to_owned(),
916 ),
917 }
918}
919
920#[must_use]
929pub fn platform_fallback_root(app_paths: &AppPaths) -> Option<PathBuf> {
930 if cfg!(windows) {
931 crate::runner_root::default_runner_root(app_paths)
932 .ok()
933 .map(|root| root.as_path().join(DEFAULT_ROOT_NAME))
934 } else {
935 library_caches_beside(app_paths.runtime_dir())
936 .or_else(|| directories::BaseDirs::new().map(|dirs| dirs.cache_dir().to_path_buf()))
937 .map(|caches| caches.join("runner-manager"))
938 }
939}
940
941fn library_caches_beside(path: &Path) -> Option<PathBuf> {
944 path.ancestors().find_map(|ancestor| {
945 let library = ancestor.parent()?;
946 (ancestor.file_name()? == "Application Support" && library.file_name()? == "Library")
947 .then(|| library.join("Caches"))
948 })
949}
950
951#[derive(Debug, Clone, PartialEq, Eq)]
957pub enum Namespace {
958 Repository { owner: String, repo: String },
959 Organization(String),
960 Shared(String),
961}
962
963impl Namespace {
964 #[must_use]
966 pub fn of(target: &ScaleTarget) -> Self {
967 match target {
968 ScaleTarget::Repository(repository) => Self::Repository {
969 owner: repository.owner().to_ascii_lowercase(),
970 repo: repository.repo().to_ascii_lowercase(),
971 },
972 ScaleTarget::Organization(org) => Self::Organization(org.as_str().to_ascii_lowercase()),
973 }
974 }
975
976 #[must_use]
978 pub fn segments(&self) -> [String; 2] {
979 match self {
980 Self::Repository { owner, repo } => [segment(owner), segment(repo)],
981 Self::Organization(org) => [ORG_PREFIX.to_owned(), segment(org)],
982 Self::Shared(name) => [SHARED_PREFIX.to_owned(), segment(name)],
983 }
984 }
985
986 #[must_use]
988 pub fn dir(&self, root: &Path) -> PathBuf {
989 let [first, second] = self.segments();
990 root.join(first).join(second)
991 }
992
993 #[must_use]
995 pub fn label(&self) -> String {
996 self.segments().join("/")
997 }
998}
999
1000fn segment(raw: &str) -> String {
1006 let cleaned = portable_segment(raw);
1007 if cleaned.len() <= MAX_SEGMENT_LEN {
1008 return cleaned;
1009 }
1010 let digest = hex::encode(sha2::Sha256::digest(raw.as_bytes()));
1015 format!(
1016 "{}-{}",
1017 &cleaned[..MAX_SEGMENT_LEN - SEGMENT_DIGEST_LEN - 1],
1018 &digest[..SEGMENT_DIGEST_LEN]
1019 )
1020}
1021
1022const MAX_SEGMENT_LEN: usize = 32;
1024const SEGMENT_DIGEST_LEN: usize = 8;
1025
1026fn portable_segment(raw: &str) -> String {
1027 let cleaned: String = raw
1028 .chars()
1029 .map(|c| {
1030 if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
1031 c
1032 } else {
1033 '_'
1034 }
1035 })
1036 .collect();
1037 let cleaned = match cleaned.as_str() {
1038 "" | "." | ".." => format!("_{cleaned}"),
1039 _ => cleaned,
1040 };
1041 if is_windows_device_name(&cleaned) || cleaned.ends_with('.') {
1042 format!("{cleaned}_")
1043 } else {
1044 cleaned
1045 }
1046}
1047
1048#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1050pub enum ToolSource {
1051 Default,
1052 Host,
1053 Target,
1054 Operator,
1056}
1057
1058impl ToolSource {
1059 #[must_use]
1060 pub const fn as_token(self) -> &'static str {
1061 match self {
1062 Self::Default => "default",
1063 Self::Host => "host",
1064 Self::Target => "this target",
1065 Self::Operator => "set by runner.env or the service",
1066 }
1067 }
1068}
1069
1070#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1072pub struct ToolState {
1073 pub tool: &'static CacheTool,
1074 pub enabled: bool,
1075 pub source: ToolSource,
1076}
1077
1078#[derive(Debug, Clone, PartialEq, Eq)]
1080pub struct Selection {
1081 pub disabled: Option<&'static str>,
1083 pub namespace: Namespace,
1084 pub tools: Vec<ToolState>,
1086}
1087
1088impl Selection {
1089 pub fn enabled_tools(&self) -> impl Iterator<Item = &'static CacheTool> + '_ {
1091 self.tools
1092 .iter()
1093 .filter(|state| state.enabled)
1094 .map(|state| state.tool)
1095 }
1096}
1097
1098pub const ORGANIZATION_OPT_IN: &str = "organization policies accept jobs from every repository \
1100 in the organization, so they get a cache only with `org cache set-enabled ORG --enabled \
1101 true`";
1102
1103#[must_use]
1106pub fn select(config: &CacheConfig, target: &ScaleTarget, platform: RunnerPlatform) -> Selection {
1107 let own = config.target(target);
1108 let namespace = own
1109 .and_then(|own| own.namespace.clone())
1110 .map_or_else(|| Namespace::of(target), Namespace::Shared);
1111 let disabled = if !config.host_enabled() {
1112 Some("caches are turned off on this host")
1113 } else {
1114 match (own.and_then(|own| own.enabled), target) {
1115 (Some(true), _) => None,
1116 (Some(false), _) => Some("caches are turned off for this target"),
1117 (None, ScaleTarget::Repository(_)) => None,
1118 (None, ScaleTarget::Organization(_)) => Some(ORGANIZATION_OPT_IN),
1119 }
1120 };
1121 let tools = TOOLS
1122 .iter()
1123 .filter(|tool| tool.applies_to(platform))
1124 .map(|tool| {
1125 let mut state = ToolState {
1126 tool,
1127 enabled: tool.default_enabled,
1128 source: ToolSource::Default,
1129 };
1130 if let Some(enabled) = config.tools.get(tool.id) {
1131 state.enabled = *enabled;
1132 state.source = ToolSource::Host;
1133 }
1134 if let Some(enabled) = own.and_then(|own| own.tools.get(tool.id)) {
1135 state.enabled = *enabled;
1136 state.source = ToolSource::Target;
1137 }
1138 state
1139 })
1140 .collect();
1141 Selection {
1142 disabled,
1143 namespace,
1144 tools,
1145 }
1146}
1147
1148#[must_use]
1154fn tool_dir(tool: &CacheTool, namespace_dir: &Path, slot_dir: &Path) -> PathBuf {
1155 match tool.sharing {
1156 Sharing::Namespace => namespace_dir.join(tool.dir),
1157 Sharing::Slot => slot_dir.join(tool.dir),
1158 }
1159}
1160
1161fn render(variable: &CacheVariable, dir: &Path) -> (OsString, Option<PathBuf>) {
1163 match variable.value {
1164 VarValue::Dir => (dir.as_os_str().to_owned(), Some(dir.to_path_buf())),
1165 VarValue::Sub(sub) => {
1166 let path = dir.join(sub);
1167 (path.as_os_str().to_owned(), Some(path))
1168 }
1169 VarValue::Literal(value) => (OsString::from(value), None),
1170 VarValue::Template(template) => (
1171 OsString::from(template.replace("{dir}", &dir.to_string_lossy())),
1172 Some(dir.to_path_buf()),
1173 ),
1174 }
1175}
1176
1177#[must_use]
1179pub fn environment<'a>(
1180 tools: impl IntoIterator<Item = &'a CacheTool>,
1181 namespace_dir: &Path,
1182 slot_dir: &Path,
1183 platform: RunnerPlatform,
1184) -> (Vec<(&'static str, OsString)>, Vec<PathBuf>) {
1185 let mut variables = Vec::new();
1186 let mut dirs = Vec::new();
1187 for tool in tools {
1188 let dir = tool_dir(tool, namespace_dir, slot_dir);
1189 for variable in tool.variables_on(platform) {
1190 let (value, named) = render(variable, &dir);
1191 variables.push((variable.name, value));
1192 dirs.extend(named);
1193 }
1194 }
1195 (variables, dirs)
1196}
1197
1198impl Selection {
1199 pub fn defer_to_operator(&mut self, platform: RunnerPlatform, is_set: impl Fn(&str) -> bool) {
1209 for state in &mut self.tools {
1210 if state.enabled && state.tool.variables_on(platform).any(|v| is_set(v.name)) {
1211 state.enabled = false;
1212 state.source = ToolSource::Operator;
1213 }
1214 }
1215 }
1216}
1217
1218#[must_use]
1223pub fn inherited(name: &str) -> bool {
1224 std::env::vars_os().any(|(key, value)| {
1225 !value.is_empty()
1226 && key
1227 .to_str()
1228 .is_some_and(|key| key.eq_ignore_ascii_case(name))
1229 })
1230}
1231
1232#[must_use]
1240pub fn runtime_holds_runner(runtime: &Path) -> bool {
1241 match fs::metadata(runtime.join("bin")) {
1245 Ok(metadata) => metadata.is_dir(),
1246 Err(error) => error.kind() != io::ErrorKind::NotFound,
1247 }
1248}
1249
1250#[derive(Debug, Clone, PartialEq, Eq)]
1252pub struct LaunchCaches {
1253 pub namespace_dir: PathBuf,
1254 pub slot: u32,
1255 pub variables: Vec<(&'static str, OsString)>,
1256}
1257
1258fn lock_cache_root(root: &Path) -> Result<HostLock, CacheError> {
1259 HostLock::acquire_at(
1260 &root.join(LockKind::DependencyCache.file_name()),
1261 LockKind::DependencyCache,
1262 LOCK_WAIT,
1263 )
1264 .map_err(|error| match error {
1265 crate::lock::LockError::Io { path, source } => CacheError::Io {
1269 action: "lock",
1270 path,
1271 source,
1272 },
1273 other => CacheError::Lock(other.to_string()),
1274 })
1275}
1276
1277pub fn prepare_launch(
1286 root: &Path,
1287 selection: &Selection,
1288 runtime: &Path,
1289 platform: RunnerPlatform,
1290 holds_runner: &dyn Fn(&Path) -> bool,
1291) -> Result<LaunchCaches, CacheError> {
1292 fs::create_dir_all(root).map_err(io_error("create", root))?;
1293 let namespace_dir = selection.namespace.dir(root);
1294 let slots = namespace_dir.join(SLOTS_DIR);
1295 let slot = {
1296 let _lock = lock_cache_root(root)?;
1297 fs::create_dir_all(&slots).map_err(io_error("create", &slots))?;
1298 let slot = free_slot(&slots, runtime, holds_runner);
1299 let lease = slots.join(format!("{slot}.{LEASE_EXTENSION}"));
1300 fs::write(&lease, runtime.to_string_lossy().as_bytes())
1301 .map_err(io_error("write", &lease))?;
1302 let marker = namespace_dir.join(LAST_USED_FILE);
1303 fs::write(&marker, Utc::now().to_rfc3339().as_bytes())
1304 .map_err(io_error("write", &marker))?;
1305 slot
1306 };
1307 let slot_dir = slots.join(slot.to_string());
1308 let (variables, dirs) = environment(
1309 selection.enabled_tools(),
1310 &namespace_dir,
1311 &slot_dir,
1312 platform,
1313 );
1314 for dir in dirs {
1315 fs::create_dir_all(&dir).map_err(io_error("create", &dir))?;
1316 }
1317 Ok(LaunchCaches {
1318 namespace_dir,
1319 slot,
1320 variables,
1321 })
1322}
1323
1324fn free_slot(slots: &Path, runtime: &Path, holds_runner: &dyn Fn(&Path) -> bool) -> u32 {
1327 let own = runtime.to_string_lossy();
1328 (1..)
1329 .find(
1330 |slot| match fs::read_to_string(slots.join(format!("{slot}.{LEASE_EXTENSION}"))) {
1331 Err(error) => error.kind() == io::ErrorKind::NotFound,
1332 Ok(holder) => holder == own || !holds_runner(Path::new(&holder)),
1333 },
1334 )
1335 .expect("an unbounded range has a free slot")
1336}
1337
1338fn namespace_in_use(namespace_dir: &Path, holds_runner: &dyn Fn(&Path) -> bool) -> bool {
1340 let Ok(entries) = fs::read_dir(namespace_dir.join(SLOTS_DIR)) else {
1341 return false;
1342 };
1343 entries.flatten().any(|entry| {
1344 let path = entry.path();
1345 path.extension().is_some_and(|ext| ext == LEASE_EXTENSION)
1346 && fs::read_to_string(&path).map_or(true, |holder| holds_runner(Path::new(&holder)))
1347 })
1348}
1349
1350#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1356pub struct NamespaceUsage {
1357 pub name: String,
1359 pub bytes: u64,
1360 pub last_used: Option<DateTime<Utc>>,
1361 pub in_use: bool,
1362}
1363
1364#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1366pub struct CacheUsage {
1367 pub measured_at: DateTime<Utc>,
1368 pub total_bytes: u64,
1369 pub max_bytes: Option<u64>,
1370 pub namespaces: Vec<NamespaceUsage>,
1371 pub pruned: Vec<String>,
1373}
1374
1375impl CacheUsage {
1376 #[must_use]
1378 pub fn read(root: &Path) -> Option<Self> {
1379 let bytes = fs::read(root.join(USAGE_FILE)).ok()?;
1380 serde_json::from_slice(&bytes).ok()
1381 }
1382}
1383
1384fn tree_size(path: &Path) -> u64 {
1386 let mut total = 0_u64;
1387 let mut pending = vec![path.to_path_buf()];
1388 while let Some(dir) = pending.pop() {
1389 let Ok(entries) = fs::read_dir(&dir) else {
1390 continue;
1391 };
1392 for entry in entries.flatten() {
1393 let Ok(metadata) = entry.metadata() else {
1394 continue;
1395 };
1396 if metadata.is_dir() {
1397 pending.push(entry.path());
1398 } else if metadata.is_file() {
1399 total = total.saturating_add(metadata.len());
1400 }
1401 }
1402 }
1403 total
1404}
1405
1406fn modified(path: &Path) -> Option<SystemTime> {
1407 fs::metadata(path).and_then(|m| m.modified()).ok()
1408}
1409
1410fn namespace_dirs(root: &Path) -> Vec<(String, PathBuf)> {
1415 let mut found = Vec::new();
1416 let Ok(first) = fs::read_dir(root) else {
1417 return found;
1418 };
1419 for outer in first.flatten() {
1420 let outer_name = outer.file_name().to_string_lossy().into_owned();
1421 if outer_name.starts_with('.') || !outer.file_type().is_ok_and(|t| t.is_dir()) {
1422 continue;
1423 }
1424 let Ok(second) = fs::read_dir(outer.path()) else {
1425 continue;
1426 };
1427 for inner in second.flatten() {
1428 let path = inner.path();
1429 let marked = path.join(LAST_USED_FILE).is_file() || path.join(SLOTS_DIR).is_dir();
1430 if marked && inner.file_type().is_ok_and(|t| t.is_dir()) {
1431 let name = format!("{outer_name}/{}", inner.file_name().to_string_lossy());
1432 found.push((name, path));
1433 }
1434 }
1435 }
1436 found.sort();
1437 found
1438}
1439
1440#[must_use]
1442pub(crate) fn measure(root: &Path, holds_runner: &dyn Fn(&Path) -> bool) -> Vec<NamespaceUsage> {
1443 namespace_dirs(root)
1444 .into_iter()
1445 .map(|(name, dir)| NamespaceUsage {
1446 bytes: tree_size(&dir),
1447 last_used: modified(&dir.join(LAST_USED_FILE))
1448 .or_else(|| modified(&dir))
1449 .map(DateTime::<Utc>::from),
1450 in_use: namespace_in_use(&dir, holds_runner),
1451 name,
1452 })
1453 .collect()
1454}
1455
1456#[must_use]
1460pub(crate) fn plan_prune(namespaces: &[NamespaceUsage], max_bytes: Option<u64>) -> Vec<String> {
1461 let Some(max) = max_bytes else {
1462 return Vec::new();
1463 };
1464 let mut total: u64 = namespaces.iter().map(|ns| ns.bytes).sum();
1465 let mut idle: Vec<&NamespaceUsage> = namespaces.iter().filter(|ns| !ns.in_use).collect();
1466 idle.sort_by(|a, b| a.last_used.cmp(&b.last_used).then(a.name.cmp(&b.name)));
1468 let mut chosen = Vec::new();
1469 for namespace in idle {
1470 if total <= max {
1471 break;
1472 }
1473 total = total.saturating_sub(namespace.bytes);
1474 chosen.push(namespace.name.clone());
1475 }
1476 chosen
1477}
1478
1479pub fn prune(
1490 root: &Path,
1491 max_bytes: Option<u64>,
1492 holds_runner: &dyn Fn(&Path) -> bool,
1493) -> Result<CacheUsage, CacheError> {
1494 empty_trash(root);
1495 let mut namespaces = measure(root, holds_runner);
1496 let planned = plan_prune(&namespaces, max_bytes);
1497 let mut pruned = Vec::new();
1498 if !planned.is_empty() {
1499 let trash = root.join(TRASH_DIR);
1500 fs::create_dir_all(&trash).map_err(io_error("create", &trash))?;
1501 let _lock = lock_cache_root(root)?;
1502 for name in planned {
1503 let dir = root.join(&name);
1504 if namespace_in_use(&dir, holds_runner) {
1505 continue;
1506 }
1507 let aside = trash.join(uuid::Uuid::new_v4().simple().to_string());
1508 if fs::rename(&dir, &aside).is_ok() {
1509 pruned.push(name);
1510 }
1511 }
1512 }
1513 empty_trash(root);
1514 namespaces.retain(|ns| !pruned.contains(&ns.name));
1515 let usage = CacheUsage {
1516 measured_at: Utc::now(),
1517 total_bytes: namespaces.iter().map(|ns| ns.bytes).sum(),
1518 max_bytes,
1519 namespaces,
1520 pruned,
1521 };
1522 let json = serde_json::to_vec_pretty(&usage).map_err(|error| CacheError::Io {
1523 action: "encode",
1524 path: root.join(USAGE_FILE),
1525 source: io::Error::other(error),
1526 })?;
1527 write_atomically(&root.join(USAGE_FILE), &json)?;
1528 Ok(usage)
1529}
1530
1531fn empty_trash(root: &Path) {
1534 let Ok(entries) = fs::read_dir(root.join(TRASH_DIR)) else {
1535 return;
1536 };
1537 for entry in entries.flatten() {
1538 let _ = remove_tree(&entry.path());
1539 }
1540}
1541
1542fn remove_tree(path: &Path) -> io::Result<()> {
1549 #[cfg(windows)]
1550 {
1551 remove_dir_all::remove_dir_all(path)
1552 }
1553 #[cfg(not(windows))]
1554 {
1555 match fs::remove_dir_all(path) {
1556 Err(error) if error.kind() == io::ErrorKind::PermissionDenied => {
1557 open_directories(path);
1558 fs::remove_dir_all(path)
1559 }
1560 other => other,
1561 }
1562 }
1563}
1564
1565#[cfg(not(windows))]
1567fn open_directories(path: &Path) {
1568 use std::os::unix::fs::PermissionsExt as _;
1569 let mut pending = vec![path.to_path_buf()];
1570 while let Some(current) = pending.pop() {
1571 let Ok(metadata) = fs::symlink_metadata(¤t) else {
1572 continue;
1573 };
1574 if !metadata.is_dir() {
1575 continue;
1576 }
1577 let mode = metadata.permissions().mode();
1578 if mode & 0o700 != 0o700 {
1579 let _ = fs::set_permissions(¤t, fs::Permissions::from_mode(mode | 0o700));
1580 }
1581 if let Ok(entries) = fs::read_dir(¤t) {
1582 pending.extend(entries.flatten().map(|entry| entry.path()));
1583 }
1584 }
1585}
1586
1587#[must_use]
1589pub fn tool_ids() -> Vec<&'static str> {
1590 TOOLS.iter().map(|tool| tool.id).collect()
1591}
1592
1593#[must_use]
1595pub fn human_bytes(bytes: u64) -> String {
1596 #[allow(clippy::cast_precision_loss)]
1597 let value = bytes as f64;
1598 if bytes >= GIB {
1599 format!("{:.1} GiB", value / GIB as f64)
1600 } else if bytes >= 1024 * 1024 {
1601 format!("{:.0} MiB", value / (1024.0 * 1024.0))
1602 } else {
1603 format!("{:.0} KiB", value / 1024.0)
1604 }
1605}
1606
1607#[cfg(test)]
1608mod tests;