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, usize::MAX)
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    let result = ic_host_fs::durable::open_regular_lock_file_with_parents(path)
344        .map_err(io::Error::from)
345        .and_then(|file| ic_host_fs::durable::try_lock_exclusive(&file).map(|()| file));
346    match result {
347        Ok(file) => Ok(Some(file)),
348        Err(error) if error.kind() == io::ErrorKind::WouldBlock => Ok(None),
349        Err(source) => Err(CacheFsError {
350            operation: "try lock cache",
351            path: path.to_owned(),
352            source,
353        }),
354    }
355}
356
357fn open_cache_lock_file(path: &Path) -> Result<File, CacheFsError> {
358    ic_host_fs::durable::open_regular_lock_file_with_parents(path)
359        .map_err(io::Error::from)
360        .map_err(|source| CacheFsError {
361            operation: "open cache lock",
362            path: path.to_owned(),
363            source,
364        })
365}
366
367pub(super) fn record_cache_entry_use(path: &Path) -> Result<(), CacheFsError> {
368    write_last_used(path, SystemTime::now())
369}
370
371pub(super) fn cache_maintenance_due(
372    path: &Path,
373    minimum_interval: Option<Duration>,
374    maintenance_identity: &str,
375) -> Result<bool, CacheFsError> {
376    let Some(minimum_interval) = minimum_interval else {
377        return Ok(true);
378    };
379    let marker = path.join(LAST_MAINTENANCE_FILE);
380    // Allow both LF and CRLF for the timestamp and policy-identity lines.
381    let maximum_len = MAX_TIMESTAMP_BYTES + maintenance_identity.len() + 4;
382    let contents = match read_stamp_with_limit(&marker, maximum_len) {
383        Ok(Some(contents)) => contents,
384        Ok(None) => return Ok(true),
385        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(true),
386        Err(source) => {
387            return Err(CacheFsError {
388                operation: "read cache maintenance time",
389                path: marker,
390                source,
391            });
392        }
393    };
394    let mut lines = contents.lines();
395    let Some(last_maintenance) = lines.next().and_then(decode_system_time) else {
396        return Ok(true);
397    };
398    if lines.next() != Some(maintenance_identity) {
399        return Ok(true);
400    }
401    Ok(SystemTime::now()
402        .duration_since(last_maintenance)
403        .map_or(true, |elapsed| elapsed >= minimum_interval))
404}
405
406pub(super) fn record_cache_maintenance(
407    path: &Path,
408    maintenance_identity: &str,
409) -> Result<(), CacheFsError> {
410    let marker = path.join(LAST_MAINTENANCE_FILE);
411    let elapsed = encode_system_time(&marker, SystemTime::now())?;
412    let contents = format!("{}\n{maintenance_identity}\n", elapsed.as_nanos());
413    ic_host_fs::durable::write_bytes(
414        &marker,
415        ic_host_fs::durable::WriteOptions {
416            mode: ic_host_fs::durable::PublicationMode::Replace,
417            permissions: 0o666,
418        },
419        contents.as_bytes(),
420    )
421    .map_err(|source| CacheFsError {
422        operation: "record cache maintenance time",
423        path: marker,
424        source: io::Error::other(source),
425    })
426}
427
428pub(super) fn perform_scheduled_cache_maintenance(
429    path: &Path,
430    minimum_interval: Option<Duration>,
431    maintenance_identity: &str,
432    maintenance: impl FnOnce() -> Result<ArtifactCachePruneReport, String>,
433) -> (Option<ArtifactCacheMaintenance>, Option<Duration>) {
434    let started = Instant::now();
435    match cache_maintenance_due(path, minimum_interval, maintenance_identity) {
436        Ok(false) => return (None, Some(started.elapsed())),
437        Ok(true) => {}
438        Err(error) => {
439            return (
440                Some(ArtifactCacheMaintenance::PruneFailed {
441                    message: error.to_string(),
442                }),
443                Some(started.elapsed()),
444            );
445        }
446    }
447
448    let result = maintenance();
449    let marker = record_cache_maintenance(path, maintenance_identity);
450    let outcome = match (result, marker) {
451        (Ok(report), Ok(())) => ArtifactCacheMaintenance::Pruned(report),
452        (Err(message), Ok(())) => ArtifactCacheMaintenance::PruneFailed { message },
453        (Ok(_), Err(error)) => ArtifactCacheMaintenance::PruneFailed {
454            message: error.to_string(),
455        },
456        (Err(message), Err(marker)) => ArtifactCacheMaintenance::PruneFailed {
457            message: format!(
458                "{message}; additionally failed to record the maintenance attempt: {marker}"
459            ),
460        },
461    };
462    (Some(outcome), Some(started.elapsed()))
463}
464
465pub(super) fn write_last_used(path: &Path, last_used: SystemTime) -> Result<(), CacheFsError> {
466    let marker = path.join(LAST_USED_FILE);
467    write_system_time(&marker, last_used, "record cache use time")
468}
469
470fn write_system_time(
471    path: &Path,
472    timestamp: SystemTime,
473    operation: &'static str,
474) -> Result<(), CacheFsError> {
475    let elapsed = encode_system_time(path, timestamp)?;
476    ic_host_fs::durable::write_bytes(
477        path,
478        ic_host_fs::durable::WriteOptions {
479            mode: ic_host_fs::durable::PublicationMode::Replace,
480            permissions: 0o666,
481        },
482        elapsed.as_nanos().to_string().as_bytes(),
483    )
484    .map_err(|source| CacheFsError {
485        operation,
486        path: path.to_owned(),
487        source: io::Error::other(source),
488    })
489}
490
491fn encode_system_time(path: &Path, timestamp: SystemTime) -> Result<Duration, CacheFsError> {
492    timestamp
493        .duration_since(UNIX_EPOCH)
494        .map_err(|source| CacheFsError {
495            operation: "encode cache time",
496            path: path.to_owned(),
497            source: io::Error::new(io::ErrorKind::InvalidInput, source),
498        })
499}
500
501fn decode_system_time(contents: &str) -> Option<SystemTime> {
502    let nanoseconds = contents.parse::<u128>().ok()?;
503    let seconds = u64::try_from(nanoseconds / 1_000_000_000).ok()?;
504    let subsecond_nanos = (nanoseconds % 1_000_000_000) as u32;
505    UNIX_EPOCH.checked_add(Duration::new(seconds, subsecond_nanos))
506}
507
508pub(super) fn prune_direct_child_directories(
509    cache_root: &Path,
510    policy: ArtifactCachePrunePolicy,
511    protected_entry: Option<&Path>,
512    is_eligible: impl Fn(&Path) -> bool,
513) -> Result<ArtifactCachePruneReport, CacheFsError> {
514    let mut entries = cache_entries(cache_root, is_eligible)?;
515    let bytes_before = entries
516        .iter()
517        .fold(0_u64, |total, entry| total.saturating_add(entry.bytes));
518    let mut report = ArtifactCachePruneReport {
519        entries_scanned: entries.len(),
520        entries_removed: 0,
521        bytes_before,
522        bytes_removed: 0,
523        uncommitted_directories_removed: 0,
524        uncommitted_bytes_removed: 0,
525    };
526    let now = SystemTime::now();
527
528    if let Some(max_age) = policy.max_age() {
529        for entry in &mut entries {
530            let age = now.duration_since(entry.last_used).unwrap_or_default();
531            if protected_entry != Some(entry.path.as_path()) && age > max_age {
532                remove_cache_entry(entry, &mut report)?;
533            }
534        }
535    }
536
537    if let Some(max_size_bytes) = policy.max_size_bytes()
538        && report.bytes_retained() > max_size_bytes
539    {
540        entries.sort_by(|left, right| {
541            left.last_used
542                .cmp(&right.last_used)
543                .then_with(|| left.path.cmp(&right.path))
544        });
545        for entry in &mut entries {
546            if report.bytes_retained() <= max_size_bytes {
547                break;
548            }
549            if protected_entry == Some(entry.path.as_path()) {
550                continue;
551            }
552            remove_cache_entry(entry, &mut report)?;
553        }
554    }
555
556    Ok(report)
557}
558
559pub(super) fn directory_logical_size(path: &Path) -> io::Result<u64> {
560    let mut total = 0_u64;
561    let mut pending = vec![path.to_owned()];
562    while let Some(current) = pending.pop() {
563        let metadata = fs::symlink_metadata(&current)?;
564        if metadata.is_dir() {
565            for entry in fs::read_dir(&current)? {
566                let path = entry?.path();
567                let metadata = fs::symlink_metadata(&path)?;
568                if metadata.is_dir() {
569                    pending.push(path);
570                } else {
571                    total = total.saturating_add(metadata.len());
572                }
573            }
574        } else {
575            total = total.saturating_add(metadata.len());
576        }
577    }
578    Ok(total)
579}
580
581pub(super) fn is_sha256_directory(path: &Path) -> bool {
582    path.file_name().is_some_and(|name| {
583        let bytes = name.as_encoded_bytes();
584        bytes.len() == 64 && bytes.iter().all(u8::is_ascii_hexdigit)
585    })
586}
587
588pub(super) fn remove_path_if_present(path: &Path) -> io::Result<()> {
589    let metadata = match fs::symlink_metadata(path) {
590        Ok(metadata) => metadata,
591        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
592        Err(error) => return Err(error),
593    };
594    if metadata.file_type().is_dir() {
595        fs::remove_dir_all(path)
596    } else {
597        fs::remove_file(path)
598    }
599}
600
601struct CacheEntry {
602    path: PathBuf,
603    bytes: u64,
604    last_used: SystemTime,
605    removed: bool,
606}
607
608impl std::fmt::Display for CacheFsError {
609    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
610        write!(
611            formatter,
612            "failed to {} at {}: {}",
613            self.operation,
614            self.path.display(),
615            self.source
616        )
617    }
618}
619
620impl std::error::Error for CacheFsError {
621    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
622        Some(&self.source)
623    }
624}
625
626fn cache_entries(
627    cache_root: &Path,
628    is_eligible: impl Fn(&Path) -> bool,
629) -> Result<Vec<CacheEntry>, CacheFsError> {
630    let read_dir = match fs::read_dir(cache_root) {
631        Ok(read_dir) => read_dir,
632        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
633        Err(source) => {
634            return Err(CacheFsError {
635                operation: "read cache directory",
636                path: cache_root.to_owned(),
637                source,
638            });
639        }
640    };
641    let mut entries = Vec::new();
642    for directory_entry in read_dir {
643        let directory_entry = directory_entry.map_err(|source| CacheFsError {
644            operation: "read cache entry",
645            path: cache_root.to_owned(),
646            source,
647        })?;
648        let path = directory_entry.path();
649        let file_type = directory_entry.file_type().map_err(|source| CacheFsError {
650            operation: "inspect cache entry",
651            path: path.clone(),
652            source,
653        })?;
654        if !file_type.is_dir() || !is_eligible(&path) {
655            continue;
656        }
657        let bytes = directory_logical_size(&path).map_err(|source| CacheFsError {
658            operation: "measure cache entry",
659            path: path.clone(),
660            source,
661        })?;
662        let last_used = cache_entry_last_used(&path).map_err(|source| CacheFsError {
663            operation: "read cache use time",
664            path: path.clone(),
665            source,
666        })?;
667        entries.push(CacheEntry {
668            path,
669            bytes,
670            last_used,
671            removed: false,
672        });
673    }
674    Ok(entries)
675}
676
677pub(super) fn cache_entry_last_used(path: &Path) -> io::Result<SystemTime> {
678    let marker = path.join(LAST_USED_FILE);
679    if let Ok(Some(contents)) = read_stamp_with_limit(&marker, MAX_TIMESTAMP_BYTES)
680        && let Some(timestamp) = decode_system_time(&contents)
681    {
682        return Ok(timestamp);
683    }
684    fs::metadata(path)?.modified()
685}
686
687fn remove_cache_entry(
688    entry: &mut CacheEntry,
689    report: &mut ArtifactCachePruneReport,
690) -> Result<(), CacheFsError> {
691    if entry.removed {
692        return Ok(());
693    }
694    let Some(_retention_lock) = try_lock_cache_file(&entry.path.join(RETENTION_LOCK_FILE))? else {
695        return Ok(());
696    };
697    remove_path_if_present(&entry.path).map_err(|source| CacheFsError {
698        operation: "prune cache entry",
699        path: entry.path.clone(),
700        source,
701    })?;
702    entry.removed = true;
703    report.entries_removed += 1;
704    report.bytes_removed = report.bytes_removed.saturating_add(entry.bytes);
705    Ok(())
706}
707
708#[cfg(test)]
709mod tests {
710    use super::directory_logical_size;
711    use crate::fs_fixture::unique_temp_directory;
712    use std::{
713        fs,
714        time::{Duration, SystemTime, UNIX_EPOCH},
715    };
716
717    #[cfg(unix)]
718    #[test]
719    fn cache_lock_admission_preserves_bytes_and_returns_an_unlocked_close_on_exec_file() {
720        use std::os::fd::AsRawFd as _;
721
722        let root = unique_temp_directory("cache-lock-admission");
723        let path = root.join("nested/lock");
724        let file = super::open_cache_lock_file(&path).unwrap();
725        assert!(super::try_lock_cache_file(&path).unwrap().is_some());
726        // SAFETY: inspect descriptor flags on the live file without changing them.
727        #[expect(
728            unsafe_code,
729            reason = "inspect flags of a live descriptor without changing them"
730        )]
731        let flags = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETFD) };
732        assert!(flags >= 0);
733        assert_ne!(flags & libc::FD_CLOEXEC, 0);
734        drop(file);
735        fs::write(&path, b"retained lock bytes").unwrap();
736        drop(super::lock_cache_file(&path).unwrap());
737        assert_eq!(fs::read(&path).unwrap(), b"retained lock bytes");
738        fs::remove_dir_all(root).unwrap();
739    }
740
741    #[cfg(unix)]
742    #[test]
743    fn cache_lock_callers_refuse_redirected_final_entries() {
744        use std::os::unix::fs::symlink;
745
746        let root = unique_temp_directory("cache-lock-redirected");
747        let target = root.join("target");
748        fs::write(&target, b"target bytes").unwrap();
749        let redirected = root.join("redirected");
750        symlink(&target, &redirected).unwrap();
751        for result in [
752            super::open_cache_lock_file(&redirected),
753            super::lock_cache_file(&redirected).map(|(file, _)| file),
754            super::lock_cache_file_with_wait_observer(
755                &redirected,
756                Duration::from_millis(5),
757                |_| panic!("redirected lock reached acquisition"),
758            )
759            .map(|(file, _)| file),
760        ] {
761            let error = result.unwrap_err();
762            assert_eq!(error.path, redirected);
763            assert!(matches!(
764                error.source.get_ref().and_then(|cause| cause
765                    .downcast_ref::<ic_host_fs::durable::RegularFileLockError>(
766                )),
767                Some(ic_host_fs::durable::RegularFileLockError::NotRegular)
768            ));
769        }
770        symlink(&target, root.join(super::RETENTION_LOCK_FILE)).unwrap();
771        assert!(super::RetainedCacheEntry::acquire(&root).is_err());
772        assert_eq!(fs::read(&target).unwrap(), b"target bytes");
773        fs::remove_dir_all(root).unwrap();
774    }
775
776    #[cfg(unix)]
777    #[test]
778    fn nonblocking_cache_locks_preserve_contention_and_reject_redirected_files() {
779        use std::{os::unix::fs::symlink, process::Command};
780        const CHILD_ENV: &str = "IC_TESTKIT_NONBLOCKING_CACHE_LOCK_CHILD";
781
782        if std::env::var_os(CHILD_ENV).is_none() {
783            // Parallel tests can fork with the lock descriptor open. CLOEXEC
784            // releases the child's copy only at exec, so isolate immediate
785            // reacquisition after dropping the parent's descriptor.
786            let thread = std::thread::current();
787            let test_name = thread.name().expect("libtest names each test thread");
788            let output = Command::new(std::env::current_exe().unwrap())
789                .args(["--exact", test_name, "--test-threads=1"])
790                .env(CHILD_ENV, "1")
791                .output()
792                .unwrap();
793            assert!(
794                output.status.success(),
795                "isolated nonblocking cache lock test failed: {}{}",
796                String::from_utf8_lossy(&output.stdout),
797                String::from_utf8_lossy(&output.stderr)
798            );
799            return;
800        }
801
802        let root = unique_temp_directory("nonblocking-cache-lock-admission");
803        let lock_path = root.join("lock");
804        fs::write(&lock_path, b"existing lock bytes").unwrap();
805        let held = super::try_lock_cache_file(&lock_path).unwrap().unwrap();
806        assert!(super::try_lock_cache_file(&lock_path).unwrap().is_none());
807        assert_eq!(fs::read(&lock_path).unwrap(), b"existing lock bytes");
808        drop(held);
809        assert!(super::try_lock_cache_file(&lock_path).unwrap().is_some());
810
811        let redirected = root.join("redirected-lock");
812        symlink(&lock_path, &redirected).unwrap();
813        let error = super::try_lock_cache_file(&redirected).unwrap_err();
814        assert_eq!(error.path, redirected);
815        assert!(matches!(
816                error.source.get_ref().and_then(|cause| cause
817                    .downcast_ref::<ic_host_fs::durable::RegularFileLockError>(
818                )),
819                Some(ic_host_fs::durable::RegularFileLockError::NotRegular)
820            ));
821        assert_eq!(fs::read(&lock_path).unwrap(), b"existing lock bytes");
822        fs::remove_dir_all(root).unwrap();
823    }
824
825    #[cfg(unix)]
826    #[test]
827    fn final_retention_owner_releases_lock_with_a_duplicate_descriptor_open() {
828        let root = unique_temp_directory("retention-duplicate-descriptor");
829        let retained = super::RetainedCacheEntry::acquire(&root).unwrap();
830        // A process spawn can duplicate this descriptor before close-on-exec.
831        #[expect(
832            clippy::used_underscore_binding,
833            reason = "inspect the RAII guard to verify inherited-descriptor retention"
834        )]
835        let inherited = retained._lock.0.try_clone().unwrap();
836        let clone = retained.clone();
837        let independently_retained = super::RetainedCacheEntry::acquire(&root).unwrap();
838        let lock_path = root.join(super::RETENTION_LOCK_FILE);
839        let available = || super::try_lock_cache_file(&lock_path).unwrap().is_some();
840        drop(retained);
841        assert!(!available(), "a record clone still retains the entry");
842        drop(clone);
843        assert!(
844            !available(),
845            "an independent acquisition still retains the entry"
846        );
847        drop(independently_retained);
848        assert!(
849            available(),
850            "descriptor duplication must not extend record ownership"
851        );
852        drop(inherited);
853        fs::remove_dir_all(root).unwrap();
854    }
855
856    #[test]
857    fn last_use_markers_preserve_timestamps_and_bounded_fallbacks() {
858        let root = unique_temp_directory("bounded-last-use-marker");
859        let marker = root.join(super::LAST_USED_FILE);
860        let modified = || fs::metadata(&root).unwrap().modified().unwrap();
861        assert_eq!(super::cache_entry_last_used(&root).unwrap(), modified());
862        for timestamp in [
863            UNIX_EPOCH + Duration::from_nanos(123),
864            SystemTime::now() + Duration::from_secs(3600),
865        ] {
866            super::write_last_used(&root, timestamp).unwrap();
867            assert_eq!(super::cache_entry_last_used(&root).unwrap(), timestamp);
868        }
869        let overflowing_timestamp = u128::MAX.to_string();
870        for invalid in [
871            b"invalid".as_slice(),
872            overflowing_timestamp.as_bytes(),
873            &[0xff],
874        ] {
875            fs::write(&marker, invalid).unwrap();
876            assert_eq!(super::cache_entry_last_used(&root).unwrap(), modified());
877        }
878        fs::File::create(&marker)
879            .unwrap()
880            .set_len(1024 * 1024 * 1024)
881            .unwrap();
882        assert_eq!(super::cache_entry_last_used(&root).unwrap(), modified());
883        fs::remove_file(&marker).unwrap();
884        fs::create_dir(&marker).unwrap();
885        assert_eq!(super::cache_entry_last_used(&root).unwrap(), modified());
886        fs::remove_dir_all(root).unwrap();
887    }
888
889    #[test]
890    fn maintenance_markers_preserve_policy_intervals_and_read_errors() {
891        let root = unique_temp_directory("bounded-maintenance-marker");
892        let marker = root.join(super::LAST_MAINTENANCE_FILE);
893        let identity = super::ArtifactCachePrunePolicy::new().maintenance_identity();
894        let interval = Some(Duration::from_secs(3600));
895        let due = || super::cache_maintenance_due(&root, interval, &identity).unwrap();
896        assert!(due());
897        super::record_cache_maintenance(&root, &identity).unwrap();
898        assert!(!due());
899        assert!(super::cache_maintenance_due(&root, interval, "other-policy").unwrap());
900        assert!(super::cache_maintenance_due(&root, Some(Duration::ZERO), &identity).unwrap());
901
902        let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
903        for suffix in ["", "\r\n"] {
904            fs::write(&marker, format!("{}\r\n{identity}{suffix}", now.as_nanos())).unwrap();
905            assert!(!due());
906        }
907        for timestamp in [
908            "invalid".to_owned(),
909            "0".to_owned(),
910            (now + Duration::from_secs(3600)).as_nanos().to_string(),
911        ] {
912            fs::write(&marker, format!("{timestamp}\n{identity}\n")).unwrap();
913            assert!(due());
914        }
915        super::record_cache_maintenance(&root, &identity).unwrap();
916        fs::OpenOptions::new()
917            .write(true)
918            .open(&marker)
919            .unwrap()
920            .set_len(1024 * 1024 * 1024)
921            .unwrap();
922        assert!(due());
923
924        fs::write(&marker, [0xff]).unwrap();
925        let error = super::cache_maintenance_due(&root, interval, &identity).unwrap_err();
926        assert_eq!(error.operation, "read cache maintenance time");
927        assert_eq!(error.path, marker);
928        assert_eq!(error.source.kind(), std::io::ErrorKind::InvalidData);
929        fs::remove_file(&marker).unwrap();
930        fs::create_dir(&marker).unwrap();
931        assert!(super::cache_maintenance_due(&root, interval, &identity).is_err());
932        assert!(super::cache_maintenance_due(&root, None, &identity).unwrap());
933        fs::remove_dir_all(root).unwrap();
934    }
935
936    #[test]
937    fn cache_directory_tags_preserve_valid_standard_signatures() {
938        let root = unique_temp_directory("cache-tag-signatures");
939        let tag = root.join("CACHEDIR.TAG");
940        let signature = "Signature: 8a477f597d28d172789f06886806bc55";
941        for contents in [
942            signature.to_owned(),
943            format!("{signature}\r\n# Created by another application\r\n"),
944            format!("{signature}\n# {}\n", "comment".repeat(100_000)),
945        ] {
946            fs::write(&tag, &contents).unwrap();
947            super::ensure_cache_directory_tag(&root).unwrap();
948            assert_eq!(fs::read_to_string(&tag).unwrap(), contents);
949        }
950        for invalid in ["", &signature[..42], "Signature: incorrect"] {
951            fs::write(&tag, invalid).unwrap();
952            super::ensure_cache_directory_tag(&root).unwrap();
953            assert_eq!(
954                fs::read_to_string(&tag).unwrap(),
955                super::CACHE_DIRECTORY_TAG
956            );
957        }
958        fs::remove_dir_all(root).unwrap();
959    }
960
961    #[test]
962    #[cfg(unix)]
963    fn cache_directory_tag_replaces_symlinks_without_changing_referents() {
964        let root = unique_temp_directory("cache-tag-symlink");
965        let referent = root.join("other-application-tag");
966        let contents = "Signature: 8a477f597d28d172789f06886806bc55\n# Preserve this file\n";
967        fs::write(&referent, contents).unwrap();
968        let tag = root.join("CACHEDIR.TAG");
969        std::os::unix::fs::symlink(&referent, &tag).unwrap();
970        super::ensure_cache_directory_tag(&root).unwrap();
971        assert!(fs::symlink_metadata(&tag).unwrap().file_type().is_file());
972        assert_eq!(fs::read_to_string(&referent).unwrap(), contents);
973        assert_eq!(
974            fs::read_to_string(&tag).unwrap(),
975            super::CACHE_DIRECTORY_TAG
976        );
977
978        fs::remove_file(&tag).unwrap();
979        fs::create_dir(&tag).unwrap();
980        let error = super::ensure_cache_directory_tag(&root).unwrap_err();
981        assert!(matches!(
982            error
983                .source
984                .get_ref()
985                .unwrap()
986                .downcast_ref::<ic_host_fs::durable::NamedWriteError<std::io::Error>>()
987                .unwrap(),
988            ic_host_fs::durable::NamedWriteError::BeforePublication {
989                cleanup_error: None,
990                ..
991            }
992        ));
993        assert_eq!(error.operation, "write cache directory tag");
994        assert!(tag.is_dir());
995        fs::remove_dir_all(root).unwrap();
996    }
997
998    #[test]
999    fn directory_size_sums_wide_and_nested_files() {
1000        let root = unique_temp_directory("directory-logical-size");
1001        assert_eq!(directory_logical_size(&root).unwrap(), 0);
1002        fs::create_dir_all(root.join("wide")).unwrap();
1003        fs::create_dir_all(root.join("nested/deep/empty")).unwrap();
1004        let mut expected = 0;
1005        for index in 0..128 {
1006            let bytes = vec![42; index % 13];
1007            fs::write(root.join("wide").join(index.to_string()), &bytes).unwrap();
1008            expected += bytes.len() as u64;
1009        }
1010        let sparse = root.join("nested/deep/sparse");
1011        fs::File::create(&sparse)
1012            .unwrap()
1013            .set_len(1024 * 1024)
1014            .unwrap();
1015        assert_eq!(directory_logical_size(&sparse).unwrap(), 1024 * 1024);
1016        assert_eq!(
1017            directory_logical_size(&root).unwrap(),
1018            expected + 1024 * 1024
1019        );
1020        assert_eq!(
1021            directory_logical_size(&root.join("missing"))
1022                .unwrap_err()
1023                .kind(),
1024            std::io::ErrorKind::NotFound,
1025        );
1026        fs::remove_dir_all(root).unwrap();
1027    }
1028
1029    #[test]
1030    #[cfg(unix)]
1031    fn directory_size_counts_symlinks_without_following_them() {
1032        let root = unique_temp_directory("directory-size-symlinks");
1033        let walked = root.join("walked");
1034        fs::create_dir_all(&walked).unwrap();
1035        fs::create_dir_all(root.join("external")).unwrap();
1036        fs::write(root.join("external/payload"), vec![42; 4096]).unwrap();
1037        fs::write(root.join("outside-file"), vec![42; 4096]).unwrap();
1038        fs::write(walked.join("payload"), b"abc").unwrap();
1039        let targets = ["../external", "../outside-file", "missing", "."];
1040        for (index, target) in targets.iter().enumerate() {
1041            std::os::unix::fs::symlink(target, walked.join(index.to_string())).unwrap();
1042        }
1043        let expected = 3 + targets
1044            .iter()
1045            .map(|target| target.len() as u64)
1046            .sum::<u64>();
1047        assert_eq!(directory_logical_size(&walked).unwrap(), expected);
1048        assert_eq!(
1049            directory_logical_size(&walked.join("0")).unwrap(),
1050            targets[0].len() as u64,
1051        );
1052        fs::remove_dir_all(root).unwrap();
1053    }
1054}