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