Skip to main content

ic_build/artifacts/
cache_fs.rs

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