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