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