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