1use fs2::FileExt as _;
2use std::{
3 fs::{self, File, OpenOptions},
4 io,
5 path::{Path, PathBuf},
6 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
7};
8
9use super::digest::write_atomic;
10
11const CACHE_DIRECTORY_TAG: &str = "Signature: 8a477f597d28d172789f06886806bc55\n\
12# This file is a cache directory tag created by ic-testkit.\n\
13# For information about cache directory tags see https://bford.info/cachedir/\n";
14pub(super) const CACHE_DIRECTORY_TAG_SIGNATURE: &str =
15 "Signature: 8a477f597d28d172789f06886806bc55\n";
16pub(super) const LAST_USED_FILE: &str = ".ic-testkit-last-used";
17const LAST_MAINTENANCE_FILE: &str = ".ic-testkit-last-maintenance";
18
19#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
25pub struct ArtifactCachePrunePolicy {
26 max_age: Option<Duration>,
27 max_size_bytes: Option<u64>,
28}
29
30#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
32pub struct ArtifactCachePruneReport {
33 entries_scanned: usize,
34 entries_removed: usize,
35 bytes_before: u64,
36 bytes_removed: u64,
37 uncommitted_directories_removed: usize,
38 uncommitted_bytes_removed: u64,
39}
40
41#[non_exhaustive]
43#[derive(Clone, Debug, Eq, PartialEq)]
44pub enum ArtifactCacheMaintenance {
45 Pruned(ArtifactCachePruneReport),
47 PruneFailed {
49 message: String,
51 },
52}
53
54impl ArtifactCachePrunePolicy {
55 #[must_use]
57 pub const fn new() -> Self {
58 Self {
59 max_age: None,
60 max_size_bytes: None,
61 }
62 }
63
64 #[must_use]
66 pub const fn with_max_age(mut self, max_age: Duration) -> Self {
67 self.max_age = Some(max_age);
68 self
69 }
70
71 #[must_use]
73 pub const fn with_max_size_bytes(mut self, bytes: u64) -> Self {
74 self.max_size_bytes = Some(bytes);
75 self
76 }
77
78 #[must_use]
80 pub const fn max_age(self) -> Option<Duration> {
81 self.max_age
82 }
83
84 #[must_use]
86 pub const fn max_size_bytes(self) -> Option<u64> {
87 self.max_size_bytes
88 }
89
90 pub(super) fn maintenance_identity(self) -> String {
91 format!(
92 "age={:?};size={:?}",
93 self.max_age.map(|duration| duration.as_nanos()),
94 self.max_size_bytes
95 )
96 }
97}
98
99impl ArtifactCachePruneReport {
100 #[must_use]
102 pub const fn entries_scanned(self) -> usize {
103 self.entries_scanned
104 }
105
106 #[must_use]
108 pub const fn entries_removed(self) -> usize {
109 self.entries_removed
110 }
111
112 #[must_use]
114 pub const fn entries_retained(self) -> usize {
115 self.entries_scanned.saturating_sub(self.entries_removed)
116 }
117
118 #[must_use]
120 pub const fn bytes_before(self) -> u64 {
121 self.bytes_before
122 }
123
124 #[must_use]
126 pub const fn bytes_removed(self) -> u64 {
127 self.bytes_removed
128 }
129
130 #[must_use]
132 pub const fn bytes_retained(self) -> u64 {
133 self.bytes_before.saturating_sub(self.bytes_removed)
134 }
135
136 #[must_use]
138 pub const fn uncommitted_directories_removed(self) -> usize {
139 self.uncommitted_directories_removed
140 }
141
142 #[must_use]
144 pub const fn uncommitted_bytes_removed(self) -> u64 {
145 self.uncommitted_bytes_removed
146 }
147
148 pub(super) const fn record_uncommitted_removal(&mut self, bytes: u64) {
149 self.uncommitted_directories_removed += 1;
150 self.uncommitted_bytes_removed = self.uncommitted_bytes_removed.saturating_add(bytes);
151 }
152}
153
154impl ArtifactCacheMaintenance {
155 #[must_use]
157 pub const fn prune_report(&self) -> Option<ArtifactCachePruneReport> {
158 match self {
159 Self::Pruned(report) => Some(*report),
160 Self::PruneFailed { .. } => None,
161 }
162 }
163
164 #[must_use]
166 pub fn failure_message(&self) -> Option<&str> {
167 match self {
168 Self::Pruned(_) => None,
169 Self::PruneFailed { message } => Some(message),
170 }
171 }
172}
173
174#[derive(Debug)]
175pub(super) struct CacheFsError {
176 pub(super) operation: &'static str,
177 pub(super) path: PathBuf,
178 pub(super) source: io::Error,
179}
180
181pub(super) fn ensure_cache_directory_tag(cache_root: &Path) -> Result<(), CacheFsError> {
182 let path = cache_root.join("CACHEDIR.TAG");
183 if fs::read_to_string(&path)
184 .is_ok_and(|contents| contents.starts_with(CACHE_DIRECTORY_TAG_SIGNATURE))
185 {
186 return Ok(());
187 }
188 write_atomic(&path, CACHE_DIRECTORY_TAG.as_bytes()).map_err(|source| CacheFsError {
189 operation: "write cache directory tag",
190 path,
191 source,
192 })
193}
194
195pub(super) fn lock_cache_file(path: &Path) -> Result<(File, Duration), CacheFsError> {
196 let file = open_cache_lock_file(path)?;
197 let started = Instant::now();
198 file.lock_exclusive().map_err(|source| CacheFsError {
199 operation: "lock cache",
200 path: path.to_owned(),
201 source,
202 })?;
203 Ok((file, started.elapsed()))
204}
205
206pub(super) fn try_lock_cache_file(path: &Path) -> Result<Option<File>, CacheFsError> {
207 let file = open_cache_lock_file(path)?;
208 match file.try_lock_exclusive() {
209 Ok(()) => Ok(Some(file)),
210 Err(error) if error.kind() == io::ErrorKind::WouldBlock => Ok(None),
211 Err(source) => Err(CacheFsError {
212 operation: "try lock cache",
213 path: path.to_owned(),
214 source,
215 }),
216 }
217}
218
219fn open_cache_lock_file(path: &Path) -> Result<File, CacheFsError> {
220 if let Some(parent) = path.parent() {
221 fs::create_dir_all(parent).map_err(|source| CacheFsError {
222 operation: "create cache lock directory",
223 path: parent.to_owned(),
224 source,
225 })?;
226 }
227 OpenOptions::new()
228 .create(true)
229 .read(true)
230 .write(true)
231 .truncate(false)
232 .open(path)
233 .map_err(|source| CacheFsError {
234 operation: "open cache lock",
235 path: path.to_owned(),
236 source,
237 })
238}
239
240pub(super) fn record_cache_entry_use(path: &Path) -> Result<(), CacheFsError> {
241 write_last_used(path, SystemTime::now())
242}
243
244pub(super) fn cache_maintenance_due(
245 path: &Path,
246 minimum_interval: Option<Duration>,
247 maintenance_identity: &str,
248) -> Result<bool, CacheFsError> {
249 let Some(minimum_interval) = minimum_interval else {
250 return Ok(true);
251 };
252 let marker = path.join(LAST_MAINTENANCE_FILE);
253 let contents = match fs::read_to_string(&marker) {
254 Ok(contents) => contents,
255 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(true),
256 Err(source) => {
257 return Err(CacheFsError {
258 operation: "read cache maintenance time",
259 path: marker,
260 source,
261 });
262 }
263 };
264 let mut lines = contents.lines();
265 let Some(last_maintenance) = lines.next().and_then(decode_system_time) else {
266 return Ok(true);
267 };
268 if lines.next() != Some(maintenance_identity) {
269 return Ok(true);
270 }
271 Ok(match SystemTime::now().duration_since(last_maintenance) {
272 Ok(elapsed) => elapsed >= minimum_interval,
273 Err(_) => true,
274 })
275}
276
277pub(super) fn record_cache_maintenance(
278 path: &Path,
279 maintenance_identity: &str,
280) -> Result<(), CacheFsError> {
281 fs::create_dir_all(path).map_err(|source| CacheFsError {
282 operation: "create cache maintenance directory",
283 path: path.to_owned(),
284 source,
285 })?;
286 let marker = path.join(LAST_MAINTENANCE_FILE);
287 let elapsed = encode_system_time(&marker, SystemTime::now())?;
288 let contents = format!("{}\n{maintenance_identity}\n", elapsed.as_nanos());
289 write_atomic(&marker, contents.as_bytes()).map_err(|source| CacheFsError {
290 operation: "record cache maintenance time",
291 path: marker,
292 source,
293 })
294}
295
296pub(super) fn perform_scheduled_cache_maintenance(
297 path: &Path,
298 minimum_interval: Option<Duration>,
299 maintenance_identity: &str,
300 maintenance: impl FnOnce() -> Result<ArtifactCachePruneReport, String>,
301) -> (Option<ArtifactCacheMaintenance>, Option<Duration>) {
302 let started = Instant::now();
303 match cache_maintenance_due(path, minimum_interval, maintenance_identity) {
304 Ok(false) => return (None, Some(started.elapsed())),
305 Ok(true) => {}
306 Err(error) => {
307 return (
308 Some(ArtifactCacheMaintenance::PruneFailed {
309 message: error.to_string(),
310 }),
311 Some(started.elapsed()),
312 );
313 }
314 }
315
316 let result = maintenance();
317 let marker = record_cache_maintenance(path, maintenance_identity);
318 let outcome = match (result, marker) {
319 (Ok(report), Ok(())) => ArtifactCacheMaintenance::Pruned(report),
320 (Err(message), Ok(())) => ArtifactCacheMaintenance::PruneFailed { message },
321 (Ok(_), Err(error)) => ArtifactCacheMaintenance::PruneFailed {
322 message: error.to_string(),
323 },
324 (Err(message), Err(marker)) => ArtifactCacheMaintenance::PruneFailed {
325 message: format!(
326 "{message}; additionally failed to record the maintenance attempt: {marker}"
327 ),
328 },
329 };
330 (Some(outcome), Some(started.elapsed()))
331}
332
333pub(super) fn write_last_used(path: &Path, last_used: SystemTime) -> Result<(), CacheFsError> {
334 let marker = path.join(LAST_USED_FILE);
335 write_system_time(&marker, last_used, "record cache use time")
336}
337
338fn write_system_time(
339 path: &Path,
340 timestamp: SystemTime,
341 operation: &'static str,
342) -> Result<(), CacheFsError> {
343 let elapsed = encode_system_time(path, timestamp)?;
344 write_atomic(path, elapsed.as_nanos().to_string().as_bytes()).map_err(|source| CacheFsError {
345 operation,
346 path: path.to_owned(),
347 source,
348 })
349}
350
351fn encode_system_time(path: &Path, timestamp: SystemTime) -> Result<Duration, CacheFsError> {
352 timestamp
353 .duration_since(UNIX_EPOCH)
354 .map_err(|source| CacheFsError {
355 operation: "encode cache time",
356 path: path.to_owned(),
357 source: io::Error::new(io::ErrorKind::InvalidInput, source),
358 })
359}
360
361fn decode_system_time(contents: &str) -> Option<SystemTime> {
362 let nanoseconds = contents.parse::<u128>().ok()?;
363 let seconds = u64::try_from(nanoseconds / 1_000_000_000).ok()?;
364 let subsecond_nanos = (nanoseconds % 1_000_000_000) as u32;
365 UNIX_EPOCH.checked_add(Duration::new(seconds, subsecond_nanos))
366}
367
368pub(super) fn prune_direct_child_directories(
369 cache_root: &Path,
370 policy: ArtifactCachePrunePolicy,
371 protected_entry: Option<&Path>,
372 is_eligible: impl Fn(&Path) -> bool,
373) -> Result<ArtifactCachePruneReport, CacheFsError> {
374 let mut entries = cache_entries(cache_root, is_eligible)?;
375 let bytes_before = entries
376 .iter()
377 .fold(0_u64, |total, entry| total.saturating_add(entry.bytes));
378 let mut report = ArtifactCachePruneReport {
379 entries_scanned: entries.len(),
380 entries_removed: 0,
381 bytes_before,
382 bytes_removed: 0,
383 uncommitted_directories_removed: 0,
384 uncommitted_bytes_removed: 0,
385 };
386 let now = SystemTime::now();
387
388 if let Some(max_age) = policy.max_age() {
389 for entry in &mut entries {
390 let age = now.duration_since(entry.last_used).unwrap_or_default();
391 if protected_entry != Some(entry.path.as_path()) && age > max_age {
392 remove_cache_entry(entry, &mut report)?;
393 }
394 }
395 }
396
397 if let Some(max_size_bytes) = policy.max_size_bytes() {
398 entries.sort_by(|left, right| {
399 left.last_used
400 .cmp(&right.last_used)
401 .then_with(|| left.path.cmp(&right.path))
402 });
403 for entry in &mut entries {
404 if report.bytes_retained() <= max_size_bytes {
405 break;
406 }
407 if protected_entry == Some(entry.path.as_path()) {
408 continue;
409 }
410 remove_cache_entry(entry, &mut report)?;
411 }
412 }
413
414 Ok(report)
415}
416
417pub(super) fn directory_logical_size(path: &Path) -> io::Result<u64> {
418 let mut total = 0_u64;
419 let mut pending = vec![path.to_owned()];
420 while let Some(current) = pending.pop() {
421 let metadata = fs::symlink_metadata(¤t)?;
422 if metadata.is_dir() {
423 for entry in fs::read_dir(¤t)? {
424 pending.push(entry?.path());
425 }
426 } else {
427 total = total.saturating_add(metadata.len());
428 }
429 }
430 Ok(total)
431}
432
433pub(super) fn is_sha256_directory(path: &Path) -> bool {
434 path.file_name().is_some_and(|name| {
435 let bytes = name.as_encoded_bytes();
436 bytes.len() == 64 && bytes.iter().all(u8::is_ascii_hexdigit)
437 })
438}
439
440pub(super) fn remove_path_if_present(path: &Path) -> io::Result<()> {
441 let metadata = match fs::symlink_metadata(path) {
442 Ok(metadata) => metadata,
443 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
444 Err(error) => return Err(error),
445 };
446 if metadata.file_type().is_dir() {
447 fs::remove_dir_all(path)
448 } else {
449 fs::remove_file(path)
450 }
451}
452
453struct CacheEntry {
454 path: PathBuf,
455 bytes: u64,
456 last_used: SystemTime,
457 removed: bool,
458}
459
460impl std::fmt::Display for CacheFsError {
461 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
462 write!(
463 formatter,
464 "failed to {} at {}: {}",
465 self.operation,
466 self.path.display(),
467 self.source
468 )
469 }
470}
471
472impl std::error::Error for CacheFsError {
473 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
474 Some(&self.source)
475 }
476}
477
478fn cache_entries(
479 cache_root: &Path,
480 is_eligible: impl Fn(&Path) -> bool,
481) -> Result<Vec<CacheEntry>, CacheFsError> {
482 let read_dir = match fs::read_dir(cache_root) {
483 Ok(read_dir) => read_dir,
484 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
485 Err(source) => {
486 return Err(CacheFsError {
487 operation: "read cache directory",
488 path: cache_root.to_owned(),
489 source,
490 });
491 }
492 };
493 let mut entries = Vec::new();
494 for directory_entry in read_dir {
495 let directory_entry = directory_entry.map_err(|source| CacheFsError {
496 operation: "read cache entry",
497 path: cache_root.to_owned(),
498 source,
499 })?;
500 let path = directory_entry.path();
501 let file_type = directory_entry.file_type().map_err(|source| CacheFsError {
502 operation: "inspect cache entry",
503 path: path.clone(),
504 source,
505 })?;
506 if !file_type.is_dir() || !is_eligible(&path) {
507 continue;
508 }
509 let bytes = directory_logical_size(&path).map_err(|source| CacheFsError {
510 operation: "measure cache entry",
511 path: path.clone(),
512 source,
513 })?;
514 let last_used = cache_entry_last_used(&path).map_err(|source| CacheFsError {
515 operation: "read cache use time",
516 path: path.clone(),
517 source,
518 })?;
519 entries.push(CacheEntry {
520 path,
521 bytes,
522 last_used,
523 removed: false,
524 });
525 }
526 Ok(entries)
527}
528
529pub(super) fn cache_entry_last_used(path: &Path) -> io::Result<SystemTime> {
530 let marker = path.join(LAST_USED_FILE);
531 if let Ok(contents) = fs::read_to_string(&marker)
532 && let Some(timestamp) = decode_system_time(&contents)
533 {
534 return Ok(timestamp);
535 }
536 fs::metadata(path)?.modified()
537}
538
539fn remove_cache_entry(
540 entry: &mut CacheEntry,
541 report: &mut ArtifactCachePruneReport,
542) -> Result<(), CacheFsError> {
543 if entry.removed {
544 return Ok(());
545 }
546 remove_path_if_present(&entry.path).map_err(|source| CacheFsError {
547 operation: "prune cache entry",
548 path: entry.path.clone(),
549 source,
550 })?;
551 entry.removed = true;
552 report.entries_removed += 1;
553 report.bytes_removed = report.bytes_removed.saturating_add(entry.bytes);
554 Ok(())
555}