1use crate::sharing::Sharing;
4use serde::Serialize;
5use std::path::{Component, Path, PathBuf};
6
7#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq, Hash)]
8pub struct PathIdentity {
9 pub device: u64,
10 pub inode: u64,
11}
12
13pub fn renumbered(recorded: PathIdentity, now: PathIdentity) -> bool {
16 recorded.inode == now.inode && recorded.device != now.device
17}
18
19pub fn directory_identity(path: &Path) -> Option<PathIdentity> {
21 kache_fs::directory_identity(path)
22 .ok()
23 .map(|id| PathIdentity {
24 device: id.dev,
25 inode: id.ino,
26 })
27}
28
29const CARGO_TAG: &[u8] = b"Signature: 8a477f597d28d172789f06886806bc55";
31const TAG_READ_BYTES: u64 = 512;
33#[cfg(unix)]
35const OPEN_TAG_FLAGS: i32 = libc::O_NOFOLLOW | libc::O_NONBLOCK;
36
37pub fn cargo_tagged(target: &Path) -> bool {
42 use std::io::Read;
43 let tag = target.join("CACHEDIR.TAG");
44 let mut options = std::fs::OpenOptions::new();
45 options.read(true);
46 #[cfg(unix)]
47 {
48 use std::os::unix::fs::OpenOptionsExt;
49 options.custom_flags(OPEN_TAG_FLAGS);
50 }
51 #[cfg(not(unix))]
52 if special_tag_without_open_flags(&tag) {
53 return false;
54 }
55 let Ok(file) = options.open(&tag) else {
56 return false;
57 };
58 if !file.metadata().is_ok_and(|meta| meta.is_file()) {
60 return false;
61 }
62 let mut start = Vec::new();
63 file.take(TAG_READ_BYTES).read_to_end(&mut start).is_ok()
64 && start
65 .windows(CARGO_TAG.len())
66 .any(|window| window == CARGO_TAG)
67}
68
69#[cfg(not(unix))]
72fn special_tag_without_open_flags(tag: &Path) -> bool {
73 !std::fs::symlink_metadata(tag).is_ok_and(|meta| meta.file_type().is_file())
74}
75
76pub fn lexically_normal(path: &Path) -> PathBuf {
80 let mut normal = PathBuf::new();
81 for component in path.components() {
82 match component {
83 Component::CurDir => {}
84 Component::ParentDir => match normal.components().next_back() {
85 Some(Component::Normal(_)) => {
86 normal.pop();
87 }
88 Some(Component::RootDir) => {}
89 _ => normal.push(component),
90 },
91 _ => normal.push(component),
92 }
93 }
94 if normal.as_os_str().is_empty() && !path.as_os_str().is_empty() {
95 normal.push(Component::CurDir);
96 }
97 normal
98}
99
100pub fn registry_path(path: &Path) -> std::io::Result<PathBuf> {
106 let absolute = std::path::absolute(path)?;
107 let lexical = lexically_normal(&absolute);
108 let identity = directory_identity(&lexical);
109 if identity.is_some() && identity == directory_identity(&absolute) {
110 return Ok(lexical);
111 }
112 Ok(absolute)
113}
114
115pub fn target_root_is_safe(target: &Path, workspace_root: &Path) -> bool {
118 let Ok(target) = std::path::absolute(target) else {
119 return false;
120 };
121 let Ok(workspace) = std::path::absolute(workspace_root) else {
122 return false;
123 };
124 let cargo_markers = cargo_tagged(&target)
125 && (target.join(".rustc_info.json").is_file()
126 || target.join("debug").is_dir()
127 || target.join("release").is_dir());
128 cargo_markers
129 && target.parent().is_some()
130 && target != workspace
131 && !workspace.starts_with(&target)
132 && directory_identity(&target).is_some()
133}
134
135pub fn blob_reclaimable_bytes(path: &Path) -> Option<u64> {
137 retainer_from_meta(path).map(|r| r.private_bytes)
138}
139
140pub fn blob_has_external_retainer(path: &Path) -> bool {
145 retainer_from_meta(path).is_some_and(|r| r.held_by_live_file())
146}
147
148pub struct BlobRetainer {
149 pub size: u64,
150 pub cloned: bool,
152 pub private_bytes: u64,
153 pub snapshot_bytes: u64,
155}
156
157impl BlobRetainer {
158 pub fn held_by_live_file(&self) -> bool {
160 self.private_bytes == 0 && self.snapshot_bytes == 0
161 }
162}
163
164pub fn retainer_from_meta(path: &Path) -> Option<BlobRetainer> {
165 let meta = std::fs::metadata(path).ok()?;
166 if !meta.is_file() {
167 return None;
168 }
169 let size = meta.len();
170 let (sharing, links) = crate::sharing::probe_with_links(path, size);
171 if links > 1 {
179 return Some(BlobRetainer {
180 size,
181 cloned: true,
182 private_bytes: 0,
183 snapshot_bytes: 0,
184 });
185 }
186 Some(retainer_from_sharing(size, sharing))
187}
188
189pub fn retainer_from_sharing(size: u64, sharing: Sharing) -> BlobRetainer {
190 let cloned = sharing.shared && sharing.private_bytes == 0;
191 BlobRetainer {
192 size,
193 cloned,
194 private_bytes: if cloned {
195 0
196 } else {
197 sharing.private_bytes.min(size)
198 },
199 snapshot_bytes: sharing.snapshot_bytes,
200 }
201}
202
203pub fn is_executable(metadata: &std::fs::Metadata) -> bool {
206 #[cfg(unix)]
207 {
208 use std::os::unix::fs::PermissionsExt;
209 metadata.permissions().mode() & 0o111 != 0
210 }
211 #[cfg(not(unix))]
212 {
213 let _ = metadata;
214 false
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 #[test]
223 fn only_a_new_device_with_the_same_inode_is_a_renumbered_volume() {
224 let id = |device, inode| PathIdentity { device, inode };
225 assert!(renumbered(id(16_777_235, 7), id(16_777_232, 7)));
226 assert!(!renumbered(id(1, 7), id(1, 7)), "unchanged");
227 assert!(
228 !renumbered(id(1, 7), id(1, 8)),
229 "replaced on the same volume"
230 );
231 assert!(
232 !renumbered(id(1, 7), id(2, 8)),
233 "replaced on another volume"
234 );
235 }
236
237 #[cfg(unix)]
238 #[test]
239 fn only_a_regular_tag_marks_a_cargo_target() {
240 let dir = tempfile::tempdir().unwrap();
241 let target = |name: &str| {
242 let target = dir.path().join(name);
243 std::fs::create_dir_all(&target).unwrap();
244 target
245 };
246 let signature = "Signature: 8a477f597d28d172789f06886806bc55";
247
248 let tagged = target("tagged");
249 std::fs::write(tagged.join("CACHEDIR.TAG"), format!("{signature}\n# Cargo")).unwrap();
250 assert!(cargo_tagged(&tagged));
251
252 let late = target("late");
253 let padding = " ".repeat(TAG_READ_BYTES as usize);
254 std::fs::write(late.join("CACHEDIR.TAG"), format!("{padding}{signature}")).unwrap();
255 assert!(!cargo_tagged(&late), "only the start is read");
256
257 let linked = target("linked");
259 std::os::unix::fs::symlink(tagged.join("CACHEDIR.TAG"), linked.join("CACHEDIR.TAG"))
260 .unwrap();
261 assert!(!cargo_tagged(&linked));
262 let endless = target("endless");
263 std::os::unix::fs::symlink("/dev/zero", endless.join("CACHEDIR.TAG")).unwrap();
264 assert!(!cargo_tagged(&endless));
265
266 let fifo = target("fifo");
268 let path = std::ffi::CString::new(fifo.join("CACHEDIR.TAG").as_os_str().as_encoded_bytes())
269 .unwrap();
270 assert_eq!(unsafe { libc::mkfifo(path.as_ptr(), 0o600) }, 0);
271 assert!(!cargo_tagged(&fifo));
272
273 assert!(!cargo_tagged(&target("untagged")));
274 }
275
276 #[cfg(unix)]
277 #[test]
278 fn is_executable_reads_any_execute_bit() {
279 use std::os::unix::fs::PermissionsExt;
280 let dir = tempfile::tempdir().unwrap();
281 let file = dir.path().join("f");
282 std::fs::write(&file, "x").unwrap();
283 for (mode, expected) in [
284 (0o644, false),
285 (0o600, false),
286 (0o755, true),
287 (0o700, true),
288 (0o010, true),
289 ] {
290 std::fs::set_permissions(&file, std::fs::Permissions::from_mode(mode)).unwrap();
291 assert_eq!(
292 is_executable(&std::fs::metadata(&file).unwrap()),
293 expected,
294 "mode {mode:o}"
295 );
296 }
297 }
298
299 #[test]
300 fn retainer_from_sharing_treats_fully_cloned_as_unreclaimable() {
301 let r = retainer_from_sharing(
302 4096,
303 Sharing {
304 shared: true,
305 private_bytes: 0,
306 snapshot_bytes: 0,
307 },
308 );
309 assert!(r.cloned);
310 assert_eq!(r.private_bytes, 0);
311 }
312
313 #[test]
314 fn retainer_from_sharing_treats_private_file_as_reclaimable() {
315 let r = retainer_from_sharing(
316 4096,
317 Sharing {
318 shared: false,
319 private_bytes: 4096,
320 snapshot_bytes: 0,
321 },
322 );
323 assert!(!r.cloned);
324 assert_eq!(r.private_bytes, 4096);
325 }
326
327 #[test]
328 fn snapshot_only_blobs_do_not_block_eviction() {
329 let snapshot = retainer_from_sharing(
330 4096,
331 Sharing {
332 shared: false,
333 private_bytes: 0,
334 snapshot_bytes: 4096,
335 },
336 );
337 assert!(!snapshot.cloned, "no live file holds the blocks");
338 assert_eq!(snapshot.private_bytes, 0, "nothing frees right away");
339 assert_eq!(snapshot.snapshot_bytes, 4096);
340 assert!(!snapshot.held_by_live_file());
341
342 let cloned = retainer_from_sharing(
343 4096,
344 Sharing {
345 shared: true,
346 private_bytes: 0,
347 snapshot_bytes: 0,
348 },
349 );
350 assert!(cloned.held_by_live_file());
351
352 let private = retainer_from_sharing(
353 4096,
354 Sharing {
355 shared: false,
356 private_bytes: 4096,
357 snapshot_bytes: 0,
358 },
359 );
360 assert!(!private.held_by_live_file());
361 }
362
363 #[test]
364 fn retainer_preserves_partial_reclaim_measurement() {
365 let r = retainer_from_sharing(
366 4096,
367 Sharing {
368 shared: true,
369 private_bytes: 1024,
370 snapshot_bytes: 0,
371 },
372 );
373 assert!(!r.cloned, "partly private blobs can reclaim some disk");
374 assert_eq!(r.private_bytes, 1024);
375 }
376
377 #[test]
382 fn a_hardlinked_blob_reclaims_nothing_and_is_reported_as_retained() {
383 let dir = tempfile::tempdir().unwrap();
384 let blob = dir.path().join("blob.bin");
385 std::fs::write(&blob, vec![7u8; 4096]).unwrap();
386
387 let alone = retainer_from_meta(&blob).expect("a plain file measures");
388 assert!(!alone.cloned, "an unlinked blob is reclaimable");
389 assert_eq!(alone.private_bytes, 4096);
390 assert!(!blob_has_external_retainer(&blob));
391 assert_eq!(blob_reclaimable_bytes(&blob), Some(4096));
392
393 std::fs::hard_link(&blob, dir.path().join("target-copy.bin")).unwrap();
394 let linked = retainer_from_meta(&blob).expect("a hardlinked file measures");
395 assert!(linked.cloned, "a second name still holds every block");
396 assert_eq!(linked.size, 4096, "the logical size is still reported");
397 assert_eq!(linked.private_bytes, 0);
398 assert!(blob_has_external_retainer(&blob));
399 assert_eq!(blob_reclaimable_bytes(&blob), Some(0));
400
401 assert_eq!(
402 retainer_from_meta(dir.path()).map(|r| r.size),
403 None,
404 "a directory is not a blob"
405 );
406 assert_eq!(blob_reclaimable_bytes(&dir.path().join("missing")), None);
407 }
408
409 #[test]
410 fn cleanup_target_cannot_be_a_source_root_or_its_ancestor() {
411 let dir = tempfile::tempdir().unwrap();
412 let workspace = dir.path().join("workspace");
413 let target = workspace.join("target");
414 std::fs::create_dir_all(&target).unwrap();
415 std::fs::write(
416 target.join("CACHEDIR.TAG"),
417 "Signature: 8a477f597d28d172789f06886806bc55",
418 )
419 .unwrap();
420 std::fs::write(target.join(".rustc_info.json"), "{}").unwrap();
421
422 assert!(target_root_is_safe(&target, &workspace));
423 assert!(!target_root_is_safe(&workspace, &workspace));
424 assert!(!target_root_is_safe(dir.path(), &workspace));
425
426 let missing_markers = workspace.join("missing-markers");
427 std::fs::create_dir_all(&missing_markers).unwrap();
428 assert!(!target_root_is_safe(&missing_markers, &workspace));
429
430 let tag_only = workspace.join("tag-only");
431 std::fs::create_dir_all(&tag_only).unwrap();
432 std::fs::write(
433 tag_only.join("CACHEDIR.TAG"),
434 "Signature: 8a477f597d28d172789f06886806bc55",
435 )
436 .unwrap();
437 assert!(!target_root_is_safe(&tag_only, &workspace));
438
439 let build_dir_marker = workspace.join("build-dir-marker");
440 std::fs::create_dir_all(build_dir_marker.join("debug")).unwrap();
441 std::fs::write(
442 build_dir_marker.join("CACHEDIR.TAG"),
443 "Signature: 8a477f597d28d172789f06886806bc55",
444 )
445 .unwrap();
446 assert!(target_root_is_safe(&build_dir_marker, &workspace));
447
448 let file = workspace.join("ordinary-file");
449 std::fs::write(&file, "x").unwrap();
450 assert_eq!(directory_identity(&file), None);
451 assert_eq!(directory_identity(&workspace.join("missing")), None);
452 assert!(directory_identity(&workspace).is_some());
453 }
454
455 #[test]
456 fn the_registry_resolves_dot_dot_by_name_unless_a_symlink_says_otherwise() {
457 let dir = tempfile::tempdir().unwrap();
458 let root = std::path::absolute(dir.path()).unwrap();
459 let shared = root.join("shared");
460 std::fs::create_dir_all(shared.join("debug")).unwrap();
461 std::fs::create_dir_all(root.join("worktree")).unwrap();
462 let through_worktree = root.join("worktree/../shared/./debug/..");
463 assert_eq!(registry_path(&through_worktree).unwrap(), shared);
464 let missing = root.join("gone/../shared");
467 assert_eq!(
468 registry_path(&missing).unwrap(),
469 std::path::absolute(&missing).unwrap()
470 );
471
472 #[cfg(unix)]
473 {
474 let inner = root.join("elsewhere/inner");
475 std::fs::create_dir_all(root.join("elsewhere/shared")).unwrap();
476 std::fs::create_dir_all(&inner).unwrap();
477 std::os::unix::fs::symlink(&inner, root.join("link")).unwrap();
478 let through_link = root.join("link/../shared");
481 assert_eq!(registry_path(&through_link).unwrap(), through_link);
482 std::os::unix::fs::symlink(root.join("elsewhere"), root.join("alias")).unwrap();
483 assert_eq!(
484 registry_path(&root.join("alias/inner/../shared")).unwrap(),
485 root.join("alias/shared")
486 );
487 }
488 }
489
490 #[test]
491 fn a_dot_dot_cancels_only_a_name_before_it() {
492 for (path, normal) in [
493 ("/../a/../b", "/b"),
494 ("/a/./b/../c", "/a/c"),
495 ("../a/./b/..", "../a"),
496 ("../..", "../.."),
497 ("../../target", "../../target"),
498 ("./../target", "../target"),
499 ("a/../../b", "../b"),
500 ("a/..", "."),
501 ("", ""),
502 ] {
503 assert_eq!(
504 lexically_normal(Path::new(path)),
505 Path::new(normal),
506 "{path}"
507 );
508 }
509 }
510}