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