Skip to main content

ic_build/artifacts/
cache_fs.rs

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