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