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::{read_stamp_with_limit, 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";
60const MAX_TIMESTAMP_BYTES: usize = 39;
62pub(super) const RETENTION_LOCK_FILE: &str = ".ic-testkit-retention-v1";
63
64#[derive(Clone, Debug)]
67pub(super) struct RetainedCacheEntry {
68 path: PathBuf,
69 _lock: Arc<File>,
70}
71
72impl PartialEq for RetainedCacheEntry {
73 fn eq(&self, other: &Self) -> bool {
74 self.path == other.path
75 }
76}
77
78impl Eq for RetainedCacheEntry {}
79
80impl RetainedCacheEntry {
81 pub(super) fn path(&self) -> &Path {
82 &self.path
83 }
84
85 pub(super) fn acquire(path: &Path) -> Result<Self, CacheFsError> {
86 let file = open_cache_lock_file(&path.join(RETENTION_LOCK_FILE))?;
87 fs2::FileExt::lock_shared(&file).map_err(|source| CacheFsError {
88 operation: "retain cache entry",
89 path: path.to_owned(),
90 source,
91 })?;
92 Ok(Self {
93 path: path.to_owned(),
94 _lock: Arc::new(file),
95 })
96 }
97}
98
99pub(super) fn remove_unretained_entry(path: &Path) -> Result<(), CacheFsError> {
101 let metadata = match fs::symlink_metadata(path) {
102 Ok(metadata) => metadata,
103 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
104 Err(source) => {
105 return Err(CacheFsError {
106 operation: "inspect cache entry",
107 path: path.to_owned(),
108 source,
109 });
110 }
111 };
112 if !metadata.is_dir() {
113 return remove_path_if_present(path).map_err(|source| CacheFsError {
114 operation: "remove invalid cache entry",
115 path: path.to_owned(),
116 source,
117 });
118 }
119 let _lock =
120 try_lock_cache_file(&path.join(RETENTION_LOCK_FILE))?.ok_or_else(|| CacheFsError {
121 operation: "replace retained cache entry",
122 path: path.to_owned(),
123 source: io::Error::new(
124 io::ErrorKind::WouldBlock,
125 "cache entry is retained by a consumer",
126 ),
127 })?;
128 remove_path_if_present(path).map_err(|source| CacheFsError {
129 operation: "remove cache entry",
130 path: path.to_owned(),
131 source,
132 })
133}
134
135#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
143pub struct ArtifactCachePrunePolicy {
144 max_age: Option<Duration>,
145 max_size_bytes: Option<u64>,
146}
147
148#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
150pub struct ArtifactCachePruneReport {
151 entries_scanned: usize,
152 entries_removed: usize,
153 bytes_before: u64,
154 bytes_removed: u64,
155 uncommitted_directories_removed: usize,
156 uncommitted_bytes_removed: u64,
157}
158
159#[non_exhaustive]
161#[derive(Clone, Debug, Eq, PartialEq)]
162pub enum ArtifactCacheMaintenance {
163 Pruned(ArtifactCachePruneReport),
165 PruneFailed {
167 message: String,
169 },
170}
171
172impl ArtifactCachePrunePolicy {
173 #[must_use]
175 pub const fn new() -> Self {
176 Self {
177 max_age: None,
178 max_size_bytes: None,
179 }
180 }
181
182 #[must_use]
184 pub const fn with_max_age(mut self, max_age: Duration) -> Self {
185 self.max_age = Some(max_age);
186 self
187 }
188
189 #[must_use]
191 pub const fn with_max_size_bytes(mut self, bytes: u64) -> Self {
192 self.max_size_bytes = Some(bytes);
193 self
194 }
195
196 #[must_use]
198 pub const fn max_age(self) -> Option<Duration> {
199 self.max_age
200 }
201
202 #[must_use]
204 pub const fn max_size_bytes(self) -> Option<u64> {
205 self.max_size_bytes
206 }
207
208 pub(super) fn maintenance_identity(self) -> String {
209 format!(
210 "age={:?};size={:?}",
211 self.max_age.map(|duration| duration.as_nanos()),
212 self.max_size_bytes
213 )
214 }
215}
216
217impl ArtifactCachePruneReport {
218 #[must_use]
220 pub const fn entries_scanned(self) -> usize {
221 self.entries_scanned
222 }
223
224 #[must_use]
226 pub const fn entries_removed(self) -> usize {
227 self.entries_removed
228 }
229
230 #[must_use]
232 pub const fn entries_retained(self) -> usize {
233 self.entries_scanned.saturating_sub(self.entries_removed)
234 }
235
236 #[must_use]
238 pub const fn bytes_before(self) -> u64 {
239 self.bytes_before
240 }
241
242 #[must_use]
244 pub const fn bytes_removed(self) -> u64 {
245 self.bytes_removed
246 }
247
248 #[must_use]
250 pub const fn bytes_retained(self) -> u64 {
251 self.bytes_before.saturating_sub(self.bytes_removed)
252 }
253
254 #[must_use]
256 pub const fn uncommitted_directories_removed(self) -> usize {
257 self.uncommitted_directories_removed
258 }
259
260 #[must_use]
262 pub const fn uncommitted_bytes_removed(self) -> u64 {
263 self.uncommitted_bytes_removed
264 }
265
266 pub(super) const fn record_uncommitted_removal(&mut self, bytes: u64) {
267 self.uncommitted_directories_removed += 1;
268 self.uncommitted_bytes_removed = self.uncommitted_bytes_removed.saturating_add(bytes);
269 }
270}
271
272impl ArtifactCacheMaintenance {
273 #[must_use]
275 pub const fn prune_report(&self) -> Option<ArtifactCachePruneReport> {
276 match self {
277 Self::Pruned(report) => Some(*report),
278 Self::PruneFailed { .. } => None,
279 }
280 }
281
282 #[must_use]
284 pub fn failure_message(&self) -> Option<&str> {
285 match self {
286 Self::Pruned(_) => None,
287 Self::PruneFailed { message } => Some(message),
288 }
289 }
290}
291
292#[derive(Debug)]
293pub(super) struct CacheFsError {
294 pub(super) operation: &'static str,
295 pub(super) path: PathBuf,
296 pub(super) source: io::Error,
297}
298
299pub(super) fn ensure_cache_directory_tag(cache_root: &Path) -> Result<(), CacheFsError> {
300 let path = cache_root.join("CACHEDIR.TAG");
301 let mut signature = [0_u8; CACHE_DIRECTORY_TAG_SIGNATURE.len()];
304 if fs::symlink_metadata(&path).is_ok_and(|metadata| metadata.file_type().is_file())
305 && File::open(&path)
306 .and_then(|mut file| file.read_exact(&mut signature))
307 .is_ok()
308 && signature == CACHE_DIRECTORY_TAG_SIGNATURE.as_bytes()
309 {
310 return Ok(());
311 }
312 write_atomic(&path, CACHE_DIRECTORY_TAG.as_bytes()).map_err(|source| CacheFsError {
313 operation: "write cache directory tag",
314 path,
315 source,
316 })
317}
318
319pub(super) fn lock_cache_file(path: &Path) -> Result<(File, Duration), CacheFsError> {
320 let file = open_cache_lock_file(path)?;
321 let started = Instant::now();
322 file.lock_exclusive().map_err(|source| CacheFsError {
323 operation: "lock cache",
324 path: path.to_owned(),
325 source,
326 })?;
327 Ok((file, started.elapsed()))
328}
329
330pub(super) fn lock_cache_file_with_wait_observer(
331 path: &Path,
332 poll_interval: Duration,
333 mut observer: impl FnMut(Duration),
334) -> Result<(File, Duration), CacheFsError> {
335 let file = open_cache_lock_file(path)?;
336 let started = Instant::now();
337 loop {
338 match file.try_lock_exclusive() {
339 Ok(()) => return Ok((file, started.elapsed())),
340 Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
341 observer(started.elapsed());
342 thread::sleep(poll_interval.min(Duration::from_millis(25)));
343 }
344 Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
345 Err(source) => {
346 return Err(CacheFsError {
347 operation: "try lock cache",
348 path: path.to_owned(),
349 source,
350 });
351 }
352 }
353 }
354}
355
356pub(super) fn try_lock_cache_file(path: &Path) -> Result<Option<File>, CacheFsError> {
357 let file = open_cache_lock_file(path)?;
358 match file.try_lock_exclusive() {
359 Ok(()) => Ok(Some(file)),
360 Err(error) if error.kind() == io::ErrorKind::WouldBlock => Ok(None),
361 Err(source) => Err(CacheFsError {
362 operation: "try lock cache",
363 path: path.to_owned(),
364 source,
365 }),
366 }
367}
368
369fn open_cache_lock_file(path: &Path) -> Result<File, CacheFsError> {
370 if let Some(parent) = path.parent() {
371 fs::create_dir_all(parent).map_err(|source| CacheFsError {
372 operation: "create cache lock directory",
373 path: parent.to_owned(),
374 source,
375 })?;
376 }
377 OpenOptions::new()
378 .create(true)
379 .read(true)
380 .write(true)
381 .truncate(false)
382 .open(path)
383 .map_err(|source| CacheFsError {
384 operation: "open cache lock",
385 path: path.to_owned(),
386 source,
387 })
388}
389
390pub(super) fn record_cache_entry_use(path: &Path) -> Result<(), CacheFsError> {
391 write_last_used(path, SystemTime::now())
392}
393
394pub(super) fn cache_maintenance_due(
395 path: &Path,
396 minimum_interval: Option<Duration>,
397 maintenance_identity: &str,
398) -> Result<bool, CacheFsError> {
399 let Some(minimum_interval) = minimum_interval else {
400 return Ok(true);
401 };
402 let marker = path.join(LAST_MAINTENANCE_FILE);
403 let maximum_len = MAX_TIMESTAMP_BYTES + maintenance_identity.len() + 4;
405 let contents = match read_stamp_with_limit(&marker, maximum_len) {
406 Ok(Some(contents)) => contents,
407 Ok(None) => return Ok(true),
408 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(true),
409 Err(source) => {
410 return Err(CacheFsError {
411 operation: "read cache maintenance time",
412 path: marker,
413 source,
414 });
415 }
416 };
417 let mut lines = contents.lines();
418 let Some(last_maintenance) = lines.next().and_then(decode_system_time) else {
419 return Ok(true);
420 };
421 if lines.next() != Some(maintenance_identity) {
422 return Ok(true);
423 }
424 Ok(match SystemTime::now().duration_since(last_maintenance) {
425 Ok(elapsed) => elapsed >= minimum_interval,
426 Err(_) => true,
427 })
428}
429
430pub(super) fn record_cache_maintenance(
431 path: &Path,
432 maintenance_identity: &str,
433) -> Result<(), CacheFsError> {
434 let marker = path.join(LAST_MAINTENANCE_FILE);
435 let elapsed = encode_system_time(&marker, SystemTime::now())?;
436 let contents = format!("{}\n{maintenance_identity}\n", elapsed.as_nanos());
437 write_atomic(&marker, contents.as_bytes()).map_err(|source| CacheFsError {
438 operation: "record cache maintenance time",
439 path: marker,
440 source,
441 })
442}
443
444pub(super) fn perform_scheduled_cache_maintenance(
445 path: &Path,
446 minimum_interval: Option<Duration>,
447 maintenance_identity: &str,
448 maintenance: impl FnOnce() -> Result<ArtifactCachePruneReport, String>,
449) -> (Option<ArtifactCacheMaintenance>, Option<Duration>) {
450 let started = Instant::now();
451 match cache_maintenance_due(path, minimum_interval, maintenance_identity) {
452 Ok(false) => return (None, Some(started.elapsed())),
453 Ok(true) => {}
454 Err(error) => {
455 return (
456 Some(ArtifactCacheMaintenance::PruneFailed {
457 message: error.to_string(),
458 }),
459 Some(started.elapsed()),
460 );
461 }
462 }
463
464 let result = maintenance();
465 let marker = record_cache_maintenance(path, maintenance_identity);
466 let outcome = match (result, marker) {
467 (Ok(report), Ok(())) => ArtifactCacheMaintenance::Pruned(report),
468 (Err(message), Ok(())) => ArtifactCacheMaintenance::PruneFailed { message },
469 (Ok(_), Err(error)) => ArtifactCacheMaintenance::PruneFailed {
470 message: error.to_string(),
471 },
472 (Err(message), Err(marker)) => ArtifactCacheMaintenance::PruneFailed {
473 message: format!(
474 "{message}; additionally failed to record the maintenance attempt: {marker}"
475 ),
476 },
477 };
478 (Some(outcome), Some(started.elapsed()))
479}
480
481pub(super) fn write_last_used(path: &Path, last_used: SystemTime) -> Result<(), CacheFsError> {
482 let marker = path.join(LAST_USED_FILE);
483 write_system_time(&marker, last_used, "record cache use time")
484}
485
486fn write_system_time(
487 path: &Path,
488 timestamp: SystemTime,
489 operation: &'static str,
490) -> Result<(), CacheFsError> {
491 let elapsed = encode_system_time(path, timestamp)?;
492 write_atomic(path, elapsed.as_nanos().to_string().as_bytes()).map_err(|source| CacheFsError {
493 operation,
494 path: path.to_owned(),
495 source,
496 })
497}
498
499fn encode_system_time(path: &Path, timestamp: SystemTime) -> Result<Duration, CacheFsError> {
500 timestamp
501 .duration_since(UNIX_EPOCH)
502 .map_err(|source| CacheFsError {
503 operation: "encode cache time",
504 path: path.to_owned(),
505 source: io::Error::new(io::ErrorKind::InvalidInput, source),
506 })
507}
508
509fn decode_system_time(contents: &str) -> Option<SystemTime> {
510 let nanoseconds = contents.parse::<u128>().ok()?;
511 let seconds = u64::try_from(nanoseconds / 1_000_000_000).ok()?;
512 let subsecond_nanos = (nanoseconds % 1_000_000_000) as u32;
513 UNIX_EPOCH.checked_add(Duration::new(seconds, subsecond_nanos))
514}
515
516pub(super) fn prune_direct_child_directories(
517 cache_root: &Path,
518 policy: ArtifactCachePrunePolicy,
519 protected_entry: Option<&Path>,
520 is_eligible: impl Fn(&Path) -> bool,
521) -> Result<ArtifactCachePruneReport, CacheFsError> {
522 let mut entries = cache_entries(cache_root, is_eligible)?;
523 let bytes_before = entries
524 .iter()
525 .fold(0_u64, |total, entry| total.saturating_add(entry.bytes));
526 let mut report = ArtifactCachePruneReport {
527 entries_scanned: entries.len(),
528 entries_removed: 0,
529 bytes_before,
530 bytes_removed: 0,
531 uncommitted_directories_removed: 0,
532 uncommitted_bytes_removed: 0,
533 };
534 let now = SystemTime::now();
535
536 if let Some(max_age) = policy.max_age() {
537 for entry in &mut entries {
538 let age = now.duration_since(entry.last_used).unwrap_or_default();
539 if protected_entry != Some(entry.path.as_path()) && age > max_age {
540 remove_cache_entry(entry, &mut report)?;
541 }
542 }
543 }
544
545 if let Some(max_size_bytes) = policy.max_size_bytes()
546 && report.bytes_retained() > max_size_bytes
547 {
548 entries.sort_by(|left, right| {
549 left.last_used
550 .cmp(&right.last_used)
551 .then_with(|| left.path.cmp(&right.path))
552 });
553 for entry in &mut entries {
554 if report.bytes_retained() <= max_size_bytes {
555 break;
556 }
557 if protected_entry == Some(entry.path.as_path()) {
558 continue;
559 }
560 remove_cache_entry(entry, &mut report)?;
561 }
562 }
563
564 Ok(report)
565}
566
567pub(super) fn directory_logical_size(path: &Path) -> io::Result<u64> {
568 let mut total = 0_u64;
569 let mut pending = vec![path.to_owned()];
570 while let Some(current) = pending.pop() {
571 let metadata = fs::symlink_metadata(¤t)?;
572 if metadata.is_dir() {
573 for entry in fs::read_dir(¤t)? {
574 let path = entry?.path();
575 let metadata = fs::symlink_metadata(&path)?;
576 if metadata.is_dir() {
577 pending.push(path);
578 } else {
579 total = total.saturating_add(metadata.len());
580 }
581 }
582 } else {
583 total = total.saturating_add(metadata.len());
584 }
585 }
586 Ok(total)
587}
588
589pub(super) fn is_sha256_directory(path: &Path) -> bool {
590 path.file_name().is_some_and(|name| {
591 let bytes = name.as_encoded_bytes();
592 bytes.len() == 64 && bytes.iter().all(u8::is_ascii_hexdigit)
593 })
594}
595
596pub(super) fn remove_path_if_present(path: &Path) -> io::Result<()> {
597 let metadata = match fs::symlink_metadata(path) {
598 Ok(metadata) => metadata,
599 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
600 Err(error) => return Err(error),
601 };
602 if metadata.file_type().is_dir() {
603 fs::remove_dir_all(path)
604 } else {
605 fs::remove_file(path)
606 }
607}
608
609struct CacheEntry {
610 path: PathBuf,
611 bytes: u64,
612 last_used: SystemTime,
613 removed: bool,
614}
615
616impl std::fmt::Display for CacheFsError {
617 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
618 write!(
619 formatter,
620 "failed to {} at {}: {}",
621 self.operation,
622 self.path.display(),
623 self.source
624 )
625 }
626}
627
628impl std::error::Error for CacheFsError {
629 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
630 Some(&self.source)
631 }
632}
633
634fn cache_entries(
635 cache_root: &Path,
636 is_eligible: impl Fn(&Path) -> bool,
637) -> Result<Vec<CacheEntry>, CacheFsError> {
638 let read_dir = match fs::read_dir(cache_root) {
639 Ok(read_dir) => read_dir,
640 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
641 Err(source) => {
642 return Err(CacheFsError {
643 operation: "read cache directory",
644 path: cache_root.to_owned(),
645 source,
646 });
647 }
648 };
649 let mut entries = Vec::new();
650 for directory_entry in read_dir {
651 let directory_entry = directory_entry.map_err(|source| CacheFsError {
652 operation: "read cache entry",
653 path: cache_root.to_owned(),
654 source,
655 })?;
656 let path = directory_entry.path();
657 let file_type = directory_entry.file_type().map_err(|source| CacheFsError {
658 operation: "inspect cache entry",
659 path: path.clone(),
660 source,
661 })?;
662 if !file_type.is_dir() || !is_eligible(&path) {
663 continue;
664 }
665 let bytes = directory_logical_size(&path).map_err(|source| CacheFsError {
666 operation: "measure cache entry",
667 path: path.clone(),
668 source,
669 })?;
670 let last_used = cache_entry_last_used(&path).map_err(|source| CacheFsError {
671 operation: "read cache use time",
672 path: path.clone(),
673 source,
674 })?;
675 entries.push(CacheEntry {
676 path,
677 bytes,
678 last_used,
679 removed: false,
680 });
681 }
682 Ok(entries)
683}
684
685pub(super) fn cache_entry_last_used(path: &Path) -> io::Result<SystemTime> {
686 let marker = path.join(LAST_USED_FILE);
687 if let Ok(Some(contents)) = read_stamp_with_limit(&marker, MAX_TIMESTAMP_BYTES)
688 && let Some(timestamp) = decode_system_time(&contents)
689 {
690 return Ok(timestamp);
691 }
692 fs::metadata(path)?.modified()
693}
694
695fn remove_cache_entry(
696 entry: &mut CacheEntry,
697 report: &mut ArtifactCachePruneReport,
698) -> Result<(), CacheFsError> {
699 if entry.removed {
700 return Ok(());
701 }
702 let Some(_retention_lock) = try_lock_cache_file(&entry.path.join(RETENTION_LOCK_FILE))? else {
703 return Ok(());
704 };
705 remove_path_if_present(&entry.path).map_err(|source| CacheFsError {
706 operation: "prune cache entry",
707 path: entry.path.clone(),
708 source,
709 })?;
710 entry.removed = true;
711 report.entries_removed += 1;
712 report.bytes_removed = report.bytes_removed.saturating_add(entry.bytes);
713 Ok(())
714}
715
716#[cfg(test)]
717mod tests {
718 #[cfg(unix)]
719 use super::canonicalize_allow_missing;
720 use super::directory_logical_size;
721 use crate::artifacts::test_support::unique_temp_directory;
722 use std::{
723 fs,
724 time::{Duration, SystemTime, UNIX_EPOCH},
725 };
726
727 #[test]
728 fn last_use_markers_preserve_timestamps_and_bounded_fallbacks() {
729 let root = unique_temp_directory("bounded-last-use-marker");
730 let marker = root.join(super::LAST_USED_FILE);
731 let modified = || fs::metadata(&root).unwrap().modified().unwrap();
732 assert_eq!(super::cache_entry_last_used(&root).unwrap(), modified());
733 for timestamp in [
734 UNIX_EPOCH + Duration::from_nanos(123),
735 SystemTime::now() + Duration::from_secs(3600),
736 ] {
737 super::write_last_used(&root, timestamp).unwrap();
738 assert_eq!(super::cache_entry_last_used(&root).unwrap(), timestamp);
739 }
740 let overflowing_timestamp = u128::MAX.to_string();
741 for invalid in [
742 b"invalid".as_slice(),
743 overflowing_timestamp.as_bytes(),
744 &[0xff],
745 ] {
746 fs::write(&marker, invalid).unwrap();
747 assert_eq!(super::cache_entry_last_used(&root).unwrap(), modified());
748 }
749 fs::File::create(&marker)
750 .unwrap()
751 .set_len(1024 * 1024 * 1024)
752 .unwrap();
753 assert_eq!(super::cache_entry_last_used(&root).unwrap(), modified());
754 fs::remove_file(&marker).unwrap();
755 fs::create_dir(&marker).unwrap();
756 assert_eq!(super::cache_entry_last_used(&root).unwrap(), modified());
757 fs::remove_dir_all(root).unwrap();
758 }
759
760 #[test]
761 fn maintenance_markers_preserve_policy_intervals_and_read_errors() {
762 let root = unique_temp_directory("bounded-maintenance-marker");
763 let marker = root.join(super::LAST_MAINTENANCE_FILE);
764 let identity = super::ArtifactCachePrunePolicy::new().maintenance_identity();
765 let interval = Some(Duration::from_secs(3600));
766 let due = || super::cache_maintenance_due(&root, interval, &identity).unwrap();
767 assert!(due());
768 super::record_cache_maintenance(&root, &identity).unwrap();
769 assert!(!due());
770 assert!(super::cache_maintenance_due(&root, interval, "other-policy").unwrap());
771 assert!(super::cache_maintenance_due(&root, Some(Duration::ZERO), &identity).unwrap());
772
773 let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
774 for suffix in ["", "\r\n"] {
775 fs::write(&marker, format!("{}\r\n{identity}{suffix}", now.as_nanos())).unwrap();
776 assert!(!due());
777 }
778 for timestamp in [
779 "invalid".to_owned(),
780 "0".to_owned(),
781 (now + Duration::from_secs(3600)).as_nanos().to_string(),
782 ] {
783 fs::write(&marker, format!("{timestamp}\n{identity}\n")).unwrap();
784 assert!(due());
785 }
786 super::record_cache_maintenance(&root, &identity).unwrap();
787 fs::OpenOptions::new()
788 .write(true)
789 .open(&marker)
790 .unwrap()
791 .set_len(1024 * 1024 * 1024)
792 .unwrap();
793 assert!(due());
794
795 fs::write(&marker, [0xff]).unwrap();
796 let error = super::cache_maintenance_due(&root, interval, &identity).unwrap_err();
797 assert_eq!(error.operation, "read cache maintenance time");
798 assert_eq!(error.path, marker);
799 assert_eq!(error.source.kind(), std::io::ErrorKind::InvalidData);
800 fs::remove_file(&marker).unwrap();
801 fs::create_dir(&marker).unwrap();
802 assert!(super::cache_maintenance_due(&root, interval, &identity).is_err());
803 assert!(super::cache_maintenance_due(&root, None, &identity).unwrap());
804 fs::remove_dir_all(root).unwrap();
805 }
806
807 #[test]
808 fn cache_directory_tags_preserve_valid_standard_signatures() {
809 let root = unique_temp_directory("cache-tag-signatures");
810 let tag = root.join("CACHEDIR.TAG");
811 let signature = "Signature: 8a477f597d28d172789f06886806bc55";
812 for contents in [
813 signature.to_owned(),
814 format!("{signature}\r\n# Created by another application\r\n"),
815 format!("{signature}\n# {}\n", "comment".repeat(100_000)),
816 ] {
817 fs::write(&tag, &contents).unwrap();
818 super::ensure_cache_directory_tag(&root).unwrap();
819 assert_eq!(fs::read_to_string(&tag).unwrap(), contents);
820 }
821 for invalid in ["", &signature[..42], "Signature: incorrect"] {
822 fs::write(&tag, invalid).unwrap();
823 super::ensure_cache_directory_tag(&root).unwrap();
824 assert_eq!(
825 fs::read_to_string(&tag).unwrap(),
826 super::CACHE_DIRECTORY_TAG
827 );
828 }
829 fs::remove_dir_all(root).unwrap();
830 }
831
832 #[test]
833 #[cfg(unix)]
834 fn cache_directory_tag_replaces_symlinks_without_changing_referents() {
835 let root = unique_temp_directory("cache-tag-symlink");
836 let referent = root.join("other-application-tag");
837 let contents = "Signature: 8a477f597d28d172789f06886806bc55\n# Preserve this file\n";
838 fs::write(&referent, contents).unwrap();
839 let tag = root.join("CACHEDIR.TAG");
840 std::os::unix::fs::symlink(&referent, &tag).unwrap();
841 super::ensure_cache_directory_tag(&root).unwrap();
842 assert!(fs::symlink_metadata(&tag).unwrap().file_type().is_file());
843 assert_eq!(fs::read_to_string(&referent).unwrap(), contents);
844 assert_eq!(
845 fs::read_to_string(&tag).unwrap(),
846 super::CACHE_DIRECTORY_TAG
847 );
848
849 fs::remove_file(&tag).unwrap();
850 fs::create_dir(&tag).unwrap();
851 let error = super::ensure_cache_directory_tag(&root).unwrap_err();
852 assert_eq!(error.operation, "write cache directory tag");
853 assert!(tag.is_dir());
854 fs::remove_dir_all(root).unwrap();
855 }
856
857 #[test]
858 fn directory_size_sums_wide_and_nested_files() {
859 let root = unique_temp_directory("directory-logical-size");
860 assert_eq!(directory_logical_size(&root).unwrap(), 0);
861 fs::create_dir_all(root.join("wide")).unwrap();
862 fs::create_dir_all(root.join("nested/deep/empty")).unwrap();
863 let mut expected = 0;
864 for index in 0..128 {
865 let bytes = vec![42; index % 13];
866 fs::write(root.join("wide").join(index.to_string()), &bytes).unwrap();
867 expected += bytes.len() as u64;
868 }
869 let sparse = root.join("nested/deep/sparse");
870 fs::File::create(&sparse)
871 .unwrap()
872 .set_len(1024 * 1024)
873 .unwrap();
874 assert_eq!(directory_logical_size(&sparse).unwrap(), 1024 * 1024);
875 assert_eq!(
876 directory_logical_size(&root).unwrap(),
877 expected + 1024 * 1024
878 );
879 assert_eq!(
880 directory_logical_size(&root.join("missing"))
881 .unwrap_err()
882 .kind(),
883 std::io::ErrorKind::NotFound,
884 );
885 fs::remove_dir_all(root).unwrap();
886 }
887
888 #[test]
889 #[cfg(unix)]
890 fn directory_size_counts_symlinks_without_following_them() {
891 let root = unique_temp_directory("directory-size-symlinks");
892 let walked = root.join("walked");
893 fs::create_dir_all(&walked).unwrap();
894 fs::create_dir_all(root.join("external")).unwrap();
895 fs::write(root.join("external/payload"), vec![42; 4096]).unwrap();
896 fs::write(root.join("outside-file"), vec![42; 4096]).unwrap();
897 fs::write(walked.join("payload"), b"abc").unwrap();
898 let targets = ["../external", "../outside-file", "missing", "."];
899 for (index, target) in targets.iter().enumerate() {
900 std::os::unix::fs::symlink(target, walked.join(index.to_string())).unwrap();
901 }
902 let expected = 3 + targets
903 .iter()
904 .map(|target| target.len() as u64)
905 .sum::<u64>();
906 assert_eq!(directory_logical_size(&walked).unwrap(), expected);
907 assert_eq!(
908 directory_logical_size(&walked.join("0")).unwrap(),
909 targets[0].len() as u64,
910 );
911 fs::remove_dir_all(root).unwrap();
912 }
913
914 #[test]
915 #[cfg(unix)]
916 fn missing_parent_traversal_resumes_existing_symlink_resolution() {
917 let root = unique_temp_directory("canonical-missing-parent");
918 let target = root.join("real");
919 fs::create_dir_all(&target).unwrap();
920 std::os::unix::fs::symlink(&target, root.join("alias")).unwrap();
921 let path = root.join("missing/../alias/generated/nested/../output");
922 assert_eq!(
923 canonicalize_allow_missing(&path).unwrap(),
924 target.canonicalize().unwrap().join("generated/output")
925 );
926 assert_eq!(
927 canonicalize_allow_missing(&root.join("alias/../other/output")).unwrap(),
928 root.canonicalize().unwrap().join("other/output")
929 );
930 fs::remove_dir_all(root).unwrap();
931 }
932}