Skip to main content

ic_build/artifacts/
cache_fs.rs

1use std::{
2    fs::{self, File},
3    io::{self, Read as _},
4    path::{Path, PathBuf},
5    sync::Arc,
6    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
7};
8
9use super::digest::read_stamp_with_limit;
10
11/// Resolve existing components through symlinks and normalize a missing suffix.
12/// Parent traversal can return from a missing suffix to existing components.
13pub(super) fn canonicalize_allow_missing(path: &Path) -> io::Result<PathBuf> {
14    let base = if path.is_absolute() {
15        PathBuf::new()
16    } else {
17        std::env::current_dir()?
18    };
19    ic_host_fs::path::canonicalize_allow_missing(path, &base)
20}
21
22const CACHE_DIRECTORY_TAG: &str = "Signature: 8a477f597d28d172789f06886806bc55\n\
23# This file is a cache directory tag created by ic-testkit.\n\
24# For information about cache directory tags see https://bford.info/cachedir/\n";
25pub(super) const CACHE_DIRECTORY_TAG_SIGNATURE: &str =
26    "Signature: 8a477f597d28d172789f06886806bc55";
27pub(super) const LAST_USED_FILE: &str = ".ic-testkit-last-used";
28const LAST_MAINTENANCE_FILE: &str = ".ic-testkit-last-maintenance";
29// Nanoseconds are written as decimal u128 values, which need at most 39 bytes.
30const MAX_TIMESTAMP_BYTES: usize = 39;
31pub(super) const RETENTION_LOCK_FILE: &str = ".ic-testkit-retention-v1";
32
33/// Acquired under the producer/namespace lock before handing an entry to a
34/// consumer. Clones share ownership; the OS releases locks on process exit.
35#[derive(Clone, Debug)]
36pub(super) struct RetainedCacheEntry {
37    path: PathBuf,
38    _lock: Arc<RetentionLock>,
39}
40
41#[derive(Debug)]
42struct RetentionLock(File);
43
44impl Drop for RetentionLock {
45    fn drop(&mut self) {
46        // Closing alone can leave a flock held by a descriptor inherited during
47        // a concurrent spawn before exec. Release it when the final record owner
48        // drops, rather than waiting for unrelated child descriptors to close.
49        // If unlocking fails, closing the owned file remains the fallback.
50        let _ = ic_host_fs::durable::unlock(&self.0);
51    }
52}
53
54impl PartialEq for RetainedCacheEntry {
55    fn eq(&self, other: &Self) -> bool {
56        self.path == other.path
57    }
58}
59
60impl Eq for RetainedCacheEntry {}
61
62impl RetainedCacheEntry {
63    pub(super) fn path(&self) -> &Path {
64        &self.path
65    }
66
67    pub(super) fn acquire(path: &Path) -> Result<Self, CacheFsError> {
68        let file = open_cache_lock_file(&path.join(RETENTION_LOCK_FILE))?;
69        ic_host_fs::durable::lock_shared(&file).map_err(|source| CacheFsError {
70            operation: "retain cache entry",
71            path: path.to_owned(),
72            source,
73        })?;
74        Ok(Self {
75            path: path.to_owned(),
76            _lock: Arc::new(RetentionLock(file)),
77        })
78    }
79}
80
81/// The caller must hold the producer/namespace lock throughout this operation.
82pub(super) fn remove_unretained_entry(path: &Path) -> Result<(), CacheFsError> {
83    let metadata = match fs::symlink_metadata(path) {
84        Ok(metadata) => metadata,
85        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
86        Err(source) => {
87            return Err(CacheFsError {
88                operation: "inspect cache entry",
89                path: path.to_owned(),
90                source,
91            });
92        }
93    };
94    if !metadata.is_dir() {
95        return remove_path_if_present(path).map_err(|source| CacheFsError {
96            operation: "remove invalid cache entry",
97            path: path.to_owned(),
98            source,
99        });
100    }
101    let _lock =
102        try_lock_cache_file(&path.join(RETENTION_LOCK_FILE))?.ok_or_else(|| CacheFsError {
103            operation: "replace retained cache entry",
104            path: path.to_owned(),
105            source: io::Error::new(
106                io::ErrorKind::WouldBlock,
107                "cache entry is retained by a consumer",
108            ),
109        })?;
110    remove_path_if_present(path).map_err(|source| CacheFsError {
111        operation: "remove cache entry",
112        path: path.to_owned(),
113        source,
114    })
115}
116
117/// Caller-selected retention limits for content-addressed artifact entries.
118///
119/// Age pruning runs before size pruning. A policy without either limit scans
120/// the selected cache namespace and updates its cache metadata without
121/// removing entries. Entries retained by live acquisition records are skipped,
122/// even when this temporarily exceeds the limits. They become eligible for the
123/// next maintenance pass after their final owner drops or its process exits.
124#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
125pub struct ArtifactCachePrunePolicy {
126    max_age: Option<Duration>,
127    max_size_bytes: Option<u64>,
128}
129
130/// Summary of one lock-coordinated artifact-cache pruning pass.
131#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
132pub struct ArtifactCachePruneReport {
133    entries_scanned: usize,
134    entries_removed: usize,
135    bytes_before: u64,
136    bytes_removed: u64,
137    uncommitted_directories_removed: usize,
138    uncommitted_bytes_removed: u64,
139}
140
141/// Nonfatal retention attempted as part of a successful cache acquisition.
142#[non_exhaustive]
143#[derive(Clone, Debug, Eq, PartialEq)]
144pub enum ArtifactCacheMaintenance {
145    /// Configured retention completed under the cache lock.
146    Pruned(ArtifactCachePruneReport),
147    /// Configured retention failed after the requested artifacts were ready.
148    PruneFailed {
149        /// Cache error rendered without invalidating the successful acquisition.
150        message: String,
151    },
152}
153
154impl ArtifactCachePrunePolicy {
155    /// Create a policy that records cache metadata without removing entries.
156    #[must_use]
157    pub const fn new() -> Self {
158        Self {
159            max_age: None,
160            max_size_bytes: None,
161        }
162    }
163
164    /// Remove entries older than `max_age` before applying the size limit.
165    #[must_use]
166    pub const fn with_max_age(mut self, max_age: Duration) -> Self {
167        self.max_age = Some(max_age);
168        self
169    }
170
171    /// Remove least-recently-used entries until retained logical size is at most `bytes`.
172    #[must_use]
173    pub const fn with_max_size_bytes(mut self, bytes: u64) -> Self {
174        self.max_size_bytes = Some(bytes);
175        self
176    }
177
178    /// Configured maximum entry age, if any.
179    #[must_use]
180    pub const fn max_age(self) -> Option<Duration> {
181        self.max_age
182    }
183
184    /// Configured maximum logical cache size in bytes, if any.
185    #[must_use]
186    pub const fn max_size_bytes(self) -> Option<u64> {
187        self.max_size_bytes
188    }
189
190    pub(super) fn maintenance_identity(self) -> String {
191        format!(
192            "age={:?};size={:?}",
193            self.max_age.map(|duration| duration.as_nanos()),
194            self.max_size_bytes
195        )
196    }
197}
198
199impl ArtifactCachePruneReport {
200    /// Number of content-addressed directories considered for pruning.
201    #[must_use]
202    pub const fn entries_scanned(self) -> usize {
203        self.entries_scanned
204    }
205
206    /// Number of content-addressed directories removed.
207    #[must_use]
208    pub const fn entries_removed(self) -> usize {
209        self.entries_removed
210    }
211
212    /// Number of content-addressed directories retained.
213    #[must_use]
214    pub const fn entries_retained(self) -> usize {
215        self.entries_scanned.saturating_sub(self.entries_removed)
216    }
217
218    /// Logical bytes occupied by scanned entries before pruning.
219    #[must_use]
220    pub const fn bytes_before(self) -> u64 {
221        self.bytes_before
222    }
223
224    /// Logical bytes removed by pruning.
225    #[must_use]
226    pub const fn bytes_removed(self) -> u64 {
227        self.bytes_removed
228    }
229
230    /// Logical bytes occupied by retained entries after pruning.
231    #[must_use]
232    pub const fn bytes_retained(self) -> u64 {
233        self.bytes_before.saturating_sub(self.bytes_removed)
234    }
235
236    /// Abandoned transaction directories removed outside the committed-entry totals.
237    #[must_use]
238    pub const fn uncommitted_directories_removed(self) -> usize {
239        self.uncommitted_directories_removed
240    }
241
242    /// Logical bytes removed from abandoned transaction directories.
243    #[must_use]
244    pub const fn uncommitted_bytes_removed(self) -> u64 {
245        self.uncommitted_bytes_removed
246    }
247
248    pub(super) const fn record_uncommitted_removal(&mut self, bytes: u64) {
249        self.uncommitted_directories_removed += 1;
250        self.uncommitted_bytes_removed = self.uncommitted_bytes_removed.saturating_add(bytes);
251    }
252}
253
254impl ArtifactCacheMaintenance {
255    /// Successful pruning report, or `None` when maintenance failed.
256    #[must_use]
257    pub const fn prune_report(&self) -> Option<ArtifactCachePruneReport> {
258        match self {
259            Self::Pruned(report) => Some(*report),
260            Self::PruneFailed { .. } => None,
261        }
262    }
263
264    /// Rendered maintenance failure, or `None` when pruning succeeded.
265    #[must_use]
266    pub fn failure_message(&self) -> Option<&str> {
267        match self {
268            Self::Pruned(_) => None,
269            Self::PruneFailed { message } => Some(message),
270        }
271    }
272}
273
274#[derive(Debug)]
275pub(super) struct CacheFsError {
276    pub(super) operation: &'static str,
277    pub(super) path: PathBuf,
278    pub(super) source: io::Error,
279}
280
281pub(super) fn ensure_cache_directory_tag(cache_root: &Path) -> Result<(), CacheFsError> {
282    let path = cache_root.join("CACHEDIR.TAG");
283    // The standard recognizes the first 43 bytes, without requiring a newline
284    // or interpreting the remaining text. Symlinks are not valid tag files.
285    let mut signature = [0_u8; CACHE_DIRECTORY_TAG_SIGNATURE.len()];
286    if fs::symlink_metadata(&path).is_ok_and(|metadata| metadata.file_type().is_file())
287        && File::open(&path)
288            .and_then(|mut file| file.read_exact(&mut signature))
289            .is_ok()
290        && signature == CACHE_DIRECTORY_TAG_SIGNATURE.as_bytes()
291    {
292        return Ok(());
293    }
294    ic_host_fs::durable::write_bytes(
295        &path,
296        ic_host_fs::durable::WriteOptions {
297            mode: ic_host_fs::durable::PublicationMode::Replace,
298            permissions: 0o666,
299        },
300        CACHE_DIRECTORY_TAG.as_bytes(),
301    )
302    .map_err(|source| CacheFsError {
303        operation: "write cache directory tag",
304        path,
305        source: io::Error::other(source),
306    })
307}
308
309pub(super) fn lock_cache_file(path: &Path) -> Result<(File, Duration), CacheFsError> {
310    let file = open_cache_lock_file(path)?;
311    let started = Instant::now();
312    ic_host_fs::durable::lock_exclusive(&file).map_err(|source| CacheFsError {
313        operation: "lock cache",
314        path: path.to_owned(),
315        source,
316    })?;
317    Ok((file, started.elapsed()))
318}
319
320pub(super) fn lock_cache_file_with_wait_observer(
321    path: &Path,
322    poll_interval: Duration,
323    mut observer: impl FnMut(Duration),
324) -> Result<(File, Duration), CacheFsError> {
325    let file = open_cache_lock_file(path)?;
326    let wait = ic_host_fs::durable::lock_exclusive_with_wait(
327        &file,
328        poll_interval.min(Duration::from_millis(25)),
329        |elapsed| {
330            observer(elapsed);
331            Ok(())
332        },
333    )
334    .map_err(|source| CacheFsError {
335        operation: "try lock cache",
336        path: path.to_owned(),
337        source,
338    })?;
339    Ok((file, wait))
340}
341
342pub(super) fn try_lock_cache_file(path: &Path) -> Result<Option<File>, CacheFsError> {
343    match ic_host_fs::durable::try_lock_regular_file_with_parents(path).map_err(io::Error::from) {
344        Ok(file) => Ok(Some(file)),
345        Err(error) if error.kind() == io::ErrorKind::WouldBlock => Ok(None),
346        Err(source) => Err(CacheFsError {
347            operation: "try lock cache",
348            path: path.to_owned(),
349            source,
350        }),
351    }
352}
353
354fn open_cache_lock_file(path: &Path) -> Result<File, CacheFsError> {
355    ic_host_fs::durable::open_regular_lock_file_with_parents(path)
356        .map_err(io::Error::from)
357        .map_err(|source| CacheFsError {
358            operation: "open cache lock",
359            path: path.to_owned(),
360            source,
361        })
362}
363
364pub(super) fn record_cache_entry_use(path: &Path) -> Result<(), CacheFsError> {
365    write_last_used(path, SystemTime::now())
366}
367
368pub(super) fn cache_maintenance_due(
369    path: &Path,
370    minimum_interval: Option<Duration>,
371    maintenance_identity: &str,
372) -> Result<bool, CacheFsError> {
373    let Some(minimum_interval) = minimum_interval else {
374        return Ok(true);
375    };
376    let marker = path.join(LAST_MAINTENANCE_FILE);
377    // Allow both LF and CRLF for the timestamp and policy-identity lines.
378    let maximum_len = MAX_TIMESTAMP_BYTES + maintenance_identity.len() + 4;
379    let contents = match read_stamp_with_limit(&marker, maximum_len) {
380        Ok(Some(contents)) => contents,
381        Ok(None) => return Ok(true),
382        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(true),
383        Err(source) => {
384            return Err(CacheFsError {
385                operation: "read cache maintenance time",
386                path: marker,
387                source,
388            });
389        }
390    };
391    let mut lines = contents.lines();
392    let Some(last_maintenance) = lines.next().and_then(decode_system_time) else {
393        return Ok(true);
394    };
395    if lines.next() != Some(maintenance_identity) {
396        return Ok(true);
397    }
398    Ok(SystemTime::now()
399        .duration_since(last_maintenance)
400        .map_or(true, |elapsed| elapsed >= minimum_interval))
401}
402
403pub(super) fn record_cache_maintenance(
404    path: &Path,
405    maintenance_identity: &str,
406) -> Result<(), CacheFsError> {
407    let marker = path.join(LAST_MAINTENANCE_FILE);
408    let elapsed = encode_system_time(&marker, SystemTime::now())?;
409    let contents = format!("{}\n{maintenance_identity}\n", elapsed.as_nanos());
410    ic_host_fs::durable::write_bytes(
411        &marker,
412        ic_host_fs::durable::WriteOptions {
413            mode: ic_host_fs::durable::PublicationMode::Replace,
414            permissions: 0o666,
415        },
416        contents.as_bytes(),
417    )
418    .map_err(|source| CacheFsError {
419        operation: "record cache maintenance time",
420        path: marker,
421        source: io::Error::other(source),
422    })
423}
424
425pub(super) fn perform_scheduled_cache_maintenance(
426    path: &Path,
427    minimum_interval: Option<Duration>,
428    maintenance_identity: &str,
429    maintenance: impl FnOnce() -> Result<ArtifactCachePruneReport, String>,
430) -> (Option<ArtifactCacheMaintenance>, Option<Duration>) {
431    let started = Instant::now();
432    match cache_maintenance_due(path, minimum_interval, maintenance_identity) {
433        Ok(false) => return (None, Some(started.elapsed())),
434        Ok(true) => {}
435        Err(error) => {
436            return (
437                Some(ArtifactCacheMaintenance::PruneFailed {
438                    message: error.to_string(),
439                }),
440                Some(started.elapsed()),
441            );
442        }
443    }
444
445    let result = maintenance();
446    let marker = record_cache_maintenance(path, maintenance_identity);
447    let outcome = match (result, marker) {
448        (Ok(report), Ok(())) => ArtifactCacheMaintenance::Pruned(report),
449        (Err(message), Ok(())) => ArtifactCacheMaintenance::PruneFailed { message },
450        (Ok(_), Err(error)) => ArtifactCacheMaintenance::PruneFailed {
451            message: error.to_string(),
452        },
453        (Err(message), Err(marker)) => ArtifactCacheMaintenance::PruneFailed {
454            message: format!(
455                "{message}; additionally failed to record the maintenance attempt: {marker}"
456            ),
457        },
458    };
459    (Some(outcome), Some(started.elapsed()))
460}
461
462pub(super) fn write_last_used(path: &Path, last_used: SystemTime) -> Result<(), CacheFsError> {
463    let marker = path.join(LAST_USED_FILE);
464    write_system_time(&marker, last_used, "record cache use time")
465}
466
467fn write_system_time(
468    path: &Path,
469    timestamp: SystemTime,
470    operation: &'static str,
471) -> Result<(), CacheFsError> {
472    let elapsed = encode_system_time(path, timestamp)?;
473    ic_host_fs::durable::write_bytes(
474        path,
475        ic_host_fs::durable::WriteOptions {
476            mode: ic_host_fs::durable::PublicationMode::Replace,
477            permissions: 0o666,
478        },
479        elapsed.as_nanos().to_string().as_bytes(),
480    )
481    .map_err(|source| CacheFsError {
482        operation,
483        path: path.to_owned(),
484        source: io::Error::other(source),
485    })
486}
487
488fn encode_system_time(path: &Path, timestamp: SystemTime) -> Result<Duration, CacheFsError> {
489    timestamp
490        .duration_since(UNIX_EPOCH)
491        .map_err(|source| CacheFsError {
492            operation: "encode cache time",
493            path: path.to_owned(),
494            source: io::Error::new(io::ErrorKind::InvalidInput, source),
495        })
496}
497
498fn decode_system_time(contents: &str) -> Option<SystemTime> {
499    let nanoseconds = contents.parse::<u128>().ok()?;
500    let seconds = u64::try_from(nanoseconds / 1_000_000_000).ok()?;
501    let subsecond_nanos = (nanoseconds % 1_000_000_000) as u32;
502    UNIX_EPOCH.checked_add(Duration::new(seconds, subsecond_nanos))
503}
504
505pub(super) fn prune_direct_child_directories(
506    cache_root: &Path,
507    policy: ArtifactCachePrunePolicy,
508    protected_entry: Option<&Path>,
509    is_eligible: impl Fn(&Path) -> bool,
510) -> Result<ArtifactCachePruneReport, CacheFsError> {
511    let mut entries = cache_entries(cache_root, is_eligible)?;
512    let bytes_before = entries
513        .iter()
514        .fold(0_u64, |total, entry| total.saturating_add(entry.bytes));
515    let mut report = ArtifactCachePruneReport {
516        entries_scanned: entries.len(),
517        entries_removed: 0,
518        bytes_before,
519        bytes_removed: 0,
520        uncommitted_directories_removed: 0,
521        uncommitted_bytes_removed: 0,
522    };
523    let now = SystemTime::now();
524
525    if let Some(max_age) = policy.max_age() {
526        for entry in &mut entries {
527            let age = now.duration_since(entry.last_used).unwrap_or_default();
528            if protected_entry != Some(entry.path.as_path()) && age > max_age {
529                remove_cache_entry(entry, &mut report)?;
530            }
531        }
532    }
533
534    if let Some(max_size_bytes) = policy.max_size_bytes()
535        && report.bytes_retained() > max_size_bytes
536    {
537        entries.sort_by(|left, right| {
538            left.last_used
539                .cmp(&right.last_used)
540                .then_with(|| left.path.cmp(&right.path))
541        });
542        for entry in &mut entries {
543            if report.bytes_retained() <= max_size_bytes {
544                break;
545            }
546            if protected_entry == Some(entry.path.as_path()) {
547                continue;
548            }
549            remove_cache_entry(entry, &mut report)?;
550        }
551    }
552
553    Ok(report)
554}
555
556pub(super) fn directory_logical_size(path: &Path) -> io::Result<u64> {
557    let mut total = 0_u64;
558    let mut pending = vec![path.to_owned()];
559    while let Some(current) = pending.pop() {
560        let metadata = fs::symlink_metadata(&current)?;
561        if metadata.is_dir() {
562            for entry in fs::read_dir(&current)? {
563                let path = entry?.path();
564                let metadata = fs::symlink_metadata(&path)?;
565                if metadata.is_dir() {
566                    pending.push(path);
567                } else {
568                    total = total.saturating_add(metadata.len());
569                }
570            }
571        } else {
572            total = total.saturating_add(metadata.len());
573        }
574    }
575    Ok(total)
576}
577
578pub(super) fn is_sha256_directory(path: &Path) -> bool {
579    path.file_name().is_some_and(|name| {
580        let bytes = name.as_encoded_bytes();
581        bytes.len() == 64 && bytes.iter().all(u8::is_ascii_hexdigit)
582    })
583}
584
585pub(super) fn remove_path_if_present(path: &Path) -> io::Result<()> {
586    let metadata = match fs::symlink_metadata(path) {
587        Ok(metadata) => metadata,
588        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
589        Err(error) => return Err(error),
590    };
591    if metadata.file_type().is_dir() {
592        fs::remove_dir_all(path)
593    } else {
594        fs::remove_file(path)
595    }
596}
597
598struct CacheEntry {
599    path: PathBuf,
600    bytes: u64,
601    last_used: SystemTime,
602    removed: bool,
603}
604
605impl std::fmt::Display for CacheFsError {
606    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
607        write!(
608            formatter,
609            "failed to {} at {}: {}",
610            self.operation,
611            self.path.display(),
612            self.source
613        )
614    }
615}
616
617impl std::error::Error for CacheFsError {
618    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
619        Some(&self.source)
620    }
621}
622
623fn cache_entries(
624    cache_root: &Path,
625    is_eligible: impl Fn(&Path) -> bool,
626) -> Result<Vec<CacheEntry>, CacheFsError> {
627    let read_dir = match fs::read_dir(cache_root) {
628        Ok(read_dir) => read_dir,
629        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
630        Err(source) => {
631            return Err(CacheFsError {
632                operation: "read cache directory",
633                path: cache_root.to_owned(),
634                source,
635            });
636        }
637    };
638    let mut entries = Vec::new();
639    for directory_entry in read_dir {
640        let directory_entry = directory_entry.map_err(|source| CacheFsError {
641            operation: "read cache entry",
642            path: cache_root.to_owned(),
643            source,
644        })?;
645        let path = directory_entry.path();
646        let file_type = directory_entry.file_type().map_err(|source| CacheFsError {
647            operation: "inspect cache entry",
648            path: path.clone(),
649            source,
650        })?;
651        if !file_type.is_dir() || !is_eligible(&path) {
652            continue;
653        }
654        let bytes = directory_logical_size(&path).map_err(|source| CacheFsError {
655            operation: "measure cache entry",
656            path: path.clone(),
657            source,
658        })?;
659        let last_used = cache_entry_last_used(&path).map_err(|source| CacheFsError {
660            operation: "read cache use time",
661            path: path.clone(),
662            source,
663        })?;
664        entries.push(CacheEntry {
665            path,
666            bytes,
667            last_used,
668            removed: false,
669        });
670    }
671    Ok(entries)
672}
673
674pub(super) fn cache_entry_last_used(path: &Path) -> io::Result<SystemTime> {
675    let marker = path.join(LAST_USED_FILE);
676    if let Ok(Some(contents)) = read_stamp_with_limit(&marker, MAX_TIMESTAMP_BYTES)
677        && let Some(timestamp) = decode_system_time(&contents)
678    {
679        return Ok(timestamp);
680    }
681    fs::metadata(path)?.modified()
682}
683
684fn remove_cache_entry(
685    entry: &mut CacheEntry,
686    report: &mut ArtifactCachePruneReport,
687) -> Result<(), CacheFsError> {
688    if entry.removed {
689        return Ok(());
690    }
691    let Some(_retention_lock) = try_lock_cache_file(&entry.path.join(RETENTION_LOCK_FILE))? else {
692        return Ok(());
693    };
694    remove_path_if_present(&entry.path).map_err(|source| CacheFsError {
695        operation: "prune cache entry",
696        path: entry.path.clone(),
697        source,
698    })?;
699    entry.removed = true;
700    report.entries_removed += 1;
701    report.bytes_removed = report.bytes_removed.saturating_add(entry.bytes);
702    Ok(())
703}
704
705#[cfg(test)]
706mod tests {
707    use super::directory_logical_size;
708    use crate::fs_fixture::unique_temp_directory;
709    use std::{
710        fs,
711        time::{Duration, SystemTime, UNIX_EPOCH},
712    };
713
714    #[cfg(unix)]
715    #[test]
716    fn cache_lock_admission_preserves_bytes_and_returns_an_unlocked_close_on_exec_file() {
717        use std::os::fd::AsRawFd as _;
718
719        let root = unique_temp_directory("cache-lock-admission");
720        let path = root.join("nested/lock");
721        let file = super::open_cache_lock_file(&path).unwrap();
722        assert!(super::try_lock_cache_file(&path).unwrap().is_some());
723        // SAFETY: inspect descriptor flags on the live file without changing them.
724        #[expect(
725            unsafe_code,
726            reason = "inspect flags of a live descriptor without changing them"
727        )]
728        let flags = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETFD) };
729        assert!(flags >= 0);
730        assert_ne!(flags & libc::FD_CLOEXEC, 0);
731        drop(file);
732        fs::write(&path, b"retained lock bytes").unwrap();
733        drop(super::lock_cache_file(&path).unwrap());
734        assert_eq!(fs::read(&path).unwrap(), b"retained lock bytes");
735        fs::remove_dir_all(root).unwrap();
736    }
737
738    #[cfg(unix)]
739    #[test]
740    fn cache_lock_callers_refuse_redirected_final_entries() {
741        use std::os::unix::fs::symlink;
742
743        let root = unique_temp_directory("cache-lock-redirected");
744        let target = root.join("target");
745        fs::write(&target, b"target bytes").unwrap();
746        let redirected = root.join("redirected");
747        symlink(&target, &redirected).unwrap();
748        for result in [
749            super::open_cache_lock_file(&redirected),
750            super::lock_cache_file(&redirected).map(|(file, _)| file),
751            super::lock_cache_file_with_wait_observer(
752                &redirected,
753                Duration::from_millis(5),
754                |_| panic!("redirected lock reached acquisition"),
755            )
756            .map(|(file, _)| file),
757        ] {
758            let error = result.unwrap_err();
759            assert_eq!(error.path, redirected);
760            assert!(matches!(
761                error.source.get_ref().and_then(|cause| cause
762                    .downcast_ref::<ic_host_fs::durable::RegularFileLockError>(
763                )),
764                Some(ic_host_fs::durable::RegularFileLockError::NotRegular)
765            ));
766        }
767        symlink(&target, root.join(super::RETENTION_LOCK_FILE)).unwrap();
768        assert!(super::RetainedCacheEntry::acquire(&root).is_err());
769        assert_eq!(fs::read(&target).unwrap(), b"target bytes");
770        fs::remove_dir_all(root).unwrap();
771    }
772
773    #[cfg(unix)]
774    #[test]
775    fn nonblocking_cache_locks_preserve_contention_and_reject_redirected_files() {
776        use std::{os::unix::fs::symlink, process::Command};
777        const CHILD_ENV: &str = "IC_TESTKIT_NONBLOCKING_CACHE_LOCK_CHILD";
778
779        if std::env::var_os(CHILD_ENV).is_none() {
780            // Parallel tests can fork with the lock descriptor open. CLOEXEC
781            // releases the child's copy only at exec, so isolate immediate
782            // reacquisition after dropping the parent's descriptor.
783            let thread = std::thread::current();
784            let test_name = thread.name().expect("libtest names each test thread");
785            let output = Command::new(std::env::current_exe().unwrap())
786                .args(["--exact", test_name, "--test-threads=1"])
787                .env(CHILD_ENV, "1")
788                .output()
789                .unwrap();
790            assert!(
791                output.status.success(),
792                "isolated nonblocking cache lock test failed: {}{}",
793                String::from_utf8_lossy(&output.stdout),
794                String::from_utf8_lossy(&output.stderr)
795            );
796            return;
797        }
798
799        let root = unique_temp_directory("nonblocking-cache-lock-admission");
800        let lock_path = root.join("lock");
801        fs::write(&lock_path, b"existing lock bytes").unwrap();
802        let held = super::try_lock_cache_file(&lock_path).unwrap().unwrap();
803        assert!(super::try_lock_cache_file(&lock_path).unwrap().is_none());
804        assert_eq!(fs::read(&lock_path).unwrap(), b"existing lock bytes");
805        drop(held);
806        assert!(super::try_lock_cache_file(&lock_path).unwrap().is_some());
807
808        let redirected = root.join("redirected-lock");
809        symlink(&lock_path, &redirected).unwrap();
810        let error = super::try_lock_cache_file(&redirected).unwrap_err();
811        assert_eq!(error.path, redirected);
812        assert!(matches!(
813                error.source.get_ref().and_then(|cause| cause
814                    .downcast_ref::<ic_host_fs::durable::RegularFileLockError>(
815                )),
816                Some(ic_host_fs::durable::RegularFileLockError::NotRegular)
817            ));
818        assert_eq!(fs::read(&lock_path).unwrap(), b"existing lock bytes");
819        fs::remove_dir_all(root).unwrap();
820    }
821
822    #[cfg(unix)]
823    #[test]
824    fn final_retention_owner_releases_lock_with_a_duplicate_descriptor_open() {
825        let root = unique_temp_directory("retention-duplicate-descriptor");
826        let retained = super::RetainedCacheEntry::acquire(&root).unwrap();
827        // A process spawn can duplicate this descriptor before close-on-exec.
828        #[expect(
829            clippy::used_underscore_binding,
830            reason = "inspect the RAII guard to verify inherited-descriptor retention"
831        )]
832        let inherited = retained._lock.0.try_clone().unwrap();
833        let clone = retained.clone();
834        let independently_retained = super::RetainedCacheEntry::acquire(&root).unwrap();
835        let lock_path = root.join(super::RETENTION_LOCK_FILE);
836        let available = || super::try_lock_cache_file(&lock_path).unwrap().is_some();
837        drop(retained);
838        assert!(!available(), "a record clone still retains the entry");
839        drop(clone);
840        assert!(
841            !available(),
842            "an independent acquisition still retains the entry"
843        );
844        drop(independently_retained);
845        assert!(
846            available(),
847            "descriptor duplication must not extend record ownership"
848        );
849        drop(inherited);
850        fs::remove_dir_all(root).unwrap();
851    }
852
853    #[test]
854    fn last_use_markers_preserve_timestamps_and_bounded_fallbacks() {
855        let root = unique_temp_directory("bounded-last-use-marker");
856        let marker = root.join(super::LAST_USED_FILE);
857        let modified = || fs::metadata(&root).unwrap().modified().unwrap();
858        assert_eq!(super::cache_entry_last_used(&root).unwrap(), modified());
859        for timestamp in [
860            UNIX_EPOCH + Duration::from_nanos(123),
861            SystemTime::now() + Duration::from_secs(3600),
862        ] {
863            super::write_last_used(&root, timestamp).unwrap();
864            assert_eq!(super::cache_entry_last_used(&root).unwrap(), timestamp);
865        }
866        let overflowing_timestamp = u128::MAX.to_string();
867        for invalid in [
868            b"invalid".as_slice(),
869            overflowing_timestamp.as_bytes(),
870            &[0xff],
871        ] {
872            fs::write(&marker, invalid).unwrap();
873            assert_eq!(super::cache_entry_last_used(&root).unwrap(), modified());
874        }
875        fs::File::create(&marker)
876            .unwrap()
877            .set_len(1024 * 1024 * 1024)
878            .unwrap();
879        assert_eq!(super::cache_entry_last_used(&root).unwrap(), modified());
880        fs::remove_file(&marker).unwrap();
881        fs::create_dir(&marker).unwrap();
882        assert_eq!(super::cache_entry_last_used(&root).unwrap(), modified());
883        fs::remove_dir_all(root).unwrap();
884    }
885
886    #[test]
887    fn maintenance_markers_preserve_policy_intervals_and_read_errors() {
888        let root = unique_temp_directory("bounded-maintenance-marker");
889        let marker = root.join(super::LAST_MAINTENANCE_FILE);
890        let identity = super::ArtifactCachePrunePolicy::new().maintenance_identity();
891        let interval = Some(Duration::from_secs(3600));
892        let due = || super::cache_maintenance_due(&root, interval, &identity).unwrap();
893        assert!(due());
894        super::record_cache_maintenance(&root, &identity).unwrap();
895        assert!(!due());
896        assert!(super::cache_maintenance_due(&root, interval, "other-policy").unwrap());
897        assert!(super::cache_maintenance_due(&root, Some(Duration::ZERO), &identity).unwrap());
898
899        let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
900        for suffix in ["", "\r\n"] {
901            fs::write(&marker, format!("{}\r\n{identity}{suffix}", now.as_nanos())).unwrap();
902            assert!(!due());
903        }
904        for timestamp in [
905            "invalid".to_owned(),
906            "0".to_owned(),
907            (now + Duration::from_secs(3600)).as_nanos().to_string(),
908        ] {
909            fs::write(&marker, format!("{timestamp}\n{identity}\n")).unwrap();
910            assert!(due());
911        }
912        super::record_cache_maintenance(&root, &identity).unwrap();
913        fs::OpenOptions::new()
914            .write(true)
915            .open(&marker)
916            .unwrap()
917            .set_len(1024 * 1024 * 1024)
918            .unwrap();
919        assert!(due());
920
921        fs::write(&marker, [0xff]).unwrap();
922        let error = super::cache_maintenance_due(&root, interval, &identity).unwrap_err();
923        assert_eq!(error.operation, "read cache maintenance time");
924        assert_eq!(error.path, marker);
925        assert_eq!(error.source.kind(), std::io::ErrorKind::InvalidData);
926        fs::remove_file(&marker).unwrap();
927        fs::create_dir(&marker).unwrap();
928        assert!(super::cache_maintenance_due(&root, interval, &identity).is_err());
929        assert!(super::cache_maintenance_due(&root, None, &identity).unwrap());
930        fs::remove_dir_all(root).unwrap();
931    }
932
933    #[test]
934    fn cache_directory_tags_preserve_valid_standard_signatures() {
935        let root = unique_temp_directory("cache-tag-signatures");
936        let tag = root.join("CACHEDIR.TAG");
937        let signature = "Signature: 8a477f597d28d172789f06886806bc55";
938        for contents in [
939            signature.to_owned(),
940            format!("{signature}\r\n# Created by another application\r\n"),
941            format!("{signature}\n# {}\n", "comment".repeat(100_000)),
942        ] {
943            fs::write(&tag, &contents).unwrap();
944            super::ensure_cache_directory_tag(&root).unwrap();
945            assert_eq!(fs::read_to_string(&tag).unwrap(), contents);
946        }
947        for invalid in ["", &signature[..42], "Signature: incorrect"] {
948            fs::write(&tag, invalid).unwrap();
949            super::ensure_cache_directory_tag(&root).unwrap();
950            assert_eq!(
951                fs::read_to_string(&tag).unwrap(),
952                super::CACHE_DIRECTORY_TAG
953            );
954        }
955        fs::remove_dir_all(root).unwrap();
956    }
957
958    #[test]
959    #[cfg(unix)]
960    fn cache_directory_tag_replaces_symlinks_without_changing_referents() {
961        let root = unique_temp_directory("cache-tag-symlink");
962        let referent = root.join("other-application-tag");
963        let contents = "Signature: 8a477f597d28d172789f06886806bc55\n# Preserve this file\n";
964        fs::write(&referent, contents).unwrap();
965        let tag = root.join("CACHEDIR.TAG");
966        std::os::unix::fs::symlink(&referent, &tag).unwrap();
967        super::ensure_cache_directory_tag(&root).unwrap();
968        assert!(fs::symlink_metadata(&tag).unwrap().file_type().is_file());
969        assert_eq!(fs::read_to_string(&referent).unwrap(), contents);
970        assert_eq!(
971            fs::read_to_string(&tag).unwrap(),
972            super::CACHE_DIRECTORY_TAG
973        );
974
975        fs::remove_file(&tag).unwrap();
976        fs::create_dir(&tag).unwrap();
977        let error = super::ensure_cache_directory_tag(&root).unwrap_err();
978        assert!(matches!(
979            error
980                .source
981                .get_ref()
982                .unwrap()
983                .downcast_ref::<ic_host_fs::durable::NamedWriteError<std::io::Error>>()
984                .unwrap(),
985            ic_host_fs::durable::NamedWriteError::BeforePublication {
986                cleanup_error: None,
987                ..
988            }
989        ));
990        assert_eq!(error.operation, "write cache directory tag");
991        assert!(tag.is_dir());
992        fs::remove_dir_all(root).unwrap();
993    }
994
995    #[test]
996    fn directory_size_sums_wide_and_nested_files() {
997        let root = unique_temp_directory("directory-logical-size");
998        assert_eq!(directory_logical_size(&root).unwrap(), 0);
999        fs::create_dir_all(root.join("wide")).unwrap();
1000        fs::create_dir_all(root.join("nested/deep/empty")).unwrap();
1001        let mut expected = 0;
1002        for index in 0..128 {
1003            let bytes = vec![42; index % 13];
1004            fs::write(root.join("wide").join(index.to_string()), &bytes).unwrap();
1005            expected += bytes.len() as u64;
1006        }
1007        let sparse = root.join("nested/deep/sparse");
1008        fs::File::create(&sparse)
1009            .unwrap()
1010            .set_len(1024 * 1024)
1011            .unwrap();
1012        assert_eq!(directory_logical_size(&sparse).unwrap(), 1024 * 1024);
1013        assert_eq!(
1014            directory_logical_size(&root).unwrap(),
1015            expected + 1024 * 1024
1016        );
1017        assert_eq!(
1018            directory_logical_size(&root.join("missing"))
1019                .unwrap_err()
1020                .kind(),
1021            std::io::ErrorKind::NotFound,
1022        );
1023        fs::remove_dir_all(root).unwrap();
1024    }
1025
1026    #[test]
1027    #[cfg(unix)]
1028    fn directory_size_counts_symlinks_without_following_them() {
1029        let root = unique_temp_directory("directory-size-symlinks");
1030        let walked = root.join("walked");
1031        fs::create_dir_all(&walked).unwrap();
1032        fs::create_dir_all(root.join("external")).unwrap();
1033        fs::write(root.join("external/payload"), vec![42; 4096]).unwrap();
1034        fs::write(root.join("outside-file"), vec![42; 4096]).unwrap();
1035        fs::write(walked.join("payload"), b"abc").unwrap();
1036        let targets = ["../external", "../outside-file", "missing", "."];
1037        for (index, target) in targets.iter().enumerate() {
1038            std::os::unix::fs::symlink(target, walked.join(index.to_string())).unwrap();
1039        }
1040        let expected = 3 + targets
1041            .iter()
1042            .map(|target| target.len() as u64)
1043            .sum::<u64>();
1044        assert_eq!(directory_logical_size(&walked).unwrap(), expected);
1045        assert_eq!(
1046            directory_logical_size(&walked.join("0")).unwrap(),
1047            targets[0].len() as u64,
1048        );
1049        fs::remove_dir_all(root).unwrap();
1050    }
1051}