Skip to main content

ic_testkit/artifacts/
cache_fs.rs

1use fs2::FileExt as _;
2use std::{
3    fs::{self, File, OpenOptions},
4    io,
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\n";
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 acquire(path: &Path) -> Result<Self, CacheFsError> {
80        let file = open_cache_lock_file(&path.join(RETENTION_LOCK_FILE))?;
81        fs2::FileExt::lock_shared(&file).map_err(|source| CacheFsError {
82            operation: "retain cache entry",
83            path: path.to_owned(),
84            source,
85        })?;
86        Ok(Self {
87            path: path.to_owned(),
88            _lock: Arc::new(file),
89        })
90    }
91}
92
93/// The caller must hold the producer/namespace lock throughout this operation.
94pub(super) fn remove_unretained_entry(path: &Path) -> Result<(), CacheFsError> {
95    let metadata = match fs::symlink_metadata(path) {
96        Ok(metadata) => metadata,
97        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
98        Err(source) => {
99            return Err(CacheFsError {
100                operation: "inspect cache entry",
101                path: path.to_owned(),
102                source,
103            });
104        }
105    };
106    if !metadata.is_dir() {
107        return remove_path_if_present(path).map_err(|source| CacheFsError {
108            operation: "remove invalid cache entry",
109            path: path.to_owned(),
110            source,
111        });
112    }
113    let _lock =
114        try_lock_cache_file(&path.join(RETENTION_LOCK_FILE))?.ok_or_else(|| CacheFsError {
115            operation: "replace retained cache entry",
116            path: path.to_owned(),
117            source: io::Error::new(
118                io::ErrorKind::WouldBlock,
119                "cache entry is retained by a consumer",
120            ),
121        })?;
122    remove_path_if_present(path).map_err(|source| CacheFsError {
123        operation: "remove cache entry",
124        path: path.to_owned(),
125        source,
126    })
127}
128
129/// Caller-selected retention limits for content-addressed artifact entries.
130///
131/// Age pruning runs before size pruning. A policy without either limit scans
132/// the selected cache namespace and updates its cache metadata without
133/// removing entries. Entries retained by live acquisition records are skipped,
134/// even when this temporarily exceeds the limits. They become eligible for the
135/// next maintenance pass after their final owner drops or its process exits.
136#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
137pub struct ArtifactCachePrunePolicy {
138    max_age: Option<Duration>,
139    max_size_bytes: Option<u64>,
140}
141
142/// Summary of one lock-coordinated artifact-cache pruning pass.
143#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
144pub struct ArtifactCachePruneReport {
145    entries_scanned: usize,
146    entries_removed: usize,
147    bytes_before: u64,
148    bytes_removed: u64,
149    uncommitted_directories_removed: usize,
150    uncommitted_bytes_removed: u64,
151}
152
153/// Nonfatal retention attempted as part of a successful cache acquisition.
154#[non_exhaustive]
155#[derive(Clone, Debug, Eq, PartialEq)]
156pub enum ArtifactCacheMaintenance {
157    /// Configured retention completed under the cache lock.
158    Pruned(ArtifactCachePruneReport),
159    /// Configured retention failed after the requested artifacts were ready.
160    PruneFailed {
161        /// Cache error rendered without invalidating the successful acquisition.
162        message: String,
163    },
164}
165
166impl ArtifactCachePrunePolicy {
167    /// Create a policy that records cache metadata without removing entries.
168    #[must_use]
169    pub const fn new() -> Self {
170        Self {
171            max_age: None,
172            max_size_bytes: None,
173        }
174    }
175
176    /// Remove entries older than `max_age` before applying the size limit.
177    #[must_use]
178    pub const fn with_max_age(mut self, max_age: Duration) -> Self {
179        self.max_age = Some(max_age);
180        self
181    }
182
183    /// Remove least-recently-used entries until retained logical size is at most `bytes`.
184    #[must_use]
185    pub const fn with_max_size_bytes(mut self, bytes: u64) -> Self {
186        self.max_size_bytes = Some(bytes);
187        self
188    }
189
190    /// Configured maximum entry age, if any.
191    #[must_use]
192    pub const fn max_age(self) -> Option<Duration> {
193        self.max_age
194    }
195
196    /// Configured maximum logical cache size in bytes, if any.
197    #[must_use]
198    pub const fn max_size_bytes(self) -> Option<u64> {
199        self.max_size_bytes
200    }
201
202    pub(super) fn maintenance_identity(self) -> String {
203        format!(
204            "age={:?};size={:?}",
205            self.max_age.map(|duration| duration.as_nanos()),
206            self.max_size_bytes
207        )
208    }
209}
210
211impl ArtifactCachePruneReport {
212    /// Number of content-addressed directories considered for pruning.
213    #[must_use]
214    pub const fn entries_scanned(self) -> usize {
215        self.entries_scanned
216    }
217
218    /// Number of content-addressed directories removed.
219    #[must_use]
220    pub const fn entries_removed(self) -> usize {
221        self.entries_removed
222    }
223
224    /// Number of content-addressed directories retained.
225    #[must_use]
226    pub const fn entries_retained(self) -> usize {
227        self.entries_scanned.saturating_sub(self.entries_removed)
228    }
229
230    /// Logical bytes occupied by scanned entries before pruning.
231    #[must_use]
232    pub const fn bytes_before(self) -> u64 {
233        self.bytes_before
234    }
235
236    /// Logical bytes removed by pruning.
237    #[must_use]
238    pub const fn bytes_removed(self) -> u64 {
239        self.bytes_removed
240    }
241
242    /// Logical bytes occupied by retained entries after pruning.
243    #[must_use]
244    pub const fn bytes_retained(self) -> u64 {
245        self.bytes_before.saturating_sub(self.bytes_removed)
246    }
247
248    /// Abandoned transaction directories removed outside the committed-entry totals.
249    #[must_use]
250    pub const fn uncommitted_directories_removed(self) -> usize {
251        self.uncommitted_directories_removed
252    }
253
254    /// Logical bytes removed from abandoned transaction directories.
255    #[must_use]
256    pub const fn uncommitted_bytes_removed(self) -> u64 {
257        self.uncommitted_bytes_removed
258    }
259
260    pub(super) const fn record_uncommitted_removal(&mut self, bytes: u64) {
261        self.uncommitted_directories_removed += 1;
262        self.uncommitted_bytes_removed = self.uncommitted_bytes_removed.saturating_add(bytes);
263    }
264}
265
266impl ArtifactCacheMaintenance {
267    /// Successful pruning report, or `None` when maintenance failed.
268    #[must_use]
269    pub const fn prune_report(&self) -> Option<ArtifactCachePruneReport> {
270        match self {
271            Self::Pruned(report) => Some(*report),
272            Self::PruneFailed { .. } => None,
273        }
274    }
275
276    /// Rendered maintenance failure, or `None` when pruning succeeded.
277    #[must_use]
278    pub fn failure_message(&self) -> Option<&str> {
279        match self {
280            Self::Pruned(_) => None,
281            Self::PruneFailed { message } => Some(message),
282        }
283    }
284}
285
286#[derive(Debug)]
287pub(super) struct CacheFsError {
288    pub(super) operation: &'static str,
289    pub(super) path: PathBuf,
290    pub(super) source: io::Error,
291}
292
293pub(super) fn ensure_cache_directory_tag(cache_root: &Path) -> Result<(), CacheFsError> {
294    let path = cache_root.join("CACHEDIR.TAG");
295    if fs::read_to_string(&path)
296        .is_ok_and(|contents| contents.starts_with(CACHE_DIRECTORY_TAG_SIGNATURE))
297    {
298        return Ok(());
299    }
300    write_atomic(&path, CACHE_DIRECTORY_TAG.as_bytes()).map_err(|source| CacheFsError {
301        operation: "write cache directory tag",
302        path,
303        source,
304    })
305}
306
307pub(super) fn lock_cache_file(path: &Path) -> Result<(File, Duration), CacheFsError> {
308    let file = open_cache_lock_file(path)?;
309    let started = Instant::now();
310    file.lock_exclusive().map_err(|source| CacheFsError {
311        operation: "lock cache",
312        path: path.to_owned(),
313        source,
314    })?;
315    Ok((file, started.elapsed()))
316}
317
318pub(super) fn lock_cache_file_with_wait_observer(
319    path: &Path,
320    poll_interval: Duration,
321    mut observer: impl FnMut(Duration),
322) -> Result<(File, Duration), CacheFsError> {
323    let file = open_cache_lock_file(path)?;
324    let started = Instant::now();
325    loop {
326        match file.try_lock_exclusive() {
327            Ok(()) => return Ok((file, started.elapsed())),
328            Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
329                observer(started.elapsed());
330                thread::sleep(poll_interval.min(Duration::from_millis(25)));
331            }
332            Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
333            Err(source) => {
334                return Err(CacheFsError {
335                    operation: "try lock cache",
336                    path: path.to_owned(),
337                    source,
338                });
339            }
340        }
341    }
342}
343
344pub(super) fn try_lock_cache_file(path: &Path) -> Result<Option<File>, CacheFsError> {
345    let file = open_cache_lock_file(path)?;
346    match file.try_lock_exclusive() {
347        Ok(()) => Ok(Some(file)),
348        Err(error) if error.kind() == io::ErrorKind::WouldBlock => Ok(None),
349        Err(source) => Err(CacheFsError {
350            operation: "try lock cache",
351            path: path.to_owned(),
352            source,
353        }),
354    }
355}
356
357fn open_cache_lock_file(path: &Path) -> Result<File, CacheFsError> {
358    if let Some(parent) = path.parent() {
359        fs::create_dir_all(parent).map_err(|source| CacheFsError {
360            operation: "create cache lock directory",
361            path: parent.to_owned(),
362            source,
363        })?;
364    }
365    OpenOptions::new()
366        .create(true)
367        .read(true)
368        .write(true)
369        .truncate(false)
370        .open(path)
371        .map_err(|source| CacheFsError {
372            operation: "open cache lock",
373            path: path.to_owned(),
374            source,
375        })
376}
377
378pub(super) fn record_cache_entry_use(path: &Path) -> Result<(), CacheFsError> {
379    write_last_used(path, SystemTime::now())
380}
381
382pub(super) fn cache_maintenance_due(
383    path: &Path,
384    minimum_interval: Option<Duration>,
385    maintenance_identity: &str,
386) -> Result<bool, CacheFsError> {
387    let Some(minimum_interval) = minimum_interval else {
388        return Ok(true);
389    };
390    let marker = path.join(LAST_MAINTENANCE_FILE);
391    let contents = match fs::read_to_string(&marker) {
392        Ok(contents) => contents,
393        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(true),
394        Err(source) => {
395            return Err(CacheFsError {
396                operation: "read cache maintenance time",
397                path: marker,
398                source,
399            });
400        }
401    };
402    let mut lines = contents.lines();
403    let Some(last_maintenance) = lines.next().and_then(decode_system_time) else {
404        return Ok(true);
405    };
406    if lines.next() != Some(maintenance_identity) {
407        return Ok(true);
408    }
409    Ok(match SystemTime::now().duration_since(last_maintenance) {
410        Ok(elapsed) => elapsed >= minimum_interval,
411        Err(_) => true,
412    })
413}
414
415pub(super) fn record_cache_maintenance(
416    path: &Path,
417    maintenance_identity: &str,
418) -> Result<(), CacheFsError> {
419    fs::create_dir_all(path).map_err(|source| CacheFsError {
420        operation: "create cache maintenance directory",
421        path: path.to_owned(),
422        source,
423    })?;
424    let marker = path.join(LAST_MAINTENANCE_FILE);
425    let elapsed = encode_system_time(&marker, SystemTime::now())?;
426    let contents = format!("{}\n{maintenance_identity}\n", elapsed.as_nanos());
427    write_atomic(&marker, contents.as_bytes()).map_err(|source| CacheFsError {
428        operation: "record cache maintenance time",
429        path: marker,
430        source,
431    })
432}
433
434pub(super) fn perform_scheduled_cache_maintenance(
435    path: &Path,
436    minimum_interval: Option<Duration>,
437    maintenance_identity: &str,
438    maintenance: impl FnOnce() -> Result<ArtifactCachePruneReport, String>,
439) -> (Option<ArtifactCacheMaintenance>, Option<Duration>) {
440    let started = Instant::now();
441    match cache_maintenance_due(path, minimum_interval, maintenance_identity) {
442        Ok(false) => return (None, Some(started.elapsed())),
443        Ok(true) => {}
444        Err(error) => {
445            return (
446                Some(ArtifactCacheMaintenance::PruneFailed {
447                    message: error.to_string(),
448                }),
449                Some(started.elapsed()),
450            );
451        }
452    }
453
454    let result = maintenance();
455    let marker = record_cache_maintenance(path, maintenance_identity);
456    let outcome = match (result, marker) {
457        (Ok(report), Ok(())) => ArtifactCacheMaintenance::Pruned(report),
458        (Err(message), Ok(())) => ArtifactCacheMaintenance::PruneFailed { message },
459        (Ok(_), Err(error)) => ArtifactCacheMaintenance::PruneFailed {
460            message: error.to_string(),
461        },
462        (Err(message), Err(marker)) => ArtifactCacheMaintenance::PruneFailed {
463            message: format!(
464                "{message}; additionally failed to record the maintenance attempt: {marker}"
465            ),
466        },
467    };
468    (Some(outcome), Some(started.elapsed()))
469}
470
471pub(super) fn write_last_used(path: &Path, last_used: SystemTime) -> Result<(), CacheFsError> {
472    let marker = path.join(LAST_USED_FILE);
473    write_system_time(&marker, last_used, "record cache use time")
474}
475
476fn write_system_time(
477    path: &Path,
478    timestamp: SystemTime,
479    operation: &'static str,
480) -> Result<(), CacheFsError> {
481    let elapsed = encode_system_time(path, timestamp)?;
482    write_atomic(path, elapsed.as_nanos().to_string().as_bytes()).map_err(|source| CacheFsError {
483        operation,
484        path: path.to_owned(),
485        source,
486    })
487}
488
489fn encode_system_time(path: &Path, timestamp: SystemTime) -> Result<Duration, CacheFsError> {
490    timestamp
491        .duration_since(UNIX_EPOCH)
492        .map_err(|source| CacheFsError {
493            operation: "encode cache time",
494            path: path.to_owned(),
495            source: io::Error::new(io::ErrorKind::InvalidInput, source),
496        })
497}
498
499fn decode_system_time(contents: &str) -> Option<SystemTime> {
500    let nanoseconds = contents.parse::<u128>().ok()?;
501    let seconds = u64::try_from(nanoseconds / 1_000_000_000).ok()?;
502    let subsecond_nanos = (nanoseconds % 1_000_000_000) as u32;
503    UNIX_EPOCH.checked_add(Duration::new(seconds, subsecond_nanos))
504}
505
506pub(super) fn prune_direct_child_directories(
507    cache_root: &Path,
508    policy: ArtifactCachePrunePolicy,
509    protected_entry: Option<&Path>,
510    is_eligible: impl Fn(&Path) -> bool,
511) -> Result<ArtifactCachePruneReport, CacheFsError> {
512    let mut entries = cache_entries(cache_root, is_eligible)?;
513    let bytes_before = entries
514        .iter()
515        .fold(0_u64, |total, entry| total.saturating_add(entry.bytes));
516    let mut report = ArtifactCachePruneReport {
517        entries_scanned: entries.len(),
518        entries_removed: 0,
519        bytes_before,
520        bytes_removed: 0,
521        uncommitted_directories_removed: 0,
522        uncommitted_bytes_removed: 0,
523    };
524    let now = SystemTime::now();
525
526    if let Some(max_age) = policy.max_age() {
527        for entry in &mut entries {
528            let age = now.duration_since(entry.last_used).unwrap_or_default();
529            if protected_entry != Some(entry.path.as_path()) && age > max_age {
530                remove_cache_entry(entry, &mut report)?;
531            }
532        }
533    }
534
535    if let Some(max_size_bytes) = policy.max_size_bytes() {
536        entries.sort_by(|left, right| {
537            left.last_used
538                .cmp(&right.last_used)
539                .then_with(|| left.path.cmp(&right.path))
540        });
541        for entry in &mut entries {
542            if report.bytes_retained() <= max_size_bytes {
543                break;
544            }
545            if protected_entry == Some(entry.path.as_path()) {
546                continue;
547            }
548            remove_cache_entry(entry, &mut report)?;
549        }
550    }
551
552    Ok(report)
553}
554
555pub(super) fn directory_logical_size(path: &Path) -> io::Result<u64> {
556    let mut total = 0_u64;
557    let mut pending = vec![path.to_owned()];
558    while let Some(current) = pending.pop() {
559        let metadata = fs::symlink_metadata(&current)?;
560        if metadata.is_dir() {
561            for entry in fs::read_dir(&current)? {
562                let path = entry?.path();
563                let metadata = fs::symlink_metadata(&path)?;
564                if metadata.is_dir() {
565                    pending.push(path);
566                } else {
567                    total = total.saturating_add(metadata.len());
568                }
569            }
570        } else {
571            total = total.saturating_add(metadata.len());
572        }
573    }
574    Ok(total)
575}
576
577pub(super) fn is_sha256_directory(path: &Path) -> bool {
578    path.file_name().is_some_and(|name| {
579        let bytes = name.as_encoded_bytes();
580        bytes.len() == 64 && bytes.iter().all(u8::is_ascii_hexdigit)
581    })
582}
583
584pub(super) fn remove_path_if_present(path: &Path) -> io::Result<()> {
585    let metadata = match fs::symlink_metadata(path) {
586        Ok(metadata) => metadata,
587        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
588        Err(error) => return Err(error),
589    };
590    if metadata.file_type().is_dir() {
591        fs::remove_dir_all(path)
592    } else {
593        fs::remove_file(path)
594    }
595}
596
597struct CacheEntry {
598    path: PathBuf,
599    bytes: u64,
600    last_used: SystemTime,
601    removed: bool,
602}
603
604impl std::fmt::Display for CacheFsError {
605    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
606        write!(
607            formatter,
608            "failed to {} at {}: {}",
609            self.operation,
610            self.path.display(),
611            self.source
612        )
613    }
614}
615
616impl std::error::Error for CacheFsError {
617    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
618        Some(&self.source)
619    }
620}
621
622fn cache_entries(
623    cache_root: &Path,
624    is_eligible: impl Fn(&Path) -> bool,
625) -> Result<Vec<CacheEntry>, CacheFsError> {
626    let read_dir = match fs::read_dir(cache_root) {
627        Ok(read_dir) => read_dir,
628        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
629        Err(source) => {
630            return Err(CacheFsError {
631                operation: "read cache directory",
632                path: cache_root.to_owned(),
633                source,
634            });
635        }
636    };
637    let mut entries = Vec::new();
638    for directory_entry in read_dir {
639        let directory_entry = directory_entry.map_err(|source| CacheFsError {
640            operation: "read cache entry",
641            path: cache_root.to_owned(),
642            source,
643        })?;
644        let path = directory_entry.path();
645        let file_type = directory_entry.file_type().map_err(|source| CacheFsError {
646            operation: "inspect cache entry",
647            path: path.clone(),
648            source,
649        })?;
650        if !file_type.is_dir() || !is_eligible(&path) {
651            continue;
652        }
653        let bytes = directory_logical_size(&path).map_err(|source| CacheFsError {
654            operation: "measure cache entry",
655            path: path.clone(),
656            source,
657        })?;
658        let last_used = cache_entry_last_used(&path).map_err(|source| CacheFsError {
659            operation: "read cache use time",
660            path: path.clone(),
661            source,
662        })?;
663        entries.push(CacheEntry {
664            path,
665            bytes,
666            last_used,
667            removed: false,
668        });
669    }
670    Ok(entries)
671}
672
673pub(super) fn cache_entry_last_used(path: &Path) -> io::Result<SystemTime> {
674    let marker = path.join(LAST_USED_FILE);
675    if let Ok(contents) = fs::read_to_string(&marker)
676        && let Some(timestamp) = decode_system_time(&contents)
677    {
678        return Ok(timestamp);
679    }
680    fs::metadata(path)?.modified()
681}
682
683fn remove_cache_entry(
684    entry: &mut CacheEntry,
685    report: &mut ArtifactCachePruneReport,
686) -> Result<(), CacheFsError> {
687    if entry.removed {
688        return Ok(());
689    }
690    let Some(_retention_lock) = try_lock_cache_file(&entry.path.join(RETENTION_LOCK_FILE))? else {
691        return Ok(());
692    };
693    remove_path_if_present(&entry.path).map_err(|source| CacheFsError {
694        operation: "prune cache entry",
695        path: entry.path.clone(),
696        source,
697    })?;
698    entry.removed = true;
699    report.entries_removed += 1;
700    report.bytes_removed = report.bytes_removed.saturating_add(entry.bytes);
701    Ok(())
702}
703
704#[cfg(test)]
705mod tests {
706    #[cfg(unix)]
707    use super::canonicalize_allow_missing;
708    use super::directory_logical_size;
709    use crate::artifacts::test_support::unique_temp_directory;
710    use std::fs;
711
712    #[test]
713    fn directory_size_sums_wide_and_nested_files() {
714        let root = unique_temp_directory("directory-logical-size");
715        assert_eq!(directory_logical_size(&root).unwrap(), 0);
716        fs::create_dir_all(root.join("wide")).unwrap();
717        fs::create_dir_all(root.join("nested/deep/empty")).unwrap();
718        let mut expected = 0;
719        for index in 0..128 {
720            let bytes = vec![42; index % 13];
721            fs::write(root.join("wide").join(index.to_string()), &bytes).unwrap();
722            expected += bytes.len() as u64;
723        }
724        let sparse = root.join("nested/deep/sparse");
725        fs::File::create(&sparse)
726            .unwrap()
727            .set_len(1024 * 1024)
728            .unwrap();
729        assert_eq!(directory_logical_size(&sparse).unwrap(), 1024 * 1024);
730        assert_eq!(
731            directory_logical_size(&root).unwrap(),
732            expected + 1024 * 1024
733        );
734        assert_eq!(
735            directory_logical_size(&root.join("missing"))
736                .unwrap_err()
737                .kind(),
738            std::io::ErrorKind::NotFound,
739        );
740        fs::remove_dir_all(root).unwrap();
741    }
742
743    #[test]
744    #[cfg(unix)]
745    fn directory_size_counts_symlinks_without_following_them() {
746        let root = unique_temp_directory("directory-size-symlinks");
747        let walked = root.join("walked");
748        fs::create_dir_all(&walked).unwrap();
749        fs::create_dir_all(root.join("external")).unwrap();
750        fs::write(root.join("external/payload"), vec![42; 4096]).unwrap();
751        fs::write(root.join("outside-file"), vec![42; 4096]).unwrap();
752        fs::write(walked.join("payload"), b"abc").unwrap();
753        let targets = ["../external", "../outside-file", "missing", "."];
754        for (index, target) in targets.iter().enumerate() {
755            std::os::unix::fs::symlink(target, walked.join(index.to_string())).unwrap();
756        }
757        let expected = 3 + targets
758            .iter()
759            .map(|target| target.len() as u64)
760            .sum::<u64>();
761        assert_eq!(directory_logical_size(&walked).unwrap(), expected);
762        assert_eq!(
763            directory_logical_size(&walked.join("0")).unwrap(),
764            targets[0].len() as u64,
765        );
766        fs::remove_dir_all(root).unwrap();
767    }
768
769    #[test]
770    #[cfg(unix)]
771    fn missing_parent_traversal_resumes_existing_symlink_resolution() {
772        let root = unique_temp_directory("canonical-missing-parent");
773        let target = root.join("real");
774        fs::create_dir_all(&target).unwrap();
775        std::os::unix::fs::symlink(&target, root.join("alias")).unwrap();
776        let path = root.join("missing/../alias/generated/nested/../output");
777        assert_eq!(
778            canonicalize_allow_missing(&path).unwrap(),
779            target.canonicalize().unwrap().join("generated/output")
780        );
781        assert_eq!(
782            canonicalize_allow_missing(&root.join("alias/../other/output")).unwrap(),
783            root.canonicalize().unwrap().join("other/output")
784        );
785        fs::remove_dir_all(root).unwrap();
786    }
787}