1use fs2::FileExt as _;
2use std::{
3 fs::{self, File, OpenOptions},
4 io,
5 path::{Path, PathBuf},
6 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
7};
8
9use super::digest::write_atomic;
10
11const CACHE_DIRECTORY_TAG: &str = "Signature: 8a477f597d28d172789f06886806bc55\n\
12# This file is a cache directory tag created by ic-testkit.\n\
13# For information about cache directory tags see https://bford.info/cachedir/\n";
14pub const CACHE_DIRECTORY_TAG_SIGNATURE: &str = "Signature: 8a477f597d28d172789f06886806bc55\n";
15const LAST_USED_FILE: &str = ".ic-testkit-last-used";
16
17#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
23pub struct ArtifactCachePrunePolicy {
24 max_age: Option<Duration>,
25 max_size_bytes: Option<u64>,
26}
27
28#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
30pub struct ArtifactCachePruneReport {
31 entries_scanned: usize,
32 entries_removed: usize,
33 bytes_before: u64,
34 bytes_removed: u64,
35}
36
37#[non_exhaustive]
39#[derive(Clone, Debug, Eq, PartialEq)]
40pub enum ArtifactCacheMaintenance {
41 Pruned(ArtifactCachePruneReport),
43 PruneFailed {
45 message: String,
47 },
48}
49
50impl ArtifactCachePrunePolicy {
51 #[must_use]
53 pub const fn new() -> Self {
54 Self {
55 max_age: None,
56 max_size_bytes: None,
57 }
58 }
59
60 #[must_use]
62 pub const fn with_max_age(mut self, max_age: Duration) -> Self {
63 self.max_age = Some(max_age);
64 self
65 }
66
67 #[must_use]
69 pub const fn with_max_size_bytes(mut self, bytes: u64) -> Self {
70 self.max_size_bytes = Some(bytes);
71 self
72 }
73
74 #[must_use]
76 pub const fn max_age(self) -> Option<Duration> {
77 self.max_age
78 }
79
80 #[must_use]
82 pub const fn max_size_bytes(self) -> Option<u64> {
83 self.max_size_bytes
84 }
85}
86
87impl ArtifactCachePruneReport {
88 #[must_use]
90 pub const fn entries_scanned(self) -> usize {
91 self.entries_scanned
92 }
93
94 #[must_use]
96 pub const fn entries_removed(self) -> usize {
97 self.entries_removed
98 }
99
100 #[must_use]
102 pub const fn entries_retained(self) -> usize {
103 self.entries_scanned.saturating_sub(self.entries_removed)
104 }
105
106 #[must_use]
108 pub const fn bytes_before(self) -> u64 {
109 self.bytes_before
110 }
111
112 #[must_use]
114 pub const fn bytes_removed(self) -> u64 {
115 self.bytes_removed
116 }
117
118 #[must_use]
120 pub const fn bytes_retained(self) -> u64 {
121 self.bytes_before.saturating_sub(self.bytes_removed)
122 }
123}
124
125impl ArtifactCacheMaintenance {
126 #[must_use]
128 pub const fn prune_report(&self) -> Option<ArtifactCachePruneReport> {
129 match self {
130 Self::Pruned(report) => Some(*report),
131 Self::PruneFailed { .. } => None,
132 }
133 }
134
135 #[must_use]
137 pub fn failure_message(&self) -> Option<&str> {
138 match self {
139 Self::Pruned(_) => None,
140 Self::PruneFailed { message } => Some(message),
141 }
142 }
143}
144
145#[derive(Debug)]
146pub struct CacheFsError {
147 pub operation: &'static str,
148 pub path: PathBuf,
149 pub source: io::Error,
150}
151
152pub fn ensure_cache_directory_tag(cache_root: &Path) -> Result<(), CacheFsError> {
153 let path = cache_root.join("CACHEDIR.TAG");
154 if fs::read_to_string(&path)
155 .is_ok_and(|contents| contents.starts_with(CACHE_DIRECTORY_TAG_SIGNATURE))
156 {
157 return Ok(());
158 }
159 write_atomic(&path, CACHE_DIRECTORY_TAG.as_bytes()).map_err(|source| CacheFsError {
160 operation: "write cache directory tag",
161 path,
162 source,
163 })
164}
165
166pub fn lock_cache_file(path: &Path) -> Result<(File, Duration), CacheFsError> {
167 if let Some(parent) = path.parent() {
168 fs::create_dir_all(parent).map_err(|source| CacheFsError {
169 operation: "create cache lock directory",
170 path: parent.to_owned(),
171 source,
172 })?;
173 }
174 let file = OpenOptions::new()
175 .create(true)
176 .read(true)
177 .write(true)
178 .truncate(false)
179 .open(path)
180 .map_err(|source| CacheFsError {
181 operation: "open cache lock",
182 path: path.to_owned(),
183 source,
184 })?;
185 let started = Instant::now();
186 file.lock_exclusive().map_err(|source| CacheFsError {
187 operation: "lock cache",
188 path: path.to_owned(),
189 source,
190 })?;
191 Ok((file, started.elapsed()))
192}
193
194pub fn record_cache_entry_use(path: &Path) -> Result<(), CacheFsError> {
195 write_last_used(path, SystemTime::now())
196}
197
198pub fn write_last_used(path: &Path, last_used: SystemTime) -> Result<(), CacheFsError> {
199 let marker = path.join(LAST_USED_FILE);
200 let elapsed = last_used
201 .duration_since(UNIX_EPOCH)
202 .map_err(|source| CacheFsError {
203 operation: "encode cache use time",
204 path: marker.clone(),
205 source: io::Error::new(io::ErrorKind::InvalidInput, source),
206 })?;
207 write_atomic(&marker, elapsed.as_nanos().to_string().as_bytes()).map_err(|source| {
208 CacheFsError {
209 operation: "record cache use time",
210 path: marker,
211 source,
212 }
213 })
214}
215
216pub fn prune_direct_child_directories(
217 cache_root: &Path,
218 policy: ArtifactCachePrunePolicy,
219 protected_entry: Option<&Path>,
220 is_eligible: impl Fn(&Path) -> bool,
221) -> Result<ArtifactCachePruneReport, CacheFsError> {
222 let mut entries = cache_entries(cache_root, is_eligible)?;
223 let bytes_before = entries
224 .iter()
225 .fold(0_u64, |total, entry| total.saturating_add(entry.bytes));
226 let mut report = ArtifactCachePruneReport {
227 entries_scanned: entries.len(),
228 entries_removed: 0,
229 bytes_before,
230 bytes_removed: 0,
231 };
232 let now = SystemTime::now();
233
234 if let Some(max_age) = policy.max_age() {
235 for entry in &mut entries {
236 let age = now.duration_since(entry.last_used).unwrap_or_default();
237 if protected_entry != Some(entry.path.as_path()) && age > max_age {
238 remove_cache_entry(entry, &mut report)?;
239 }
240 }
241 }
242
243 if let Some(max_size_bytes) = policy.max_size_bytes() {
244 entries.sort_by(|left, right| {
245 left.last_used
246 .cmp(&right.last_used)
247 .then_with(|| left.path.cmp(&right.path))
248 });
249 for entry in &mut entries {
250 if report.bytes_retained() <= max_size_bytes {
251 break;
252 }
253 if protected_entry == Some(entry.path.as_path()) {
254 continue;
255 }
256 remove_cache_entry(entry, &mut report)?;
257 }
258 }
259
260 Ok(report)
261}
262
263pub fn directory_logical_size(path: &Path) -> io::Result<u64> {
264 let mut total = 0_u64;
265 let mut pending = vec![path.to_owned()];
266 while let Some(current) = pending.pop() {
267 let metadata = fs::symlink_metadata(¤t)?;
268 if metadata.is_dir() {
269 for entry in fs::read_dir(¤t)? {
270 pending.push(entry?.path());
271 }
272 } else {
273 total = total.saturating_add(metadata.len());
274 }
275 }
276 Ok(total)
277}
278
279struct CacheEntry {
280 path: PathBuf,
281 bytes: u64,
282 last_used: SystemTime,
283 removed: bool,
284}
285
286fn cache_entries(
287 cache_root: &Path,
288 is_eligible: impl Fn(&Path) -> bool,
289) -> Result<Vec<CacheEntry>, CacheFsError> {
290 let read_dir = match fs::read_dir(cache_root) {
291 Ok(read_dir) => read_dir,
292 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
293 Err(source) => {
294 return Err(CacheFsError {
295 operation: "read cache directory",
296 path: cache_root.to_owned(),
297 source,
298 });
299 }
300 };
301 let mut entries = Vec::new();
302 for directory_entry in read_dir {
303 let directory_entry = directory_entry.map_err(|source| CacheFsError {
304 operation: "read cache entry",
305 path: cache_root.to_owned(),
306 source,
307 })?;
308 let path = directory_entry.path();
309 let file_type = directory_entry.file_type().map_err(|source| CacheFsError {
310 operation: "inspect cache entry",
311 path: path.clone(),
312 source,
313 })?;
314 if !file_type.is_dir() || !is_eligible(&path) {
315 continue;
316 }
317 let bytes = directory_logical_size(&path).map_err(|source| CacheFsError {
318 operation: "measure cache entry",
319 path: path.clone(),
320 source,
321 })?;
322 let last_used = cache_entry_last_used(&path).map_err(|source| CacheFsError {
323 operation: "read cache use time",
324 path: path.clone(),
325 source,
326 })?;
327 entries.push(CacheEntry {
328 path,
329 bytes,
330 last_used,
331 removed: false,
332 });
333 }
334 Ok(entries)
335}
336
337fn cache_entry_last_used(path: &Path) -> io::Result<SystemTime> {
338 let marker = path.join(LAST_USED_FILE);
339 if let Ok(contents) = fs::read_to_string(&marker)
340 && let Ok(nanoseconds) = contents.parse::<u128>()
341 {
342 let seconds = nanoseconds / 1_000_000_000;
343 let subsecond_nanos = (nanoseconds % 1_000_000_000) as u32;
344 if let Ok(seconds) = u64::try_from(seconds)
345 && let Some(timestamp) = UNIX_EPOCH.checked_add(Duration::new(seconds, subsecond_nanos))
346 {
347 return Ok(timestamp);
348 }
349 }
350 fs::metadata(path)?.modified()
351}
352
353fn remove_cache_entry(
354 entry: &mut CacheEntry,
355 report: &mut ArtifactCachePruneReport,
356) -> Result<(), CacheFsError> {
357 if entry.removed {
358 return Ok(());
359 }
360 match fs::remove_dir_all(&entry.path) {
361 Ok(()) => {}
362 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
363 Err(source) => {
364 return Err(CacheFsError {
365 operation: "prune cache entry",
366 path: entry.path.clone(),
367 source,
368 });
369 }
370 }
371 entry.removed = true;
372 report.entries_removed += 1;
373 report.bytes_removed = report.bytes_removed.saturating_add(entry.bytes);
374 Ok(())
375}