Skip to main content

ic_build/artifacts/
cache_fs.rs

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