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)]
265pub(super) struct CacheFsError {
266 pub(super) operation: &'static str,
267 pub(super) path: PathBuf,
268 pub(super) source: io::Error,
269}
270
271pub(super) fn ensure_cache_directory_tag(cache_root: &Path) -> Result<(), CacheFsError> {
272 ic_host_fs::cache_tag::ensure(cache_root, CACHE_DIRECTORY_TAG_SUFFIX.as_bytes(), 0o666).map_err(
273 |source| CacheFsError {
274 operation: "write cache directory tag",
275 path: cache_root.join("CACHEDIR.TAG"),
276 source: io::Error::other(source),
277 },
278 )
279}
280
281pub(super) fn lock_cache_file(path: &Path) -> Result<(File, Duration), CacheFsError> {
282 let file = open_cache_lock_file(path)?;
283 let started = Instant::now();
284 ic_host_fs::durable::lock_exclusive(&file).map_err(|source| CacheFsError {
285 operation: "lock cache",
286 path: path.to_owned(),
287 source,
288 })?;
289 Ok((file, started.elapsed()))
290}
291
292pub(super) fn lock_cache_file_with_wait_observer(
293 path: &Path,
294 poll_interval: Duration,
295 mut observer: impl FnMut(Duration),
296) -> Result<(File, Duration), CacheFsError> {
297 let file = open_cache_lock_file(path)?;
298 let wait = ic_host_fs::durable::lock_exclusive_with_wait(
299 &file,
300 poll_interval.min(Duration::from_millis(25)),
301 |elapsed| {
302 observer(elapsed);
303 Ok(())
304 },
305 )
306 .map_err(|source| CacheFsError {
307 operation: "try lock cache",
308 path: path.to_owned(),
309 source,
310 })?;
311 Ok((file, wait))
312}
313
314pub(super) fn try_lock_cache_file(path: &Path) -> Result<Option<File>, CacheFsError> {
315 let result = ic_host_fs::durable::open_regular_lock_file_with_parents(path)
316 .map_err(io::Error::from)
317 .and_then(|file| ic_host_fs::durable::try_lock_exclusive(&file).map(|()| file));
318 match result {
319 Ok(file) => Ok(Some(file)),
320 Err(error) if error.kind() == io::ErrorKind::WouldBlock => Ok(None),
321 Err(source) => Err(CacheFsError {
322 operation: "try lock cache",
323 path: path.to_owned(),
324 source,
325 }),
326 }
327}
328
329fn open_cache_lock_file(path: &Path) -> Result<File, CacheFsError> {
330 ic_host_fs::durable::open_regular_lock_file_with_parents(path)
331 .map_err(io::Error::from)
332 .map_err(|source| CacheFsError {
333 operation: "open cache lock",
334 path: path.to_owned(),
335 source,
336 })
337}
338
339pub(super) fn record_cache_entry_use(path: &Path) -> Result<(), CacheFsError> {
340 write_last_used(path, SystemTime::now())
341}
342
343pub(super) fn cache_maintenance_due(
344 path: &Path,
345 minimum_interval: Duration,
346 maintenance_identity: &str,
347) -> Result<bool, CacheFsError> {
348 if minimum_interval.is_zero() {
349 return Ok(true);
350 }
351 let marker = path.join(LAST_MAINTENANCE_FILE);
352 let maximum_len = MAX_TIMESTAMP_BYTES + maintenance_identity.len() + 4;
354 let contents = match read_stamp_with_limit(&marker, maximum_len) {
355 Ok(Some(contents)) => contents,
356 Ok(None) => return Ok(true),
357 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(true),
358 Err(source) => {
359 return Err(CacheFsError {
360 operation: "read cache maintenance time",
361 path: marker,
362 source,
363 });
364 }
365 };
366 let mut lines = contents.lines();
367 let Some(last_maintenance) = lines.next().and_then(decode_system_time) else {
368 return Ok(true);
369 };
370 if lines.next() != Some(maintenance_identity) {
371 return Ok(true);
372 }
373 Ok(SystemTime::now()
374 .duration_since(last_maintenance)
375 .map_or(true, |elapsed| elapsed >= minimum_interval))
376}
377
378pub(super) fn record_cache_maintenance(
379 path: &Path,
380 maintenance_identity: &str,
381) -> Result<(), CacheFsError> {
382 let marker = path.join(LAST_MAINTENANCE_FILE);
383 let elapsed = encode_system_time(&marker, SystemTime::now())?;
384 let contents = format!("{}\n{maintenance_identity}\n", elapsed.as_nanos());
385 ic_host_fs::durable::write_bytes(
386 &marker,
387 ic_host_fs::durable::WriteOptions {
388 mode: ic_host_fs::durable::PublicationMode::Replace,
389 permissions: 0o666,
390 },
391 contents.as_bytes(),
392 )
393 .map_err(|source| CacheFsError {
394 operation: "record cache maintenance time",
395 path: marker,
396 source: io::Error::other(source),
397 })
398}
399
400pub(super) fn perform_scheduled_cache_maintenance(
401 path: &Path,
402 minimum_interval: Duration,
403 maintenance_identity: &str,
404 maintenance: impl FnOnce() -> Result<ArtifactCachePruneReport, String>,
405) -> (Option<ArtifactCacheMaintenance>, Option<Duration>) {
406 let started = Instant::now();
407 match cache_maintenance_due(path, minimum_interval, maintenance_identity) {
408 Ok(false) => return (None, Some(started.elapsed())),
409 Ok(true) => {}
410 Err(error) => {
411 return (
412 Some(ArtifactCacheMaintenance::PruneFailed {
413 message: error.to_string(),
414 }),
415 Some(started.elapsed()),
416 );
417 }
418 }
419
420 let result = maintenance();
421 let marker = record_cache_maintenance(path, maintenance_identity);
422 let outcome = match (result, marker) {
423 (Ok(report), Ok(())) => ArtifactCacheMaintenance::Pruned(report),
424 (Err(message), Ok(())) => ArtifactCacheMaintenance::PruneFailed { message },
425 (Ok(_), Err(error)) => ArtifactCacheMaintenance::PruneFailed {
426 message: error.to_string(),
427 },
428 (Err(message), Err(marker)) => ArtifactCacheMaintenance::PruneFailed {
429 message: format!(
430 "{message}; additionally failed to record the maintenance attempt: {marker}"
431 ),
432 },
433 };
434 (Some(outcome), Some(started.elapsed()))
435}
436
437pub(super) fn write_last_used(path: &Path, last_used: SystemTime) -> Result<(), CacheFsError> {
438 let marker = path.join(LAST_USED_FILE);
439 write_system_time(&marker, last_used, "record cache use time")
440}
441
442fn write_system_time(
443 path: &Path,
444 timestamp: SystemTime,
445 operation: &'static str,
446) -> Result<(), CacheFsError> {
447 let elapsed = encode_system_time(path, timestamp)?;
448 ic_host_fs::durable::write_bytes(
449 path,
450 ic_host_fs::durable::WriteOptions {
451 mode: ic_host_fs::durable::PublicationMode::Replace,
452 permissions: 0o666,
453 },
454 elapsed.as_nanos().to_string().as_bytes(),
455 )
456 .map_err(|source| CacheFsError {
457 operation,
458 path: path.to_owned(),
459 source: io::Error::other(source),
460 })
461}
462
463fn encode_system_time(path: &Path, timestamp: SystemTime) -> Result<Duration, CacheFsError> {
464 timestamp
465 .duration_since(UNIX_EPOCH)
466 .map_err(|source| CacheFsError {
467 operation: "encode cache time",
468 path: path.to_owned(),
469 source: io::Error::new(io::ErrorKind::InvalidInput, source),
470 })
471}
472
473fn decode_system_time(contents: &str) -> Option<SystemTime> {
474 let nanoseconds = contents.parse::<u128>().ok()?;
475 let seconds = u64::try_from(nanoseconds / 1_000_000_000).ok()?;
476 let subsecond_nanos = (nanoseconds % 1_000_000_000) as u32;
477 UNIX_EPOCH.checked_add(Duration::new(seconds, subsecond_nanos))
478}
479
480pub(super) fn prune_direct_child_directories(
481 cache_root: &Path,
482 policy: CacheRetentionLimits,
483 protected_entry: Option<&Path>,
484 is_eligible: impl Fn(&Path) -> bool,
485) -> Result<ArtifactCachePruneReport, CacheFsError> {
486 let mut entries = cache_entries(cache_root, is_eligible)?;
487 let bytes_before = entries
488 .iter()
489 .fold(0_u64, |total, entry| total.saturating_add(entry.bytes));
490 let mut report = ArtifactCachePruneReport {
491 entries_scanned: entries.len(),
492 entries_removed: 0,
493 bytes_before,
494 bytes_removed: 0,
495 uncommitted_directories_removed: 0,
496 uncommitted_bytes_removed: 0,
497 };
498 let now = SystemTime::now();
499
500 if let Some(max_age) = policy.max_age() {
501 for entry in &mut entries {
502 let age = now.duration_since(entry.last_used).unwrap_or_default();
503 if protected_entry != Some(entry.path.as_path()) && age > max_age {
504 remove_cache_entry(entry, &mut report)?;
505 }
506 }
507 }
508
509 if let Some(max_size_bytes) = policy.max_size_bytes()
510 && report.bytes_retained() > max_size_bytes
511 {
512 entries.sort_by(|left, right| {
513 left.last_used
514 .cmp(&right.last_used)
515 .then_with(|| left.path.cmp(&right.path))
516 });
517 for entry in &mut entries {
518 if report.bytes_retained() <= max_size_bytes {
519 break;
520 }
521 if protected_entry == Some(entry.path.as_path()) {
522 continue;
523 }
524 remove_cache_entry(entry, &mut report)?;
525 }
526 }
527
528 Ok(report)
529}
530
531pub(super) fn is_sha256_directory(path: &Path) -> bool {
532 path.file_name().is_some_and(|name| {
533 let bytes = name.as_encoded_bytes();
534 bytes.len() == 64 && bytes.iter().all(u8::is_ascii_hexdigit)
535 })
536}
537
538struct CacheEntry {
539 path: PathBuf,
540 bytes: u64,
541 last_used: SystemTime,
542 removed: bool,
543}
544
545impl std::fmt::Display for CacheFsError {
546 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
547 write!(
548 formatter,
549 "failed to {} at {}: {}",
550 self.operation,
551 self.path.display(),
552 self.source
553 )
554 }
555}
556
557impl std::error::Error for CacheFsError {
558 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
559 Some(&self.source)
560 }
561}
562
563fn cache_entries(
564 cache_root: &Path,
565 is_eligible: impl Fn(&Path) -> bool,
566) -> Result<Vec<CacheEntry>, CacheFsError> {
567 let read_dir = match fs::read_dir(cache_root) {
568 Ok(read_dir) => read_dir,
569 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
570 Err(source) => {
571 return Err(CacheFsError {
572 operation: "read cache directory",
573 path: cache_root.to_owned(),
574 source,
575 });
576 }
577 };
578 let mut entries = Vec::new();
579 for directory_entry in read_dir {
580 let directory_entry = directory_entry.map_err(|source| CacheFsError {
581 operation: "read cache entry",
582 path: cache_root.to_owned(),
583 source,
584 })?;
585 let path = directory_entry.path();
586 let file_type = directory_entry.file_type().map_err(|source| CacheFsError {
587 operation: "inspect cache entry",
588 path: path.clone(),
589 source,
590 })?;
591 if !file_type.is_dir() || !is_eligible(&path) {
592 continue;
593 }
594 let bytes = directory_logical_size(&path).map_err(|source| CacheFsError {
595 operation: "measure cache entry",
596 path: path.clone(),
597 source,
598 })?;
599 let last_used = cache_entry_last_used(&path).map_err(|source| CacheFsError {
600 operation: "read cache use time",
601 path: path.clone(),
602 source,
603 })?;
604 entries.push(CacheEntry {
605 path,
606 bytes,
607 last_used,
608 removed: false,
609 });
610 }
611 Ok(entries)
612}
613
614pub(super) fn cache_entry_last_used(path: &Path) -> io::Result<SystemTime> {
615 let marker = path.join(LAST_USED_FILE);
616 if let Ok(Some(contents)) = read_stamp_with_limit(&marker, MAX_TIMESTAMP_BYTES)
617 && let Some(timestamp) = decode_system_time(&contents)
618 {
619 return Ok(timestamp);
620 }
621 fs::metadata(path)?.modified()
622}
623
624fn remove_cache_entry(
625 entry: &mut CacheEntry,
626 report: &mut ArtifactCachePruneReport,
627) -> Result<(), CacheFsError> {
628 if entry.removed {
629 return Ok(());
630 }
631 let Some(_retention_lock) = try_lock_cache_file(&entry.path.join(RETENTION_LOCK_FILE))? else {
632 return Ok(());
633 };
634 remove_path_if_present(&entry.path).map_err(|source| CacheFsError {
635 operation: "prune cache entry",
636 path: entry.path.clone(),
637 source,
638 })?;
639 entry.removed = true;
640 report.entries_removed += 1;
641 report.bytes_removed = report.bytes_removed.saturating_add(entry.bytes);
642 Ok(())
643}
644
645#[cfg(test)]
646mod tests {
647 use crate::fs_fixture::unique_temp_directory;
648 use std::{
649 fs,
650 time::{Duration, SystemTime, UNIX_EPOCH},
651 };
652
653 #[cfg(unix)]
654 #[test]
655 fn cache_lock_admission_preserves_bytes_and_returns_an_unlocked_close_on_exec_file() {
656 let root = unique_temp_directory("cache-lock-admission");
657 let path = root.join("nested/lock");
658 let file = super::open_cache_lock_file(&path).unwrap();
659 assert!(super::try_lock_cache_file(&path).unwrap().is_some());
660 let flags = rustix::io::fcntl_getfd(&file).unwrap();
661 assert!(flags.contains(rustix::io::FdFlags::CLOEXEC));
662 drop(file);
663 fs::write(&path, b"retained lock bytes").unwrap();
664 drop(super::lock_cache_file(&path).unwrap());
665 assert_eq!(fs::read(&path).unwrap(), b"retained lock bytes");
666 fs::remove_dir_all(root).unwrap();
667 }
668
669 #[cfg(unix)]
670 #[test]
671 fn cache_lock_callers_refuse_redirected_final_entries() {
672 use std::os::unix::fs::symlink;
673
674 let root = unique_temp_directory("cache-lock-redirected");
675 let target = root.join("target");
676 fs::write(&target, b"target bytes").unwrap();
677 let redirected = root.join("redirected");
678 symlink(&target, &redirected).unwrap();
679 for result in [
680 super::open_cache_lock_file(&redirected),
681 super::lock_cache_file(&redirected).map(|(file, _)| file),
682 super::lock_cache_file_with_wait_observer(
683 &redirected,
684 Duration::from_millis(5),
685 |_| panic!("redirected lock reached acquisition"),
686 )
687 .map(|(file, _)| file),
688 ] {
689 let error = result.unwrap_err();
690 assert_eq!(error.path, redirected);
691 assert!(matches!(
692 error.source.get_ref().and_then(|cause| cause
693 .downcast_ref::<ic_host_fs::durable::RegularFileLockError>(
694 )),
695 Some(ic_host_fs::durable::RegularFileLockError::NotRegular)
696 ));
697 }
698 symlink(&target, root.join(super::RETENTION_LOCK_FILE)).unwrap();
699 assert!(super::RetainedCacheEntry::acquire(&root).is_err());
700 assert_eq!(fs::read(&target).unwrap(), b"target bytes");
701 fs::remove_dir_all(root).unwrap();
702 }
703
704 #[cfg(unix)]
705 #[test]
706 fn nonblocking_cache_locks_preserve_contention_and_reject_redirected_files() {
707 use std::{os::unix::fs::symlink, process::Command};
708 const CHILD_ENV: &str = "IC_TESTKIT_NONBLOCKING_CACHE_LOCK_CHILD";
709
710 if std::env::var_os(CHILD_ENV).is_none() {
711 let thread = std::thread::current();
715 let test_name = thread.name().expect("libtest names each test thread");
716 let output = Command::new(std::env::current_exe().unwrap())
717 .args(["--exact", test_name, "--test-threads=1"])
718 .env(CHILD_ENV, "1")
719 .output()
720 .unwrap();
721 assert!(
722 output.status.success(),
723 "isolated nonblocking cache lock test failed: {}{}",
724 String::from_utf8_lossy(&output.stdout),
725 String::from_utf8_lossy(&output.stderr)
726 );
727 return;
728 }
729
730 let root = unique_temp_directory("nonblocking-cache-lock-admission");
731 let lock_path = root.join("lock");
732 fs::write(&lock_path, b"existing lock bytes").unwrap();
733 let held = super::try_lock_cache_file(&lock_path).unwrap().unwrap();
734 assert!(super::try_lock_cache_file(&lock_path).unwrap().is_none());
735 assert_eq!(fs::read(&lock_path).unwrap(), b"existing lock bytes");
736 drop(held);
737 assert!(super::try_lock_cache_file(&lock_path).unwrap().is_some());
738
739 let redirected = root.join("redirected-lock");
740 symlink(&lock_path, &redirected).unwrap();
741 let error = super::try_lock_cache_file(&redirected).unwrap_err();
742 assert_eq!(error.path, redirected);
743 assert!(matches!(
744 error.source.get_ref().and_then(|cause| cause
745 .downcast_ref::<ic_host_fs::durable::RegularFileLockError>(
746 )),
747 Some(ic_host_fs::durable::RegularFileLockError::NotRegular)
748 ));
749 assert_eq!(fs::read(&lock_path).unwrap(), b"existing lock bytes");
750 fs::remove_dir_all(root).unwrap();
751 }
752
753 #[cfg(unix)]
754 #[test]
755 fn final_retention_owner_releases_lock_with_a_duplicate_descriptor_open() {
756 let root = unique_temp_directory("retention-duplicate-descriptor");
757 let retained = super::RetainedCacheEntry::acquire(&root).unwrap();
758 #[expect(
760 clippy::used_underscore_binding,
761 reason = "inspect the RAII guard to verify inherited-descriptor retention"
762 )]
763 let inherited = retained._lock.file().try_clone().unwrap();
764 let clone = retained.clone();
765 let independently_retained = super::RetainedCacheEntry::acquire(&root).unwrap();
766 let lock_path = root.join(super::RETENTION_LOCK_FILE);
767 let available = || super::try_lock_cache_file(&lock_path).unwrap().is_some();
768 drop(retained);
769 assert!(!available(), "a record clone still retains the entry");
770 drop(clone);
771 assert!(
772 !available(),
773 "an independent acquisition still retains the entry"
774 );
775 drop(independently_retained);
776 assert!(
777 available(),
778 "descriptor duplication must not extend record ownership"
779 );
780 drop(inherited);
781 fs::remove_dir_all(root).unwrap();
782 }
783
784 #[test]
785 fn last_use_markers_preserve_timestamps_and_bounded_fallbacks() {
786 let root = unique_temp_directory("bounded-last-use-marker");
787 let marker = root.join(super::LAST_USED_FILE);
788 let modified = || fs::metadata(&root).unwrap().modified().unwrap();
789 assert_eq!(super::cache_entry_last_used(&root).unwrap(), modified());
790 for timestamp in [
791 UNIX_EPOCH + Duration::from_nanos(123),
792 SystemTime::now() + Duration::from_secs(3600),
793 ] {
794 super::write_last_used(&root, timestamp).unwrap();
795 assert_eq!(super::cache_entry_last_used(&root).unwrap(), timestamp);
796 }
797 let overflowing_timestamp = u128::MAX.to_string();
798 for invalid in [
799 b"invalid".as_slice(),
800 overflowing_timestamp.as_bytes(),
801 &[0xff],
802 ] {
803 fs::write(&marker, invalid).unwrap();
804 assert_eq!(super::cache_entry_last_used(&root).unwrap(), modified());
805 }
806 fs::File::create(&marker)
807 .unwrap()
808 .set_len(1024 * 1024 * 1024)
809 .unwrap();
810 assert_eq!(super::cache_entry_last_used(&root).unwrap(), modified());
811 fs::remove_file(&marker).unwrap();
812 fs::create_dir(&marker).unwrap();
813 assert_eq!(super::cache_entry_last_used(&root).unwrap(), modified());
814 fs::remove_dir_all(root).unwrap();
815 }
816
817 #[test]
818 fn maintenance_markers_preserve_policy_intervals_and_read_errors() {
819 let root = unique_temp_directory("bounded-maintenance-marker");
820 let marker = root.join(super::LAST_MAINTENANCE_FILE);
821 let identity = super::CacheRetentionLimits::new().maintenance_identity();
822 let interval = Duration::from_secs(3600);
823 let due = || super::cache_maintenance_due(&root, interval, &identity).unwrap();
824 assert!(due());
825 super::record_cache_maintenance(&root, &identity).unwrap();
826 assert!(!due());
827 assert!(super::cache_maintenance_due(&root, interval, "other-policy").unwrap());
828 assert!(super::cache_maintenance_due(&root, Duration::ZERO, &identity).unwrap());
829
830 let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
831 for suffix in ["", "\r\n"] {
832 fs::write(&marker, format!("{}\r\n{identity}{suffix}", now.as_nanos())).unwrap();
833 assert!(!due());
834 }
835 for timestamp in [
836 "invalid".to_owned(),
837 "0".to_owned(),
838 (now + Duration::from_secs(3600)).as_nanos().to_string(),
839 ] {
840 fs::write(&marker, format!("{timestamp}\n{identity}\n")).unwrap();
841 assert!(due());
842 }
843 super::record_cache_maintenance(&root, &identity).unwrap();
844 fs::OpenOptions::new()
845 .write(true)
846 .open(&marker)
847 .unwrap()
848 .set_len(1024 * 1024 * 1024)
849 .unwrap();
850 assert!(due());
851
852 fs::write(&marker, [0xff]).unwrap();
853 assert!(super::cache_maintenance_due(&root, Duration::ZERO, &identity).unwrap());
854 let error = super::cache_maintenance_due(&root, interval, &identity).unwrap_err();
855 assert_eq!(error.operation, "read cache maintenance time");
856 assert_eq!(error.path, marker);
857 assert_eq!(error.source.kind(), std::io::ErrorKind::InvalidData);
858 fs::remove_file(&marker).unwrap();
859 fs::create_dir(&marker).unwrap();
860 assert!(super::cache_maintenance_due(&root, interval, &identity).is_err());
861 assert!(super::cache_maintenance_due(&root, Duration::ZERO, &identity).unwrap());
862 fs::remove_dir_all(root).unwrap();
863 }
864
865 #[test]
866 fn cache_directory_tags_preserve_valid_standard_signatures() {
867 let root = unique_temp_directory("cache-tag-signatures");
868 let tag = root.join("CACHEDIR.TAG");
869 let signature = ic_host_fs::cache_tag::SIGNATURE;
870 for contents in [
871 signature.to_owned(),
872 format!("{signature}\r\n# Created by another application\r\n"),
873 format!("{signature}\n# {}\n", "comment".repeat(100_000)),
874 ] {
875 fs::write(&tag, &contents).unwrap();
876 super::ensure_cache_directory_tag(&root).unwrap();
877 assert_eq!(fs::read_to_string(&tag).unwrap(), contents);
878 }
879 for invalid in ["", &signature[..42], "Signature: incorrect"] {
880 fs::write(&tag, invalid).unwrap();
881 super::ensure_cache_directory_tag(&root).unwrap();
882 assert_eq!(
883 fs::read_to_string(&tag).unwrap(),
884 format!("{signature}{}", super::CACHE_DIRECTORY_TAG_SUFFIX)
885 );
886 }
887 fs::remove_dir_all(root).unwrap();
888 }
889
890 #[test]
891 #[cfg(unix)]
892 fn cache_directory_tag_replaces_symlinks_without_changing_referents() {
893 let root = unique_temp_directory("cache-tag-symlink");
894 let referent = root.join("other-application-tag");
895 let contents = "Signature: 8a477f597d28d172789f06886806bc55\n# Preserve this file\n";
896 fs::write(&referent, contents).unwrap();
897 let tag = root.join("CACHEDIR.TAG");
898 std::os::unix::fs::symlink(&referent, &tag).unwrap();
899 super::ensure_cache_directory_tag(&root).unwrap();
900 assert!(fs::symlink_metadata(&tag).unwrap().file_type().is_file());
901 assert_eq!(fs::read_to_string(&referent).unwrap(), contents);
902 assert_eq!(
903 fs::read_to_string(&tag).unwrap(),
904 format!(
905 "{}{}",
906 ic_host_fs::cache_tag::SIGNATURE,
907 super::CACHE_DIRECTORY_TAG_SUFFIX
908 )
909 );
910
911 fs::remove_file(&tag).unwrap();
912 fs::create_dir(&tag).unwrap();
913 let error = super::ensure_cache_directory_tag(&root).unwrap_err();
914 assert!(matches!(
915 error
916 .source
917 .get_ref()
918 .unwrap()
919 .downcast_ref::<ic_host_fs::durable::NamedWriteError<std::io::Error>>()
920 .unwrap(),
921 ic_host_fs::durable::NamedWriteError::BeforePublication {
922 cleanup_error: None,
923 ..
924 }
925 ));
926 assert_eq!(error.operation, "write cache directory tag");
927 assert_eq!(error.path, tag);
928 assert!(tag.is_dir());
929 fs::remove_dir_all(root).unwrap();
930 }
931}