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