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
13pub(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#[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
97pub(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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
141pub struct ArtifactCachePrunePolicy {
142 max_age: Option<Duration>,
143 max_size_bytes: Option<u64>,
144}
145
146#[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#[non_exhaustive]
159#[derive(Clone, Debug, Eq, PartialEq)]
160pub enum ArtifactCacheMaintenance {
161 Pruned(ArtifactCachePruneReport),
163 PruneFailed {
165 message: String,
167 },
168}
169
170impl ArtifactCachePrunePolicy {
171 #[must_use]
173 pub const fn new() -> Self {
174 Self {
175 max_age: None,
176 max_size_bytes: None,
177 }
178 }
179
180 #[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 #[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 #[must_use]
196 pub const fn max_age(self) -> Option<Duration> {
197 self.max_age
198 }
199
200 #[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 #[must_use]
218 pub const fn entries_scanned(self) -> usize {
219 self.entries_scanned
220 }
221
222 #[must_use]
224 pub const fn entries_removed(self) -> usize {
225 self.entries_removed
226 }
227
228 #[must_use]
230 pub const fn entries_retained(self) -> usize {
231 self.entries_scanned.saturating_sub(self.entries_removed)
232 }
233
234 #[must_use]
236 pub const fn bytes_before(self) -> u64 {
237 self.bytes_before
238 }
239
240 #[must_use]
242 pub const fn bytes_removed(self) -> u64 {
243 self.bytes_removed
244 }
245
246 #[must_use]
248 pub const fn bytes_retained(self) -> u64 {
249 self.bytes_before.saturating_sub(self.bytes_removed)
250 }
251
252 #[must_use]
254 pub const fn uncommitted_directories_removed(self) -> usize {
255 self.uncommitted_directories_removed
256 }
257
258 #[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 #[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 #[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 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(¤t)?;
565 if metadata.is_dir() {
566 for entry in fs::read_dir(¤t)? {
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}