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