Skip to main content

ic_testkit/artifacts/
cache_fs.rs

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/// Caller-selected retention limits for content-addressed artifact entries.
20///
21/// Age pruning runs before size pruning. A policy without either limit scans
22/// the selected cache namespace and updates its cache metadata without
23/// removing entries.
24#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
25pub struct ArtifactCachePrunePolicy {
26    max_age: Option<Duration>,
27    max_size_bytes: Option<u64>,
28}
29
30/// Summary of one lock-coordinated artifact-cache pruning pass.
31#[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/// Nonfatal retention attempted as part of a successful cache acquisition.
42#[non_exhaustive]
43#[derive(Clone, Debug, Eq, PartialEq)]
44pub enum ArtifactCacheMaintenance {
45    /// Configured retention completed under the cache lock.
46    Pruned(ArtifactCachePruneReport),
47    /// Configured retention failed after the requested artifacts were ready.
48    PruneFailed {
49        /// Cache error rendered without invalidating the successful acquisition.
50        message: String,
51    },
52}
53
54impl ArtifactCachePrunePolicy {
55    /// Create a policy that records cache metadata without removing entries.
56    #[must_use]
57    pub const fn new() -> Self {
58        Self {
59            max_age: None,
60            max_size_bytes: None,
61        }
62    }
63
64    /// Remove entries older than `max_age` before applying the size limit.
65    #[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    /// Remove least-recently-used entries until retained logical size is at most `bytes`.
72    #[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    /// Configured maximum entry age, if any.
79    #[must_use]
80    pub const fn max_age(self) -> Option<Duration> {
81        self.max_age
82    }
83
84    /// Configured maximum logical cache size in bytes, if any.
85    #[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    /// Number of content-addressed directories considered for pruning.
101    #[must_use]
102    pub const fn entries_scanned(self) -> usize {
103        self.entries_scanned
104    }
105
106    /// Number of content-addressed directories removed.
107    #[must_use]
108    pub const fn entries_removed(self) -> usize {
109        self.entries_removed
110    }
111
112    /// Number of content-addressed directories retained.
113    #[must_use]
114    pub const fn entries_retained(self) -> usize {
115        self.entries_scanned.saturating_sub(self.entries_removed)
116    }
117
118    /// Logical bytes occupied by scanned entries before pruning.
119    #[must_use]
120    pub const fn bytes_before(self) -> u64 {
121        self.bytes_before
122    }
123
124    /// Logical bytes removed by pruning.
125    #[must_use]
126    pub const fn bytes_removed(self) -> u64 {
127        self.bytes_removed
128    }
129
130    /// Logical bytes occupied by retained entries after pruning.
131    #[must_use]
132    pub const fn bytes_retained(self) -> u64 {
133        self.bytes_before.saturating_sub(self.bytes_removed)
134    }
135
136    /// Abandoned transaction directories removed outside the committed-entry totals.
137    #[must_use]
138    pub const fn uncommitted_directories_removed(self) -> usize {
139        self.uncommitted_directories_removed
140    }
141
142    /// Logical bytes removed from abandoned transaction directories.
143    #[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    /// Successful pruning report, or `None` when maintenance failed.
156    #[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    /// Rendered maintenance failure, or `None` when pruning succeeded.
165    #[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(&current)?;
422        if metadata.is_dir() {
423            for entry in fs::read_dir(&current)? {
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}