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