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