Skip to main content

ic_testkit/artifacts/
cache_fs.rs

1use fs2::FileExt as _;
2use std::{
3    fs::{self, File, OpenOptions},
4    io::{self, Read as _},
5    path::{Component, Path, PathBuf},
6    sync::Arc,
7    thread,
8    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
9};
10
11use super::digest::write_atomic;
12
13/// Resolve existing components through symlinks and normalize a missing suffix.
14/// Parent traversal can return from a missing suffix to existing components.
15pub(super) fn canonicalize_allow_missing(path: &Path) -> io::Result<PathBuf> {
16    let absolute = if path.is_absolute() {
17        path.to_owned()
18    } else {
19        std::env::current_dir()?.join(path)
20    };
21    let mut resolved = PathBuf::new();
22    let mut missing_depth = 0_usize;
23    for component in absolute.components() {
24        match component {
25            Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
26                let candidate = resolved.join(component.as_os_str());
27                if missing_depth == 0 && matches!(component, Component::Normal(_)) {
28                    match candidate.canonicalize() {
29                        Ok(canonical) => resolved = canonical,
30                        Err(error) if error.kind() == io::ErrorKind::NotFound => {
31                            resolved = candidate;
32                            missing_depth = 1;
33                        }
34                        Err(error) => return Err(error),
35                    }
36                } else {
37                    resolved = candidate;
38                    if matches!(component, Component::Normal(_)) && missing_depth > 0 {
39                        missing_depth += 1;
40                    }
41                }
42            }
43            Component::CurDir => {}
44            Component::ParentDir => {
45                resolved.pop();
46                missing_depth = missing_depth.saturating_sub(1);
47            }
48        }
49    }
50    Ok(resolved)
51}
52
53const CACHE_DIRECTORY_TAG: &str = "Signature: 8a477f597d28d172789f06886806bc55\n\
54# This file is a cache directory tag created by ic-testkit.\n\
55# For information about cache directory tags see https://bford.info/cachedir/\n";
56pub(super) const CACHE_DIRECTORY_TAG_SIGNATURE: &str =
57    "Signature: 8a477f597d28d172789f06886806bc55";
58pub(super) const LAST_USED_FILE: &str = ".ic-testkit-last-used";
59const LAST_MAINTENANCE_FILE: &str = ".ic-testkit-last-maintenance";
60pub(super) const RETENTION_LOCK_FILE: &str = ".ic-testkit-retention-v1";
61
62/// Acquired under the producer/namespace lock before handing an entry to a
63/// consumer. Clones share ownership; the OS releases locks on process exit.
64#[derive(Clone, Debug)]
65pub(super) struct RetainedCacheEntry {
66    path: PathBuf,
67    _lock: Arc<File>,
68}
69
70impl PartialEq for RetainedCacheEntry {
71    fn eq(&self, other: &Self) -> bool {
72        self.path == other.path
73    }
74}
75
76impl Eq for RetainedCacheEntry {}
77
78impl RetainedCacheEntry {
79    pub(super) fn path(&self) -> &Path {
80        &self.path
81    }
82
83    pub(super) fn acquire(path: &Path) -> Result<Self, CacheFsError> {
84        let file = open_cache_lock_file(&path.join(RETENTION_LOCK_FILE))?;
85        fs2::FileExt::lock_shared(&file).map_err(|source| CacheFsError {
86            operation: "retain cache entry",
87            path: path.to_owned(),
88            source,
89        })?;
90        Ok(Self {
91            path: path.to_owned(),
92            _lock: Arc::new(file),
93        })
94    }
95}
96
97/// The caller must hold the producer/namespace lock throughout this operation.
98pub(super) fn remove_unretained_entry(path: &Path) -> Result<(), CacheFsError> {
99    let metadata = match fs::symlink_metadata(path) {
100        Ok(metadata) => metadata,
101        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
102        Err(source) => {
103            return Err(CacheFsError {
104                operation: "inspect cache entry",
105                path: path.to_owned(),
106                source,
107            });
108        }
109    };
110    if !metadata.is_dir() {
111        return remove_path_if_present(path).map_err(|source| CacheFsError {
112            operation: "remove invalid cache entry",
113            path: path.to_owned(),
114            source,
115        });
116    }
117    let _lock =
118        try_lock_cache_file(&path.join(RETENTION_LOCK_FILE))?.ok_or_else(|| CacheFsError {
119            operation: "replace retained cache entry",
120            path: path.to_owned(),
121            source: io::Error::new(
122                io::ErrorKind::WouldBlock,
123                "cache entry is retained by a consumer",
124            ),
125        })?;
126    remove_path_if_present(path).map_err(|source| CacheFsError {
127        operation: "remove cache entry",
128        path: path.to_owned(),
129        source,
130    })
131}
132
133/// Caller-selected retention limits for content-addressed artifact entries.
134///
135/// Age pruning runs before size pruning. A policy without either limit scans
136/// the selected cache namespace and updates its cache metadata without
137/// removing entries. Entries retained by live acquisition records are skipped,
138/// even when this temporarily exceeds the limits. They become eligible for the
139/// next maintenance pass after their final owner drops or its process exits.
140#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
141pub struct ArtifactCachePrunePolicy {
142    max_age: Option<Duration>,
143    max_size_bytes: Option<u64>,
144}
145
146/// Summary of one lock-coordinated artifact-cache pruning pass.
147#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
148pub struct ArtifactCachePruneReport {
149    entries_scanned: usize,
150    entries_removed: usize,
151    bytes_before: u64,
152    bytes_removed: u64,
153    uncommitted_directories_removed: usize,
154    uncommitted_bytes_removed: u64,
155}
156
157/// Nonfatal retention attempted as part of a successful cache acquisition.
158#[non_exhaustive]
159#[derive(Clone, Debug, Eq, PartialEq)]
160pub enum ArtifactCacheMaintenance {
161    /// Configured retention completed under the cache lock.
162    Pruned(ArtifactCachePruneReport),
163    /// Configured retention failed after the requested artifacts were ready.
164    PruneFailed {
165        /// Cache error rendered without invalidating the successful acquisition.
166        message: String,
167    },
168}
169
170impl ArtifactCachePrunePolicy {
171    /// Create a policy that records cache metadata without removing entries.
172    #[must_use]
173    pub const fn new() -> Self {
174        Self {
175            max_age: None,
176            max_size_bytes: None,
177        }
178    }
179
180    /// Remove entries older than `max_age` before applying the size limit.
181    #[must_use]
182    pub const fn with_max_age(mut self, max_age: Duration) -> Self {
183        self.max_age = Some(max_age);
184        self
185    }
186
187    /// Remove least-recently-used entries until retained logical size is at most `bytes`.
188    #[must_use]
189    pub const fn with_max_size_bytes(mut self, bytes: u64) -> Self {
190        self.max_size_bytes = Some(bytes);
191        self
192    }
193
194    /// Configured maximum entry age, if any.
195    #[must_use]
196    pub const fn max_age(self) -> Option<Duration> {
197        self.max_age
198    }
199
200    /// Configured maximum logical cache size in bytes, if any.
201    #[must_use]
202    pub const fn max_size_bytes(self) -> Option<u64> {
203        self.max_size_bytes
204    }
205
206    pub(super) fn maintenance_identity(self) -> String {
207        format!(
208            "age={:?};size={:?}",
209            self.max_age.map(|duration| duration.as_nanos()),
210            self.max_size_bytes
211        )
212    }
213}
214
215impl ArtifactCachePruneReport {
216    /// Number of content-addressed directories considered for pruning.
217    #[must_use]
218    pub const fn entries_scanned(self) -> usize {
219        self.entries_scanned
220    }
221
222    /// Number of content-addressed directories removed.
223    #[must_use]
224    pub const fn entries_removed(self) -> usize {
225        self.entries_removed
226    }
227
228    /// Number of content-addressed directories retained.
229    #[must_use]
230    pub const fn entries_retained(self) -> usize {
231        self.entries_scanned.saturating_sub(self.entries_removed)
232    }
233
234    /// Logical bytes occupied by scanned entries before pruning.
235    #[must_use]
236    pub const fn bytes_before(self) -> u64 {
237        self.bytes_before
238    }
239
240    /// Logical bytes removed by pruning.
241    #[must_use]
242    pub const fn bytes_removed(self) -> u64 {
243        self.bytes_removed
244    }
245
246    /// Logical bytes occupied by retained entries after pruning.
247    #[must_use]
248    pub const fn bytes_retained(self) -> u64 {
249        self.bytes_before.saturating_sub(self.bytes_removed)
250    }
251
252    /// Abandoned transaction directories removed outside the committed-entry totals.
253    #[must_use]
254    pub const fn uncommitted_directories_removed(self) -> usize {
255        self.uncommitted_directories_removed
256    }
257
258    /// Logical bytes removed from abandoned transaction directories.
259    #[must_use]
260    pub const fn uncommitted_bytes_removed(self) -> u64 {
261        self.uncommitted_bytes_removed
262    }
263
264    pub(super) const fn record_uncommitted_removal(&mut self, bytes: u64) {
265        self.uncommitted_directories_removed += 1;
266        self.uncommitted_bytes_removed = self.uncommitted_bytes_removed.saturating_add(bytes);
267    }
268}
269
270impl ArtifactCacheMaintenance {
271    /// Successful pruning report, or `None` when maintenance failed.
272    #[must_use]
273    pub const fn prune_report(&self) -> Option<ArtifactCachePruneReport> {
274        match self {
275            Self::Pruned(report) => Some(*report),
276            Self::PruneFailed { .. } => None,
277        }
278    }
279
280    /// Rendered maintenance failure, or `None` when pruning succeeded.
281    #[must_use]
282    pub fn failure_message(&self) -> Option<&str> {
283        match self {
284            Self::Pruned(_) => None,
285            Self::PruneFailed { message } => Some(message),
286        }
287    }
288}
289
290#[derive(Debug)]
291pub(super) struct CacheFsError {
292    pub(super) operation: &'static str,
293    pub(super) path: PathBuf,
294    pub(super) source: io::Error,
295}
296
297pub(super) fn ensure_cache_directory_tag(cache_root: &Path) -> Result<(), CacheFsError> {
298    let path = cache_root.join("CACHEDIR.TAG");
299    // The standard recognizes the first 43 bytes, without requiring a newline
300    // or interpreting the remaining text. Symlinks are not valid tag files.
301    let mut signature = [0_u8; CACHE_DIRECTORY_TAG_SIGNATURE.len()];
302    if fs::symlink_metadata(&path).is_ok_and(|metadata| metadata.file_type().is_file())
303        && File::open(&path)
304            .and_then(|mut file| file.read_exact(&mut signature))
305            .is_ok()
306        && signature == CACHE_DIRECTORY_TAG_SIGNATURE.as_bytes()
307    {
308        return Ok(());
309    }
310    write_atomic(&path, CACHE_DIRECTORY_TAG.as_bytes()).map_err(|source| CacheFsError {
311        operation: "write cache directory tag",
312        path,
313        source,
314    })
315}
316
317pub(super) fn lock_cache_file(path: &Path) -> Result<(File, Duration), CacheFsError> {
318    let file = open_cache_lock_file(path)?;
319    let started = Instant::now();
320    file.lock_exclusive().map_err(|source| CacheFsError {
321        operation: "lock cache",
322        path: path.to_owned(),
323        source,
324    })?;
325    Ok((file, started.elapsed()))
326}
327
328pub(super) fn lock_cache_file_with_wait_observer(
329    path: &Path,
330    poll_interval: Duration,
331    mut observer: impl FnMut(Duration),
332) -> Result<(File, Duration), CacheFsError> {
333    let file = open_cache_lock_file(path)?;
334    let started = Instant::now();
335    loop {
336        match file.try_lock_exclusive() {
337            Ok(()) => return Ok((file, started.elapsed())),
338            Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
339                observer(started.elapsed());
340                thread::sleep(poll_interval.min(Duration::from_millis(25)));
341            }
342            Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
343            Err(source) => {
344                return Err(CacheFsError {
345                    operation: "try lock cache",
346                    path: path.to_owned(),
347                    source,
348                });
349            }
350        }
351    }
352}
353
354pub(super) fn try_lock_cache_file(path: &Path) -> Result<Option<File>, CacheFsError> {
355    let file = open_cache_lock_file(path)?;
356    match file.try_lock_exclusive() {
357        Ok(()) => Ok(Some(file)),
358        Err(error) if error.kind() == io::ErrorKind::WouldBlock => Ok(None),
359        Err(source) => Err(CacheFsError {
360            operation: "try lock cache",
361            path: path.to_owned(),
362            source,
363        }),
364    }
365}
366
367fn open_cache_lock_file(path: &Path) -> Result<File, CacheFsError> {
368    if let Some(parent) = path.parent() {
369        fs::create_dir_all(parent).map_err(|source| CacheFsError {
370            operation: "create cache lock directory",
371            path: parent.to_owned(),
372            source,
373        })?;
374    }
375    OpenOptions::new()
376        .create(true)
377        .read(true)
378        .write(true)
379        .truncate(false)
380        .open(path)
381        .map_err(|source| CacheFsError {
382            operation: "open cache lock",
383            path: path.to_owned(),
384            source,
385        })
386}
387
388pub(super) fn record_cache_entry_use(path: &Path) -> Result<(), CacheFsError> {
389    write_last_used(path, SystemTime::now())
390}
391
392pub(super) fn cache_maintenance_due(
393    path: &Path,
394    minimum_interval: Option<Duration>,
395    maintenance_identity: &str,
396) -> Result<bool, CacheFsError> {
397    let Some(minimum_interval) = minimum_interval else {
398        return Ok(true);
399    };
400    let marker = path.join(LAST_MAINTENANCE_FILE);
401    let contents = match fs::read_to_string(&marker) {
402        Ok(contents) => contents,
403        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(true),
404        Err(source) => {
405            return Err(CacheFsError {
406                operation: "read cache maintenance time",
407                path: marker,
408                source,
409            });
410        }
411    };
412    let mut lines = contents.lines();
413    let Some(last_maintenance) = lines.next().and_then(decode_system_time) else {
414        return Ok(true);
415    };
416    if lines.next() != Some(maintenance_identity) {
417        return Ok(true);
418    }
419    Ok(match SystemTime::now().duration_since(last_maintenance) {
420        Ok(elapsed) => elapsed >= minimum_interval,
421        Err(_) => true,
422    })
423}
424
425pub(super) fn record_cache_maintenance(
426    path: &Path,
427    maintenance_identity: &str,
428) -> Result<(), CacheFsError> {
429    let marker = path.join(LAST_MAINTENANCE_FILE);
430    let elapsed = encode_system_time(&marker, SystemTime::now())?;
431    let contents = format!("{}\n{maintenance_identity}\n", elapsed.as_nanos());
432    write_atomic(&marker, contents.as_bytes()).map_err(|source| CacheFsError {
433        operation: "record cache maintenance time",
434        path: marker,
435        source,
436    })
437}
438
439pub(super) fn perform_scheduled_cache_maintenance(
440    path: &Path,
441    minimum_interval: Option<Duration>,
442    maintenance_identity: &str,
443    maintenance: impl FnOnce() -> Result<ArtifactCachePruneReport, String>,
444) -> (Option<ArtifactCacheMaintenance>, Option<Duration>) {
445    let started = Instant::now();
446    match cache_maintenance_due(path, minimum_interval, maintenance_identity) {
447        Ok(false) => return (None, Some(started.elapsed())),
448        Ok(true) => {}
449        Err(error) => {
450            return (
451                Some(ArtifactCacheMaintenance::PruneFailed {
452                    message: error.to_string(),
453                }),
454                Some(started.elapsed()),
455            );
456        }
457    }
458
459    let result = maintenance();
460    let marker = record_cache_maintenance(path, maintenance_identity);
461    let outcome = match (result, marker) {
462        (Ok(report), Ok(())) => ArtifactCacheMaintenance::Pruned(report),
463        (Err(message), Ok(())) => ArtifactCacheMaintenance::PruneFailed { message },
464        (Ok(_), Err(error)) => ArtifactCacheMaintenance::PruneFailed {
465            message: error.to_string(),
466        },
467        (Err(message), Err(marker)) => ArtifactCacheMaintenance::PruneFailed {
468            message: format!(
469                "{message}; additionally failed to record the maintenance attempt: {marker}"
470            ),
471        },
472    };
473    (Some(outcome), Some(started.elapsed()))
474}
475
476pub(super) fn write_last_used(path: &Path, last_used: SystemTime) -> Result<(), CacheFsError> {
477    let marker = path.join(LAST_USED_FILE);
478    write_system_time(&marker, last_used, "record cache use time")
479}
480
481fn write_system_time(
482    path: &Path,
483    timestamp: SystemTime,
484    operation: &'static str,
485) -> Result<(), CacheFsError> {
486    let elapsed = encode_system_time(path, timestamp)?;
487    write_atomic(path, elapsed.as_nanos().to_string().as_bytes()).map_err(|source| CacheFsError {
488        operation,
489        path: path.to_owned(),
490        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: ArtifactCachePrunePolicy,
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        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
589pub(super) fn remove_path_if_present(path: &Path) -> io::Result<()> {
590    let metadata = match fs::symlink_metadata(path) {
591        Ok(metadata) => metadata,
592        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
593        Err(error) => return Err(error),
594    };
595    if metadata.file_type().is_dir() {
596        fs::remove_dir_all(path)
597    } else {
598        fs::remove_file(path)
599    }
600}
601
602struct CacheEntry {
603    path: PathBuf,
604    bytes: u64,
605    last_used: SystemTime,
606    removed: bool,
607}
608
609impl std::fmt::Display for CacheFsError {
610    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
611        write!(
612            formatter,
613            "failed to {} at {}: {}",
614            self.operation,
615            self.path.display(),
616            self.source
617        )
618    }
619}
620
621impl std::error::Error for CacheFsError {
622    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
623        Some(&self.source)
624    }
625}
626
627fn cache_entries(
628    cache_root: &Path,
629    is_eligible: impl Fn(&Path) -> bool,
630) -> Result<Vec<CacheEntry>, CacheFsError> {
631    let read_dir = match fs::read_dir(cache_root) {
632        Ok(read_dir) => read_dir,
633        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
634        Err(source) => {
635            return Err(CacheFsError {
636                operation: "read cache directory",
637                path: cache_root.to_owned(),
638                source,
639            });
640        }
641    };
642    let mut entries = Vec::new();
643    for directory_entry in read_dir {
644        let directory_entry = directory_entry.map_err(|source| CacheFsError {
645            operation: "read cache entry",
646            path: cache_root.to_owned(),
647            source,
648        })?;
649        let path = directory_entry.path();
650        let file_type = directory_entry.file_type().map_err(|source| CacheFsError {
651            operation: "inspect cache entry",
652            path: path.clone(),
653            source,
654        })?;
655        if !file_type.is_dir() || !is_eligible(&path) {
656            continue;
657        }
658        let bytes = directory_logical_size(&path).map_err(|source| CacheFsError {
659            operation: "measure cache entry",
660            path: path.clone(),
661            source,
662        })?;
663        let last_used = cache_entry_last_used(&path).map_err(|source| CacheFsError {
664            operation: "read cache use time",
665            path: path.clone(),
666            source,
667        })?;
668        entries.push(CacheEntry {
669            path,
670            bytes,
671            last_used,
672            removed: false,
673        });
674    }
675    Ok(entries)
676}
677
678pub(super) fn cache_entry_last_used(path: &Path) -> io::Result<SystemTime> {
679    let marker = path.join(LAST_USED_FILE);
680    if let Ok(contents) = fs::read_to_string(&marker)
681        && let Some(timestamp) = decode_system_time(&contents)
682    {
683        return Ok(timestamp);
684    }
685    fs::metadata(path)?.modified()
686}
687
688fn remove_cache_entry(
689    entry: &mut CacheEntry,
690    report: &mut ArtifactCachePruneReport,
691) -> Result<(), CacheFsError> {
692    if entry.removed {
693        return Ok(());
694    }
695    let Some(_retention_lock) = try_lock_cache_file(&entry.path.join(RETENTION_LOCK_FILE))? else {
696        return Ok(());
697    };
698    remove_path_if_present(&entry.path).map_err(|source| CacheFsError {
699        operation: "prune cache entry",
700        path: entry.path.clone(),
701        source,
702    })?;
703    entry.removed = true;
704    report.entries_removed += 1;
705    report.bytes_removed = report.bytes_removed.saturating_add(entry.bytes);
706    Ok(())
707}
708
709#[cfg(test)]
710mod tests {
711    #[cfg(unix)]
712    use super::canonicalize_allow_missing;
713    use super::directory_logical_size;
714    use crate::artifacts::test_support::unique_temp_directory;
715    use std::fs;
716
717    #[test]
718    fn cache_directory_tags_preserve_valid_standard_signatures() {
719        let root = unique_temp_directory("cache-tag-signatures");
720        let tag = root.join("CACHEDIR.TAG");
721        let signature = "Signature: 8a477f597d28d172789f06886806bc55";
722        for contents in [
723            signature.to_owned(),
724            format!("{signature}\r\n# Created by another application\r\n"),
725            format!("{signature}\n# {}\n", "comment".repeat(100_000)),
726        ] {
727            fs::write(&tag, &contents).unwrap();
728            super::ensure_cache_directory_tag(&root).unwrap();
729            assert_eq!(fs::read_to_string(&tag).unwrap(), contents);
730        }
731        for invalid in ["", &signature[..42], "Signature: incorrect"] {
732            fs::write(&tag, invalid).unwrap();
733            super::ensure_cache_directory_tag(&root).unwrap();
734            assert_eq!(
735                fs::read_to_string(&tag).unwrap(),
736                super::CACHE_DIRECTORY_TAG
737            );
738        }
739        fs::remove_dir_all(root).unwrap();
740    }
741
742    #[test]
743    #[cfg(unix)]
744    fn cache_directory_tag_replaces_symlinks_without_changing_referents() {
745        let root = unique_temp_directory("cache-tag-symlink");
746        let referent = root.join("other-application-tag");
747        let contents = "Signature: 8a477f597d28d172789f06886806bc55\n# Preserve this file\n";
748        fs::write(&referent, contents).unwrap();
749        let tag = root.join("CACHEDIR.TAG");
750        std::os::unix::fs::symlink(&referent, &tag).unwrap();
751        super::ensure_cache_directory_tag(&root).unwrap();
752        assert!(fs::symlink_metadata(&tag).unwrap().file_type().is_file());
753        assert_eq!(fs::read_to_string(&referent).unwrap(), contents);
754        assert_eq!(
755            fs::read_to_string(&tag).unwrap(),
756            super::CACHE_DIRECTORY_TAG
757        );
758
759        fs::remove_file(&tag).unwrap();
760        fs::create_dir(&tag).unwrap();
761        let error = super::ensure_cache_directory_tag(&root).unwrap_err();
762        assert_eq!(error.operation, "write cache directory tag");
763        assert!(tag.is_dir());
764        fs::remove_dir_all(root).unwrap();
765    }
766
767    #[test]
768    fn directory_size_sums_wide_and_nested_files() {
769        let root = unique_temp_directory("directory-logical-size");
770        assert_eq!(directory_logical_size(&root).unwrap(), 0);
771        fs::create_dir_all(root.join("wide")).unwrap();
772        fs::create_dir_all(root.join("nested/deep/empty")).unwrap();
773        let mut expected = 0;
774        for index in 0..128 {
775            let bytes = vec![42; index % 13];
776            fs::write(root.join("wide").join(index.to_string()), &bytes).unwrap();
777            expected += bytes.len() as u64;
778        }
779        let sparse = root.join("nested/deep/sparse");
780        fs::File::create(&sparse)
781            .unwrap()
782            .set_len(1024 * 1024)
783            .unwrap();
784        assert_eq!(directory_logical_size(&sparse).unwrap(), 1024 * 1024);
785        assert_eq!(
786            directory_logical_size(&root).unwrap(),
787            expected + 1024 * 1024
788        );
789        assert_eq!(
790            directory_logical_size(&root.join("missing"))
791                .unwrap_err()
792                .kind(),
793            std::io::ErrorKind::NotFound,
794        );
795        fs::remove_dir_all(root).unwrap();
796    }
797
798    #[test]
799    #[cfg(unix)]
800    fn directory_size_counts_symlinks_without_following_them() {
801        let root = unique_temp_directory("directory-size-symlinks");
802        let walked = root.join("walked");
803        fs::create_dir_all(&walked).unwrap();
804        fs::create_dir_all(root.join("external")).unwrap();
805        fs::write(root.join("external/payload"), vec![42; 4096]).unwrap();
806        fs::write(root.join("outside-file"), vec![42; 4096]).unwrap();
807        fs::write(walked.join("payload"), b"abc").unwrap();
808        let targets = ["../external", "../outside-file", "missing", "."];
809        for (index, target) in targets.iter().enumerate() {
810            std::os::unix::fs::symlink(target, walked.join(index.to_string())).unwrap();
811        }
812        let expected = 3 + targets
813            .iter()
814            .map(|target| target.len() as u64)
815            .sum::<u64>();
816        assert_eq!(directory_logical_size(&walked).unwrap(), expected);
817        assert_eq!(
818            directory_logical_size(&walked.join("0")).unwrap(),
819            targets[0].len() as u64,
820        );
821        fs::remove_dir_all(root).unwrap();
822    }
823
824    #[test]
825    #[cfg(unix)]
826    fn missing_parent_traversal_resumes_existing_symlink_resolution() {
827        let root = unique_temp_directory("canonical-missing-parent");
828        let target = root.join("real");
829        fs::create_dir_all(&target).unwrap();
830        std::os::unix::fs::symlink(&target, root.join("alias")).unwrap();
831        let path = root.join("missing/../alias/generated/nested/../output");
832        assert_eq!(
833            canonicalize_allow_missing(&path).unwrap(),
834            target.canonicalize().unwrap().join("generated/output")
835        );
836        assert_eq!(
837            canonicalize_allow_missing(&root.join("alias/../other/output")).unwrap(),
838            root.canonicalize().unwrap().join("other/output")
839        );
840        fs::remove_dir_all(root).unwrap();
841    }
842}