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 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    if fs::read_to_string(&path)
300        .is_ok_and(|contents| contents.starts_with(CACHE_DIRECTORY_TAG_SIGNATURE))
301    {
302        return Ok(());
303    }
304    write_atomic(&path, CACHE_DIRECTORY_TAG.as_bytes()).map_err(|source| CacheFsError {
305        operation: "write cache directory tag",
306        path,
307        source,
308    })
309}
310
311pub(super) fn lock_cache_file(path: &Path) -> Result<(File, Duration), CacheFsError> {
312    let file = open_cache_lock_file(path)?;
313    let started = Instant::now();
314    file.lock_exclusive().map_err(|source| CacheFsError {
315        operation: "lock cache",
316        path: path.to_owned(),
317        source,
318    })?;
319    Ok((file, started.elapsed()))
320}
321
322pub(super) fn lock_cache_file_with_wait_observer(
323    path: &Path,
324    poll_interval: Duration,
325    mut observer: impl FnMut(Duration),
326) -> Result<(File, Duration), CacheFsError> {
327    let file = open_cache_lock_file(path)?;
328    let started = Instant::now();
329    loop {
330        match file.try_lock_exclusive() {
331            Ok(()) => return Ok((file, started.elapsed())),
332            Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
333                observer(started.elapsed());
334                thread::sleep(poll_interval.min(Duration::from_millis(25)));
335            }
336            Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
337            Err(source) => {
338                return Err(CacheFsError {
339                    operation: "try lock cache",
340                    path: path.to_owned(),
341                    source,
342                });
343            }
344        }
345    }
346}
347
348pub(super) fn try_lock_cache_file(path: &Path) -> Result<Option<File>, CacheFsError> {
349    let file = open_cache_lock_file(path)?;
350    match file.try_lock_exclusive() {
351        Ok(()) => Ok(Some(file)),
352        Err(error) if error.kind() == io::ErrorKind::WouldBlock => Ok(None),
353        Err(source) => Err(CacheFsError {
354            operation: "try lock cache",
355            path: path.to_owned(),
356            source,
357        }),
358    }
359}
360
361fn open_cache_lock_file(path: &Path) -> Result<File, CacheFsError> {
362    if let Some(parent) = path.parent() {
363        fs::create_dir_all(parent).map_err(|source| CacheFsError {
364            operation: "create cache lock directory",
365            path: parent.to_owned(),
366            source,
367        })?;
368    }
369    OpenOptions::new()
370        .create(true)
371        .read(true)
372        .write(true)
373        .truncate(false)
374        .open(path)
375        .map_err(|source| CacheFsError {
376            operation: "open cache lock",
377            path: path.to_owned(),
378            source,
379        })
380}
381
382pub(super) fn record_cache_entry_use(path: &Path) -> Result<(), CacheFsError> {
383    write_last_used(path, SystemTime::now())
384}
385
386pub(super) fn cache_maintenance_due(
387    path: &Path,
388    minimum_interval: Option<Duration>,
389    maintenance_identity: &str,
390) -> Result<bool, CacheFsError> {
391    let Some(minimum_interval) = minimum_interval else {
392        return Ok(true);
393    };
394    let marker = path.join(LAST_MAINTENANCE_FILE);
395    let contents = match fs::read_to_string(&marker) {
396        Ok(contents) => contents,
397        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(true),
398        Err(source) => {
399            return Err(CacheFsError {
400                operation: "read cache maintenance time",
401                path: marker,
402                source,
403            });
404        }
405    };
406    let mut lines = contents.lines();
407    let Some(last_maintenance) = lines.next().and_then(decode_system_time) else {
408        return Ok(true);
409    };
410    if lines.next() != Some(maintenance_identity) {
411        return Ok(true);
412    }
413    Ok(match SystemTime::now().duration_since(last_maintenance) {
414        Ok(elapsed) => elapsed >= minimum_interval,
415        Err(_) => true,
416    })
417}
418
419pub(super) fn record_cache_maintenance(
420    path: &Path,
421    maintenance_identity: &str,
422) -> Result<(), CacheFsError> {
423    fs::create_dir_all(path).map_err(|source| CacheFsError {
424        operation: "create cache maintenance directory",
425        path: path.to_owned(),
426        source,
427    })?;
428    let marker = path.join(LAST_MAINTENANCE_FILE);
429    let elapsed = encode_system_time(&marker, SystemTime::now())?;
430    let contents = format!("{}\n{maintenance_identity}\n", elapsed.as_nanos());
431    write_atomic(&marker, contents.as_bytes()).map_err(|source| CacheFsError {
432        operation: "record cache maintenance time",
433        path: marker,
434        source,
435    })
436}
437
438pub(super) fn perform_scheduled_cache_maintenance(
439    path: &Path,
440    minimum_interval: Option<Duration>,
441    maintenance_identity: &str,
442    maintenance: impl FnOnce() -> Result<ArtifactCachePruneReport, String>,
443) -> (Option<ArtifactCacheMaintenance>, Option<Duration>) {
444    let started = Instant::now();
445    match cache_maintenance_due(path, minimum_interval, maintenance_identity) {
446        Ok(false) => return (None, Some(started.elapsed())),
447        Ok(true) => {}
448        Err(error) => {
449            return (
450                Some(ArtifactCacheMaintenance::PruneFailed {
451                    message: error.to_string(),
452                }),
453                Some(started.elapsed()),
454            );
455        }
456    }
457
458    let result = maintenance();
459    let marker = record_cache_maintenance(path, maintenance_identity);
460    let outcome = match (result, marker) {
461        (Ok(report), Ok(())) => ArtifactCacheMaintenance::Pruned(report),
462        (Err(message), Ok(())) => ArtifactCacheMaintenance::PruneFailed { message },
463        (Ok(_), Err(error)) => ArtifactCacheMaintenance::PruneFailed {
464            message: error.to_string(),
465        },
466        (Err(message), Err(marker)) => ArtifactCacheMaintenance::PruneFailed {
467            message: format!(
468                "{message}; additionally failed to record the maintenance attempt: {marker}"
469            ),
470        },
471    };
472    (Some(outcome), Some(started.elapsed()))
473}
474
475pub(super) fn write_last_used(path: &Path, last_used: SystemTime) -> Result<(), CacheFsError> {
476    let marker = path.join(LAST_USED_FILE);
477    write_system_time(&marker, last_used, "record cache use time")
478}
479
480fn write_system_time(
481    path: &Path,
482    timestamp: SystemTime,
483    operation: &'static str,
484) -> Result<(), CacheFsError> {
485    let elapsed = encode_system_time(path, timestamp)?;
486    write_atomic(path, elapsed.as_nanos().to_string().as_bytes()).map_err(|source| CacheFsError {
487        operation,
488        path: path.to_owned(),
489        source,
490    })
491}
492
493fn encode_system_time(path: &Path, timestamp: SystemTime) -> Result<Duration, CacheFsError> {
494    timestamp
495        .duration_since(UNIX_EPOCH)
496        .map_err(|source| CacheFsError {
497            operation: "encode cache time",
498            path: path.to_owned(),
499            source: io::Error::new(io::ErrorKind::InvalidInput, source),
500        })
501}
502
503fn decode_system_time(contents: &str) -> Option<SystemTime> {
504    let nanoseconds = contents.parse::<u128>().ok()?;
505    let seconds = u64::try_from(nanoseconds / 1_000_000_000).ok()?;
506    let subsecond_nanos = (nanoseconds % 1_000_000_000) as u32;
507    UNIX_EPOCH.checked_add(Duration::new(seconds, subsecond_nanos))
508}
509
510pub(super) fn prune_direct_child_directories(
511    cache_root: &Path,
512    policy: ArtifactCachePrunePolicy,
513    protected_entry: Option<&Path>,
514    is_eligible: impl Fn(&Path) -> bool,
515) -> Result<ArtifactCachePruneReport, CacheFsError> {
516    let mut entries = cache_entries(cache_root, is_eligible)?;
517    let bytes_before = entries
518        .iter()
519        .fold(0_u64, |total, entry| total.saturating_add(entry.bytes));
520    let mut report = ArtifactCachePruneReport {
521        entries_scanned: entries.len(),
522        entries_removed: 0,
523        bytes_before,
524        bytes_removed: 0,
525        uncommitted_directories_removed: 0,
526        uncommitted_bytes_removed: 0,
527    };
528    let now = SystemTime::now();
529
530    if let Some(max_age) = policy.max_age() {
531        for entry in &mut entries {
532            let age = now.duration_since(entry.last_used).unwrap_or_default();
533            if protected_entry != Some(entry.path.as_path()) && age > max_age {
534                remove_cache_entry(entry, &mut report)?;
535            }
536        }
537    }
538
539    if let Some(max_size_bytes) = policy.max_size_bytes() {
540        entries.sort_by(|left, right| {
541            left.last_used
542                .cmp(&right.last_used)
543                .then_with(|| left.path.cmp(&right.path))
544        });
545        for entry in &mut entries {
546            if report.bytes_retained() <= max_size_bytes {
547                break;
548            }
549            if protected_entry == Some(entry.path.as_path()) {
550                continue;
551            }
552            remove_cache_entry(entry, &mut report)?;
553        }
554    }
555
556    Ok(report)
557}
558
559pub(super) fn directory_logical_size(path: &Path) -> io::Result<u64> {
560    let mut total = 0_u64;
561    let mut pending = vec![path.to_owned()];
562    while let Some(current) = pending.pop() {
563        let metadata = fs::symlink_metadata(&current)?;
564        if metadata.is_dir() {
565            for entry in fs::read_dir(&current)? {
566                let path = entry?.path();
567                let metadata = fs::symlink_metadata(&path)?;
568                if metadata.is_dir() {
569                    pending.push(path);
570                } else {
571                    total = total.saturating_add(metadata.len());
572                }
573            }
574        } else {
575            total = total.saturating_add(metadata.len());
576        }
577    }
578    Ok(total)
579}
580
581pub(super) fn is_sha256_directory(path: &Path) -> bool {
582    path.file_name().is_some_and(|name| {
583        let bytes = name.as_encoded_bytes();
584        bytes.len() == 64 && bytes.iter().all(u8::is_ascii_hexdigit)
585    })
586}
587
588pub(super) fn remove_path_if_present(path: &Path) -> io::Result<()> {
589    let metadata = match fs::symlink_metadata(path) {
590        Ok(metadata) => metadata,
591        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
592        Err(error) => return Err(error),
593    };
594    if metadata.file_type().is_dir() {
595        fs::remove_dir_all(path)
596    } else {
597        fs::remove_file(path)
598    }
599}
600
601struct CacheEntry {
602    path: PathBuf,
603    bytes: u64,
604    last_used: SystemTime,
605    removed: bool,
606}
607
608impl std::fmt::Display for CacheFsError {
609    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
610        write!(
611            formatter,
612            "failed to {} at {}: {}",
613            self.operation,
614            self.path.display(),
615            self.source
616        )
617    }
618}
619
620impl std::error::Error for CacheFsError {
621    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
622        Some(&self.source)
623    }
624}
625
626fn cache_entries(
627    cache_root: &Path,
628    is_eligible: impl Fn(&Path) -> bool,
629) -> Result<Vec<CacheEntry>, CacheFsError> {
630    let read_dir = match fs::read_dir(cache_root) {
631        Ok(read_dir) => read_dir,
632        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
633        Err(source) => {
634            return Err(CacheFsError {
635                operation: "read cache directory",
636                path: cache_root.to_owned(),
637                source,
638            });
639        }
640    };
641    let mut entries = Vec::new();
642    for directory_entry in read_dir {
643        let directory_entry = directory_entry.map_err(|source| CacheFsError {
644            operation: "read cache entry",
645            path: cache_root.to_owned(),
646            source,
647        })?;
648        let path = directory_entry.path();
649        let file_type = directory_entry.file_type().map_err(|source| CacheFsError {
650            operation: "inspect cache entry",
651            path: path.clone(),
652            source,
653        })?;
654        if !file_type.is_dir() || !is_eligible(&path) {
655            continue;
656        }
657        let bytes = directory_logical_size(&path).map_err(|source| CacheFsError {
658            operation: "measure cache entry",
659            path: path.clone(),
660            source,
661        })?;
662        let last_used = cache_entry_last_used(&path).map_err(|source| CacheFsError {
663            operation: "read cache use time",
664            path: path.clone(),
665            source,
666        })?;
667        entries.push(CacheEntry {
668            path,
669            bytes,
670            last_used,
671            removed: false,
672        });
673    }
674    Ok(entries)
675}
676
677pub(super) fn cache_entry_last_used(path: &Path) -> io::Result<SystemTime> {
678    let marker = path.join(LAST_USED_FILE);
679    if let Ok(contents) = fs::read_to_string(&marker)
680        && let Some(timestamp) = decode_system_time(&contents)
681    {
682        return Ok(timestamp);
683    }
684    fs::metadata(path)?.modified()
685}
686
687fn remove_cache_entry(
688    entry: &mut CacheEntry,
689    report: &mut ArtifactCachePruneReport,
690) -> Result<(), CacheFsError> {
691    if entry.removed {
692        return Ok(());
693    }
694    let Some(_retention_lock) = try_lock_cache_file(&entry.path.join(RETENTION_LOCK_FILE))? else {
695        return Ok(());
696    };
697    remove_path_if_present(&entry.path).map_err(|source| CacheFsError {
698        operation: "prune cache entry",
699        path: entry.path.clone(),
700        source,
701    })?;
702    entry.removed = true;
703    report.entries_removed += 1;
704    report.bytes_removed = report.bytes_removed.saturating_add(entry.bytes);
705    Ok(())
706}
707
708#[cfg(test)]
709mod tests {
710    #[cfg(unix)]
711    use super::canonicalize_allow_missing;
712    use super::directory_logical_size;
713    use crate::artifacts::test_support::unique_temp_directory;
714    use std::fs;
715
716    #[test]
717    fn directory_size_sums_wide_and_nested_files() {
718        let root = unique_temp_directory("directory-logical-size");
719        assert_eq!(directory_logical_size(&root).unwrap(), 0);
720        fs::create_dir_all(root.join("wide")).unwrap();
721        fs::create_dir_all(root.join("nested/deep/empty")).unwrap();
722        let mut expected = 0;
723        for index in 0..128 {
724            let bytes = vec![42; index % 13];
725            fs::write(root.join("wide").join(index.to_string()), &bytes).unwrap();
726            expected += bytes.len() as u64;
727        }
728        let sparse = root.join("nested/deep/sparse");
729        fs::File::create(&sparse)
730            .unwrap()
731            .set_len(1024 * 1024)
732            .unwrap();
733        assert_eq!(directory_logical_size(&sparse).unwrap(), 1024 * 1024);
734        assert_eq!(
735            directory_logical_size(&root).unwrap(),
736            expected + 1024 * 1024
737        );
738        assert_eq!(
739            directory_logical_size(&root.join("missing"))
740                .unwrap_err()
741                .kind(),
742            std::io::ErrorKind::NotFound,
743        );
744        fs::remove_dir_all(root).unwrap();
745    }
746
747    #[test]
748    #[cfg(unix)]
749    fn directory_size_counts_symlinks_without_following_them() {
750        let root = unique_temp_directory("directory-size-symlinks");
751        let walked = root.join("walked");
752        fs::create_dir_all(&walked).unwrap();
753        fs::create_dir_all(root.join("external")).unwrap();
754        fs::write(root.join("external/payload"), vec![42; 4096]).unwrap();
755        fs::write(root.join("outside-file"), vec![42; 4096]).unwrap();
756        fs::write(walked.join("payload"), b"abc").unwrap();
757        let targets = ["../external", "../outside-file", "missing", "."];
758        for (index, target) in targets.iter().enumerate() {
759            std::os::unix::fs::symlink(target, walked.join(index.to_string())).unwrap();
760        }
761        let expected = 3 + targets
762            .iter()
763            .map(|target| target.len() as u64)
764            .sum::<u64>();
765        assert_eq!(directory_logical_size(&walked).unwrap(), expected);
766        assert_eq!(
767            directory_logical_size(&walked.join("0")).unwrap(),
768            targets[0].len() as u64,
769        );
770        fs::remove_dir_all(root).unwrap();
771    }
772
773    #[test]
774    #[cfg(unix)]
775    fn missing_parent_traversal_resumes_existing_symlink_resolution() {
776        let root = unique_temp_directory("canonical-missing-parent");
777        let target = root.join("real");
778        fs::create_dir_all(&target).unwrap();
779        std::os::unix::fs::symlink(&target, root.join("alias")).unwrap();
780        let path = root.join("missing/../alias/generated/nested/../output");
781        assert_eq!(
782            canonicalize_allow_missing(&path).unwrap(),
783            target.canonicalize().unwrap().join("generated/output")
784        );
785        assert_eq!(
786            canonicalize_allow_missing(&root.join("alias/../other/output")).unwrap(),
787            root.canonicalize().unwrap().join("other/output")
788        );
789        fs::remove_dir_all(root).unwrap();
790    }
791}