1use std::{
2 fs::{self, File},
3 io::{self, Read as _},
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::owned_directory::remove_path_if_present;
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, usize::MAX)
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 _ = ic_host_fs::durable::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 ic_host_fs::durable::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)]
128pub struct CacheRetentionLimits {
129 max_age: Option<Duration>,
130 max_size_bytes: Option<u64>,
131}
132
133#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
135pub struct ArtifactCachePruneReport {
136 entries_scanned: usize,
137 entries_removed: usize,
138 bytes_before: u64,
139 bytes_removed: u64,
140 uncommitted_directories_removed: usize,
141 uncommitted_bytes_removed: u64,
142}
143
144#[non_exhaustive]
146#[derive(Clone, Debug, Eq, PartialEq)]
147pub enum ArtifactCacheMaintenance {
148 Pruned(ArtifactCachePruneReport),
150 PruneFailed {
152 message: String,
154 },
155}
156
157impl CacheRetentionLimits {
158 #[must_use]
160 pub const fn new() -> Self {
161 Self {
162 max_age: None,
163 max_size_bytes: None,
164 }
165 }
166
167 #[must_use]
169 pub const fn with_max_age(mut self, max_age: Duration) -> Self {
170 self.max_age = Some(max_age);
171 self
172 }
173
174 #[must_use]
176 pub const fn with_max_size_bytes(mut self, bytes: u64) -> Self {
177 self.max_size_bytes = Some(bytes);
178 self
179 }
180
181 #[must_use]
183 pub const fn max_age(self) -> Option<Duration> {
184 self.max_age
185 }
186
187 #[must_use]
189 pub const fn max_size_bytes(self) -> Option<u64> {
190 self.max_size_bytes
191 }
192
193 pub(super) fn maintenance_identity(self) -> String {
194 format!(
195 "age={:?};size={:?}",
196 self.max_age.map(|duration| duration.as_nanos()),
197 self.max_size_bytes
198 )
199 }
200}
201
202impl ArtifactCachePruneReport {
203 #[must_use]
205 pub const fn entries_scanned(self) -> usize {
206 self.entries_scanned
207 }
208
209 #[must_use]
211 pub const fn entries_removed(self) -> usize {
212 self.entries_removed
213 }
214
215 #[must_use]
217 pub const fn entries_retained(self) -> usize {
218 self.entries_scanned.saturating_sub(self.entries_removed)
219 }
220
221 #[must_use]
223 pub const fn bytes_before(self) -> u64 {
224 self.bytes_before
225 }
226
227 #[must_use]
229 pub const fn bytes_removed(self) -> u64 {
230 self.bytes_removed
231 }
232
233 #[must_use]
235 pub const fn bytes_retained(self) -> u64 {
236 self.bytes_before.saturating_sub(self.bytes_removed)
237 }
238
239 #[must_use]
241 pub const fn uncommitted_directories_removed(self) -> usize {
242 self.uncommitted_directories_removed
243 }
244
245 #[must_use]
247 pub const fn uncommitted_bytes_removed(self) -> u64 {
248 self.uncommitted_bytes_removed
249 }
250
251 pub(super) const fn record_uncommitted_removal(&mut self, bytes: u64) {
252 self.uncommitted_directories_removed += 1;
253 self.uncommitted_bytes_removed = self.uncommitted_bytes_removed.saturating_add(bytes);
254 }
255}
256
257impl ArtifactCacheMaintenance {
258 #[must_use]
260 pub const fn prune_report(&self) -> Option<ArtifactCachePruneReport> {
261 match self {
262 Self::Pruned(report) => Some(*report),
263 Self::PruneFailed { .. } => None,
264 }
265 }
266
267 #[must_use]
269 pub fn failure_message(&self) -> Option<&str> {
270 match self {
271 Self::Pruned(_) => None,
272 Self::PruneFailed { message } => Some(message),
273 }
274 }
275}
276
277#[derive(Debug)]
278pub(super) struct CacheFsError {
279 pub(super) operation: &'static str,
280 pub(super) path: PathBuf,
281 pub(super) source: io::Error,
282}
283
284pub(super) fn ensure_cache_directory_tag(cache_root: &Path) -> Result<(), CacheFsError> {
285 let path = cache_root.join("CACHEDIR.TAG");
286 let mut signature = [0_u8; CACHE_DIRECTORY_TAG_SIGNATURE.len()];
289 if fs::symlink_metadata(&path).is_ok_and(|metadata| metadata.file_type().is_file())
290 && File::open(&path)
291 .and_then(|mut file| file.read_exact(&mut signature))
292 .is_ok()
293 && signature == CACHE_DIRECTORY_TAG_SIGNATURE.as_bytes()
294 {
295 return Ok(());
296 }
297 ic_host_fs::durable::write_bytes(
298 &path,
299 ic_host_fs::durable::WriteOptions {
300 mode: ic_host_fs::durable::PublicationMode::Replace,
301 permissions: 0o666,
302 },
303 CACHE_DIRECTORY_TAG.as_bytes(),
304 )
305 .map_err(|source| CacheFsError {
306 operation: "write cache directory tag",
307 path,
308 source: io::Error::other(source),
309 })
310}
311
312pub(super) fn lock_cache_file(path: &Path) -> Result<(File, Duration), CacheFsError> {
313 let file = open_cache_lock_file(path)?;
314 let started = Instant::now();
315 ic_host_fs::durable::lock_exclusive(&file).map_err(|source| CacheFsError {
316 operation: "lock cache",
317 path: path.to_owned(),
318 source,
319 })?;
320 Ok((file, started.elapsed()))
321}
322
323pub(super) fn lock_cache_file_with_wait_observer(
324 path: &Path,
325 poll_interval: Duration,
326 mut observer: impl FnMut(Duration),
327) -> Result<(File, Duration), CacheFsError> {
328 let file = open_cache_lock_file(path)?;
329 let wait = ic_host_fs::durable::lock_exclusive_with_wait(
330 &file,
331 poll_interval.min(Duration::from_millis(25)),
332 |elapsed| {
333 observer(elapsed);
334 Ok(())
335 },
336 )
337 .map_err(|source| CacheFsError {
338 operation: "try lock cache",
339 path: path.to_owned(),
340 source,
341 })?;
342 Ok((file, wait))
343}
344
345pub(super) fn try_lock_cache_file(path: &Path) -> Result<Option<File>, CacheFsError> {
346 let result = ic_host_fs::durable::open_regular_lock_file_with_parents(path)
347 .map_err(io::Error::from)
348 .and_then(|file| ic_host_fs::durable::try_lock_exclusive(&file).map(|()| file));
349 match result {
350 Ok(file) => Ok(Some(file)),
351 Err(error) if error.kind() == io::ErrorKind::WouldBlock => Ok(None),
352 Err(source) => Err(CacheFsError {
353 operation: "try lock cache",
354 path: path.to_owned(),
355 source,
356 }),
357 }
358}
359
360fn open_cache_lock_file(path: &Path) -> Result<File, CacheFsError> {
361 ic_host_fs::durable::open_regular_lock_file_with_parents(path)
362 .map_err(io::Error::from)
363 .map_err(|source| CacheFsError {
364 operation: "open cache lock",
365 path: path.to_owned(),
366 source,
367 })
368}
369
370pub(super) fn record_cache_entry_use(path: &Path) -> Result<(), CacheFsError> {
371 write_last_used(path, SystemTime::now())
372}
373
374pub(super) fn cache_maintenance_due(
375 path: &Path,
376 minimum_interval: Duration,
377 maintenance_identity: &str,
378) -> Result<bool, CacheFsError> {
379 if minimum_interval.is_zero() {
380 return Ok(true);
381 }
382 let marker = path.join(LAST_MAINTENANCE_FILE);
383 let maximum_len = MAX_TIMESTAMP_BYTES + maintenance_identity.len() + 4;
385 let contents = match read_stamp_with_limit(&marker, maximum_len) {
386 Ok(Some(contents)) => contents,
387 Ok(None) => return Ok(true),
388 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(true),
389 Err(source) => {
390 return Err(CacheFsError {
391 operation: "read cache maintenance time",
392 path: marker,
393 source,
394 });
395 }
396 };
397 let mut lines = contents.lines();
398 let Some(last_maintenance) = lines.next().and_then(decode_system_time) else {
399 return Ok(true);
400 };
401 if lines.next() != Some(maintenance_identity) {
402 return Ok(true);
403 }
404 Ok(SystemTime::now()
405 .duration_since(last_maintenance)
406 .map_or(true, |elapsed| elapsed >= minimum_interval))
407}
408
409pub(super) fn record_cache_maintenance(
410 path: &Path,
411 maintenance_identity: &str,
412) -> Result<(), CacheFsError> {
413 let marker = path.join(LAST_MAINTENANCE_FILE);
414 let elapsed = encode_system_time(&marker, SystemTime::now())?;
415 let contents = format!("{}\n{maintenance_identity}\n", elapsed.as_nanos());
416 ic_host_fs::durable::write_bytes(
417 &marker,
418 ic_host_fs::durable::WriteOptions {
419 mode: ic_host_fs::durable::PublicationMode::Replace,
420 permissions: 0o666,
421 },
422 contents.as_bytes(),
423 )
424 .map_err(|source| CacheFsError {
425 operation: "record cache maintenance time",
426 path: marker,
427 source: io::Error::other(source),
428 })
429}
430
431pub(super) fn perform_scheduled_cache_maintenance(
432 path: &Path,
433 minimum_interval: Duration,
434 maintenance_identity: &str,
435 maintenance: impl FnOnce() -> Result<ArtifactCachePruneReport, String>,
436) -> (Option<ArtifactCacheMaintenance>, Option<Duration>) {
437 let started = Instant::now();
438 match cache_maintenance_due(path, minimum_interval, maintenance_identity) {
439 Ok(false) => return (None, Some(started.elapsed())),
440 Ok(true) => {}
441 Err(error) => {
442 return (
443 Some(ArtifactCacheMaintenance::PruneFailed {
444 message: error.to_string(),
445 }),
446 Some(started.elapsed()),
447 );
448 }
449 }
450
451 let result = maintenance();
452 let marker = record_cache_maintenance(path, maintenance_identity);
453 let outcome = match (result, marker) {
454 (Ok(report), Ok(())) => ArtifactCacheMaintenance::Pruned(report),
455 (Err(message), Ok(())) => ArtifactCacheMaintenance::PruneFailed { message },
456 (Ok(_), Err(error)) => ArtifactCacheMaintenance::PruneFailed {
457 message: error.to_string(),
458 },
459 (Err(message), Err(marker)) => ArtifactCacheMaintenance::PruneFailed {
460 message: format!(
461 "{message}; additionally failed to record the maintenance attempt: {marker}"
462 ),
463 },
464 };
465 (Some(outcome), Some(started.elapsed()))
466}
467
468pub(super) fn write_last_used(path: &Path, last_used: SystemTime) -> Result<(), CacheFsError> {
469 let marker = path.join(LAST_USED_FILE);
470 write_system_time(&marker, last_used, "record cache use time")
471}
472
473fn write_system_time(
474 path: &Path,
475 timestamp: SystemTime,
476 operation: &'static str,
477) -> Result<(), CacheFsError> {
478 let elapsed = encode_system_time(path, timestamp)?;
479 ic_host_fs::durable::write_bytes(
480 path,
481 ic_host_fs::durable::WriteOptions {
482 mode: ic_host_fs::durable::PublicationMode::Replace,
483 permissions: 0o666,
484 },
485 elapsed.as_nanos().to_string().as_bytes(),
486 )
487 .map_err(|source| CacheFsError {
488 operation,
489 path: path.to_owned(),
490 source: io::Error::other(source),
491 })
492}
493
494fn encode_system_time(path: &Path, timestamp: SystemTime) -> Result<Duration, CacheFsError> {
495 timestamp
496 .duration_since(UNIX_EPOCH)
497 .map_err(|source| CacheFsError {
498 operation: "encode cache time",
499 path: path.to_owned(),
500 source: io::Error::new(io::ErrorKind::InvalidInput, source),
501 })
502}
503
504fn decode_system_time(contents: &str) -> Option<SystemTime> {
505 let nanoseconds = contents.parse::<u128>().ok()?;
506 let seconds = u64::try_from(nanoseconds / 1_000_000_000).ok()?;
507 let subsecond_nanos = (nanoseconds % 1_000_000_000) as u32;
508 UNIX_EPOCH.checked_add(Duration::new(seconds, subsecond_nanos))
509}
510
511pub(super) fn prune_direct_child_directories(
512 cache_root: &Path,
513 policy: CacheRetentionLimits,
514 protected_entry: Option<&Path>,
515 is_eligible: impl Fn(&Path) -> bool,
516) -> Result<ArtifactCachePruneReport, CacheFsError> {
517 let mut entries = cache_entries(cache_root, is_eligible)?;
518 let bytes_before = entries
519 .iter()
520 .fold(0_u64, |total, entry| total.saturating_add(entry.bytes));
521 let mut report = ArtifactCachePruneReport {
522 entries_scanned: entries.len(),
523 entries_removed: 0,
524 bytes_before,
525 bytes_removed: 0,
526 uncommitted_directories_removed: 0,
527 uncommitted_bytes_removed: 0,
528 };
529 let now = SystemTime::now();
530
531 if let Some(max_age) = policy.max_age() {
532 for entry in &mut entries {
533 let age = now.duration_since(entry.last_used).unwrap_or_default();
534 if protected_entry != Some(entry.path.as_path()) && age > max_age {
535 remove_cache_entry(entry, &mut report)?;
536 }
537 }
538 }
539
540 if let Some(max_size_bytes) = policy.max_size_bytes()
541 && report.bytes_retained() > max_size_bytes
542 {
543 entries.sort_by(|left, right| {
544 left.last_used
545 .cmp(&right.last_used)
546 .then_with(|| left.path.cmp(&right.path))
547 });
548 for entry in &mut entries {
549 if report.bytes_retained() <= max_size_bytes {
550 break;
551 }
552 if protected_entry == Some(entry.path.as_path()) {
553 continue;
554 }
555 remove_cache_entry(entry, &mut report)?;
556 }
557 }
558
559 Ok(report)
560}
561
562pub(super) fn directory_logical_size(path: &Path) -> io::Result<u64> {
563 let mut total = 0_u64;
564 let mut pending = vec![path.to_owned()];
565 while let Some(current) = pending.pop() {
566 let metadata = fs::symlink_metadata(¤t)?;
567 if metadata.is_dir() {
568 for entry in fs::read_dir(¤t)? {
569 let path = entry?.path();
570 let metadata = fs::symlink_metadata(&path)?;
571 if metadata.is_dir() {
572 pending.push(path);
573 } else {
574 total = total.saturating_add(metadata.len());
575 }
576 }
577 } else {
578 total = total.saturating_add(metadata.len());
579 }
580 }
581 Ok(total)
582}
583
584pub(super) fn is_sha256_directory(path: &Path) -> bool {
585 path.file_name().is_some_and(|name| {
586 let bytes = name.as_encoded_bytes();
587 bytes.len() == 64 && bytes.iter().all(u8::is_ascii_hexdigit)
588 })
589}
590
591struct CacheEntry {
592 path: PathBuf,
593 bytes: u64,
594 last_used: SystemTime,
595 removed: bool,
596}
597
598impl std::fmt::Display for CacheFsError {
599 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
600 write!(
601 formatter,
602 "failed to {} at {}: {}",
603 self.operation,
604 self.path.display(),
605 self.source
606 )
607 }
608}
609
610impl std::error::Error for CacheFsError {
611 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
612 Some(&self.source)
613 }
614}
615
616fn cache_entries(
617 cache_root: &Path,
618 is_eligible: impl Fn(&Path) -> bool,
619) -> Result<Vec<CacheEntry>, CacheFsError> {
620 let read_dir = match fs::read_dir(cache_root) {
621 Ok(read_dir) => read_dir,
622 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
623 Err(source) => {
624 return Err(CacheFsError {
625 operation: "read cache directory",
626 path: cache_root.to_owned(),
627 source,
628 });
629 }
630 };
631 let mut entries = Vec::new();
632 for directory_entry in read_dir {
633 let directory_entry = directory_entry.map_err(|source| CacheFsError {
634 operation: "read cache entry",
635 path: cache_root.to_owned(),
636 source,
637 })?;
638 let path = directory_entry.path();
639 let file_type = directory_entry.file_type().map_err(|source| CacheFsError {
640 operation: "inspect cache entry",
641 path: path.clone(),
642 source,
643 })?;
644 if !file_type.is_dir() || !is_eligible(&path) {
645 continue;
646 }
647 let bytes = directory_logical_size(&path).map_err(|source| CacheFsError {
648 operation: "measure cache entry",
649 path: path.clone(),
650 source,
651 })?;
652 let last_used = cache_entry_last_used(&path).map_err(|source| CacheFsError {
653 operation: "read cache use time",
654 path: path.clone(),
655 source,
656 })?;
657 entries.push(CacheEntry {
658 path,
659 bytes,
660 last_used,
661 removed: false,
662 });
663 }
664 Ok(entries)
665}
666
667pub(super) fn cache_entry_last_used(path: &Path) -> io::Result<SystemTime> {
668 let marker = path.join(LAST_USED_FILE);
669 if let Ok(Some(contents)) = read_stamp_with_limit(&marker, MAX_TIMESTAMP_BYTES)
670 && let Some(timestamp) = decode_system_time(&contents)
671 {
672 return Ok(timestamp);
673 }
674 fs::metadata(path)?.modified()
675}
676
677fn remove_cache_entry(
678 entry: &mut CacheEntry,
679 report: &mut ArtifactCachePruneReport,
680) -> Result<(), CacheFsError> {
681 if entry.removed {
682 return Ok(());
683 }
684 let Some(_retention_lock) = try_lock_cache_file(&entry.path.join(RETENTION_LOCK_FILE))? else {
685 return Ok(());
686 };
687 remove_path_if_present(&entry.path).map_err(|source| CacheFsError {
688 operation: "prune cache entry",
689 path: entry.path.clone(),
690 source,
691 })?;
692 entry.removed = true;
693 report.entries_removed += 1;
694 report.bytes_removed = report.bytes_removed.saturating_add(entry.bytes);
695 Ok(())
696}
697
698#[cfg(test)]
699mod tests {
700 use super::directory_logical_size;
701 use crate::fs_fixture::unique_temp_directory;
702 use std::{
703 fs,
704 time::{Duration, SystemTime, UNIX_EPOCH},
705 };
706
707 #[cfg(unix)]
708 #[test]
709 fn cache_lock_admission_preserves_bytes_and_returns_an_unlocked_close_on_exec_file() {
710 use std::os::fd::AsRawFd as _;
711
712 let root = unique_temp_directory("cache-lock-admission");
713 let path = root.join("nested/lock");
714 let file = super::open_cache_lock_file(&path).unwrap();
715 assert!(super::try_lock_cache_file(&path).unwrap().is_some());
716 #[expect(
718 unsafe_code,
719 reason = "inspect flags of a live descriptor without changing them"
720 )]
721 let flags = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETFD) };
722 assert!(flags >= 0);
723 assert_ne!(flags & libc::FD_CLOEXEC, 0);
724 drop(file);
725 fs::write(&path, b"retained lock bytes").unwrap();
726 drop(super::lock_cache_file(&path).unwrap());
727 assert_eq!(fs::read(&path).unwrap(), b"retained lock bytes");
728 fs::remove_dir_all(root).unwrap();
729 }
730
731 #[cfg(unix)]
732 #[test]
733 fn cache_lock_callers_refuse_redirected_final_entries() {
734 use std::os::unix::fs::symlink;
735
736 let root = unique_temp_directory("cache-lock-redirected");
737 let target = root.join("target");
738 fs::write(&target, b"target bytes").unwrap();
739 let redirected = root.join("redirected");
740 symlink(&target, &redirected).unwrap();
741 for result in [
742 super::open_cache_lock_file(&redirected),
743 super::lock_cache_file(&redirected).map(|(file, _)| file),
744 super::lock_cache_file_with_wait_observer(
745 &redirected,
746 Duration::from_millis(5),
747 |_| panic!("redirected lock reached acquisition"),
748 )
749 .map(|(file, _)| file),
750 ] {
751 let error = result.unwrap_err();
752 assert_eq!(error.path, redirected);
753 assert!(matches!(
754 error.source.get_ref().and_then(|cause| cause
755 .downcast_ref::<ic_host_fs::durable::RegularFileLockError>(
756 )),
757 Some(ic_host_fs::durable::RegularFileLockError::NotRegular)
758 ));
759 }
760 symlink(&target, root.join(super::RETENTION_LOCK_FILE)).unwrap();
761 assert!(super::RetainedCacheEntry::acquire(&root).is_err());
762 assert_eq!(fs::read(&target).unwrap(), b"target bytes");
763 fs::remove_dir_all(root).unwrap();
764 }
765
766 #[cfg(unix)]
767 #[test]
768 fn nonblocking_cache_locks_preserve_contention_and_reject_redirected_files() {
769 use std::{os::unix::fs::symlink, process::Command};
770 const CHILD_ENV: &str = "IC_TESTKIT_NONBLOCKING_CACHE_LOCK_CHILD";
771
772 if std::env::var_os(CHILD_ENV).is_none() {
773 let thread = std::thread::current();
777 let test_name = thread.name().expect("libtest names each test thread");
778 let output = Command::new(std::env::current_exe().unwrap())
779 .args(["--exact", test_name, "--test-threads=1"])
780 .env(CHILD_ENV, "1")
781 .output()
782 .unwrap();
783 assert!(
784 output.status.success(),
785 "isolated nonblocking cache lock test failed: {}{}",
786 String::from_utf8_lossy(&output.stdout),
787 String::from_utf8_lossy(&output.stderr)
788 );
789 return;
790 }
791
792 let root = unique_temp_directory("nonblocking-cache-lock-admission");
793 let lock_path = root.join("lock");
794 fs::write(&lock_path, b"existing lock bytes").unwrap();
795 let held = super::try_lock_cache_file(&lock_path).unwrap().unwrap();
796 assert!(super::try_lock_cache_file(&lock_path).unwrap().is_none());
797 assert_eq!(fs::read(&lock_path).unwrap(), b"existing lock bytes");
798 drop(held);
799 assert!(super::try_lock_cache_file(&lock_path).unwrap().is_some());
800
801 let redirected = root.join("redirected-lock");
802 symlink(&lock_path, &redirected).unwrap();
803 let error = super::try_lock_cache_file(&redirected).unwrap_err();
804 assert_eq!(error.path, redirected);
805 assert!(matches!(
806 error.source.get_ref().and_then(|cause| cause
807 .downcast_ref::<ic_host_fs::durable::RegularFileLockError>(
808 )),
809 Some(ic_host_fs::durable::RegularFileLockError::NotRegular)
810 ));
811 assert_eq!(fs::read(&lock_path).unwrap(), b"existing lock bytes");
812 fs::remove_dir_all(root).unwrap();
813 }
814
815 #[cfg(unix)]
816 #[test]
817 fn final_retention_owner_releases_lock_with_a_duplicate_descriptor_open() {
818 let root = unique_temp_directory("retention-duplicate-descriptor");
819 let retained = super::RetainedCacheEntry::acquire(&root).unwrap();
820 #[expect(
822 clippy::used_underscore_binding,
823 reason = "inspect the RAII guard to verify inherited-descriptor retention"
824 )]
825 let inherited = retained._lock.0.try_clone().unwrap();
826 let clone = retained.clone();
827 let independently_retained = super::RetainedCacheEntry::acquire(&root).unwrap();
828 let lock_path = root.join(super::RETENTION_LOCK_FILE);
829 let available = || super::try_lock_cache_file(&lock_path).unwrap().is_some();
830 drop(retained);
831 assert!(!available(), "a record clone still retains the entry");
832 drop(clone);
833 assert!(
834 !available(),
835 "an independent acquisition still retains the entry"
836 );
837 drop(independently_retained);
838 assert!(
839 available(),
840 "descriptor duplication must not extend record ownership"
841 );
842 drop(inherited);
843 fs::remove_dir_all(root).unwrap();
844 }
845
846 #[test]
847 fn last_use_markers_preserve_timestamps_and_bounded_fallbacks() {
848 let root = unique_temp_directory("bounded-last-use-marker");
849 let marker = root.join(super::LAST_USED_FILE);
850 let modified = || fs::metadata(&root).unwrap().modified().unwrap();
851 assert_eq!(super::cache_entry_last_used(&root).unwrap(), modified());
852 for timestamp in [
853 UNIX_EPOCH + Duration::from_nanos(123),
854 SystemTime::now() + Duration::from_secs(3600),
855 ] {
856 super::write_last_used(&root, timestamp).unwrap();
857 assert_eq!(super::cache_entry_last_used(&root).unwrap(), timestamp);
858 }
859 let overflowing_timestamp = u128::MAX.to_string();
860 for invalid in [
861 b"invalid".as_slice(),
862 overflowing_timestamp.as_bytes(),
863 &[0xff],
864 ] {
865 fs::write(&marker, invalid).unwrap();
866 assert_eq!(super::cache_entry_last_used(&root).unwrap(), modified());
867 }
868 fs::File::create(&marker)
869 .unwrap()
870 .set_len(1024 * 1024 * 1024)
871 .unwrap();
872 assert_eq!(super::cache_entry_last_used(&root).unwrap(), modified());
873 fs::remove_file(&marker).unwrap();
874 fs::create_dir(&marker).unwrap();
875 assert_eq!(super::cache_entry_last_used(&root).unwrap(), modified());
876 fs::remove_dir_all(root).unwrap();
877 }
878
879 #[test]
880 fn maintenance_markers_preserve_policy_intervals_and_read_errors() {
881 let root = unique_temp_directory("bounded-maintenance-marker");
882 let marker = root.join(super::LAST_MAINTENANCE_FILE);
883 let identity = super::CacheRetentionLimits::new().maintenance_identity();
884 let interval = Duration::from_secs(3600);
885 let due = || super::cache_maintenance_due(&root, interval, &identity).unwrap();
886 assert!(due());
887 super::record_cache_maintenance(&root, &identity).unwrap();
888 assert!(!due());
889 assert!(super::cache_maintenance_due(&root, interval, "other-policy").unwrap());
890 assert!(super::cache_maintenance_due(&root, Duration::ZERO, &identity).unwrap());
891
892 let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
893 for suffix in ["", "\r\n"] {
894 fs::write(&marker, format!("{}\r\n{identity}{suffix}", now.as_nanos())).unwrap();
895 assert!(!due());
896 }
897 for timestamp in [
898 "invalid".to_owned(),
899 "0".to_owned(),
900 (now + Duration::from_secs(3600)).as_nanos().to_string(),
901 ] {
902 fs::write(&marker, format!("{timestamp}\n{identity}\n")).unwrap();
903 assert!(due());
904 }
905 super::record_cache_maintenance(&root, &identity).unwrap();
906 fs::OpenOptions::new()
907 .write(true)
908 .open(&marker)
909 .unwrap()
910 .set_len(1024 * 1024 * 1024)
911 .unwrap();
912 assert!(due());
913
914 fs::write(&marker, [0xff]).unwrap();
915 assert!(super::cache_maintenance_due(&root, Duration::ZERO, &identity).unwrap());
916 let error = super::cache_maintenance_due(&root, interval, &identity).unwrap_err();
917 assert_eq!(error.operation, "read cache maintenance time");
918 assert_eq!(error.path, marker);
919 assert_eq!(error.source.kind(), std::io::ErrorKind::InvalidData);
920 fs::remove_file(&marker).unwrap();
921 fs::create_dir(&marker).unwrap();
922 assert!(super::cache_maintenance_due(&root, interval, &identity).is_err());
923 assert!(super::cache_maintenance_due(&root, Duration::ZERO, &identity).unwrap());
924 fs::remove_dir_all(root).unwrap();
925 }
926
927 #[test]
928 fn cache_directory_tags_preserve_valid_standard_signatures() {
929 let root = unique_temp_directory("cache-tag-signatures");
930 let tag = root.join("CACHEDIR.TAG");
931 let signature = "Signature: 8a477f597d28d172789f06886806bc55";
932 for contents in [
933 signature.to_owned(),
934 format!("{signature}\r\n# Created by another application\r\n"),
935 format!("{signature}\n# {}\n", "comment".repeat(100_000)),
936 ] {
937 fs::write(&tag, &contents).unwrap();
938 super::ensure_cache_directory_tag(&root).unwrap();
939 assert_eq!(fs::read_to_string(&tag).unwrap(), contents);
940 }
941 for invalid in ["", &signature[..42], "Signature: incorrect"] {
942 fs::write(&tag, invalid).unwrap();
943 super::ensure_cache_directory_tag(&root).unwrap();
944 assert_eq!(
945 fs::read_to_string(&tag).unwrap(),
946 super::CACHE_DIRECTORY_TAG
947 );
948 }
949 fs::remove_dir_all(root).unwrap();
950 }
951
952 #[test]
953 #[cfg(unix)]
954 fn cache_directory_tag_replaces_symlinks_without_changing_referents() {
955 let root = unique_temp_directory("cache-tag-symlink");
956 let referent = root.join("other-application-tag");
957 let contents = "Signature: 8a477f597d28d172789f06886806bc55\n# Preserve this file\n";
958 fs::write(&referent, contents).unwrap();
959 let tag = root.join("CACHEDIR.TAG");
960 std::os::unix::fs::symlink(&referent, &tag).unwrap();
961 super::ensure_cache_directory_tag(&root).unwrap();
962 assert!(fs::symlink_metadata(&tag).unwrap().file_type().is_file());
963 assert_eq!(fs::read_to_string(&referent).unwrap(), contents);
964 assert_eq!(
965 fs::read_to_string(&tag).unwrap(),
966 super::CACHE_DIRECTORY_TAG
967 );
968
969 fs::remove_file(&tag).unwrap();
970 fs::create_dir(&tag).unwrap();
971 let error = super::ensure_cache_directory_tag(&root).unwrap_err();
972 assert!(matches!(
973 error
974 .source
975 .get_ref()
976 .unwrap()
977 .downcast_ref::<ic_host_fs::durable::NamedWriteError<std::io::Error>>()
978 .unwrap(),
979 ic_host_fs::durable::NamedWriteError::BeforePublication {
980 cleanup_error: None,
981 ..
982 }
983 ));
984 assert_eq!(error.operation, "write cache directory tag");
985 assert!(tag.is_dir());
986 fs::remove_dir_all(root).unwrap();
987 }
988
989 #[test]
990 fn directory_size_sums_wide_and_nested_files() {
991 let root = unique_temp_directory("directory-logical-size");
992 assert_eq!(directory_logical_size(&root).unwrap(), 0);
993 fs::create_dir_all(root.join("wide")).unwrap();
994 fs::create_dir_all(root.join("nested/deep/empty")).unwrap();
995 let mut expected = 0;
996 for index in 0..128 {
997 let bytes = vec![42; index % 13];
998 fs::write(root.join("wide").join(index.to_string()), &bytes).unwrap();
999 expected += bytes.len() as u64;
1000 }
1001 let sparse = root.join("nested/deep/sparse");
1002 fs::File::create(&sparse)
1003 .unwrap()
1004 .set_len(1024 * 1024)
1005 .unwrap();
1006 assert_eq!(directory_logical_size(&sparse).unwrap(), 1024 * 1024);
1007 assert_eq!(
1008 directory_logical_size(&root).unwrap(),
1009 expected + 1024 * 1024
1010 );
1011 assert_eq!(
1012 directory_logical_size(&root.join("missing"))
1013 .unwrap_err()
1014 .kind(),
1015 std::io::ErrorKind::NotFound,
1016 );
1017 fs::remove_dir_all(root).unwrap();
1018 }
1019
1020 #[test]
1021 #[cfg(unix)]
1022 fn directory_size_counts_symlinks_without_following_them() {
1023 let root = unique_temp_directory("directory-size-symlinks");
1024 let walked = root.join("walked");
1025 fs::create_dir_all(&walked).unwrap();
1026 fs::create_dir_all(root.join("external")).unwrap();
1027 fs::write(root.join("external/payload"), vec![42; 4096]).unwrap();
1028 fs::write(root.join("outside-file"), vec![42; 4096]).unwrap();
1029 fs::write(walked.join("payload"), b"abc").unwrap();
1030 let targets = ["../external", "../outside-file", "missing", "."];
1031 for (index, target) in targets.iter().enumerate() {
1032 std::os::unix::fs::symlink(target, walked.join(index.to_string())).unwrap();
1033 }
1034 let expected = 3 + targets
1035 .iter()
1036 .map(|target| target.len() as u64)
1037 .sum::<u64>();
1038 assert_eq!(directory_logical_size(&walked).unwrap(), expected);
1039 assert_eq!(
1040 directory_logical_size(&walked.join("0")).unwrap(),
1041 targets[0].len() as u64,
1042 );
1043 fs::remove_dir_all(root).unwrap();
1044 }
1045}