Skip to main content

kache_store/
filesystem.rs

1//! Filesystem identity and physical reclamation.
2
3use 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
13/// Whether a directory recorded as `recorded` and found as `now` at the same
14/// path is the same one on a renumbered volume: same inode, new device.
15pub fn renumbered(recorded: PathIdentity, now: PathIdentity) -> bool {
16    recorded.inode == now.inode && recorded.device != now.device
17}
18
19/// Stable directory identity used to reject moved or replaced tracked targets.
20pub 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
29/// What Cargo writes in `CACHEDIR.TAG` at the top of a target directory.
30const CARGO_TAG: &[u8] = b"Signature: 8a477f597d28d172789f06886806bc55";
31/// How much of `CACHEDIR.TAG` is read: the signature is in its first line.
32const TAG_READ_BYTES: u64 = 512;
33/// Open a tag without following a symlink or waiting on a FIFO.
34#[cfg(unix)]
35const OPEN_TAG_FLAGS: i32 = libc::O_NOFOLLOW | libc::O_NONBLOCK;
36
37/// Whether Cargo tagged `target` as its build directory. Only a regular
38/// `CACHEDIR.TAG` counts, opened without following a symlink or waiting on
39/// a FIFO, and only its start is read, so a planted marker in a directory
40/// discovery never built in cannot hang or exhaust the reader.
41pub 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    // A FIFO opens at once without a writer; it is not a tag.
59    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/// Without Unix's open flags, refuse a symlink or special file before
70/// opening it.
71#[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
76/// `path` with `.` dropped and each `..` cancelling the name before it, by
77/// name alone. A `..` with no name before it stays, unless it is at the
78/// root.
79pub 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
100/// How the target registry spells `path`: absolute, with `.` and `..`
101/// resolved when that names the same directory, so a target named through
102/// a directory deleted later (`<worktree>/../shared`) is still found. A
103/// symlink before a `..` can make the resolved name another directory; then
104/// the path stays as `std::path::absolute` spells it.
105pub 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
115/// A tracked cleanup target must be a derived directory, never a source root
116/// or an ancestor of one.
117pub 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
135/// Best-effort bytes the filesystem would reclaim by unlinking this blob.
136pub fn blob_reclaimable_bytes(path: &Path) -> Option<u64> {
137    retainer_from_meta(path).map(|r| r.private_bytes)
138}
139
140/// Would unlinking this store name leave the blob's blocks held by another
141/// live file? Blocks held only by snapshots do not count: they are freed
142/// once the snapshots are deleted, and keeping the entry would pin the store
143/// over its size limit for as long as any snapshot exists.
144pub 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    /// Another live file (hardlink or clone) holds every block.
151    pub cloned: bool,
152    pub private_bytes: u64,
153    /// Bytes only filesystem snapshots hold.
154    pub snapshot_bytes: u64,
155}
156
157impl BlobRetainer {
158    /// True when unlinking frees nothing now and nothing later either.
159    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    // A blob a build's target directory still hardlinks frees nothing when the
172    // store drops its own name (kunobi-ninja/kache#725). This used to read
173    // `MetadataExt::nlink` behind `#[cfg(unix)]`, so on Windows the guard did
174    // not exist at all and GC evicted still-linked entries, reclaiming no disk
175    // and destroying the hits. NTFS has hardlinks, `cache.windows_hardlink`
176    // and `cache.shared_hardlink_restores` make them, and a link made by
177    // anything else holds the blocks just the same.
178    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
203/// Whether any execute bit is set. Always `false` off Unix, where files carry
204/// no execute bit.
205pub 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        // A symlink to a tag, or to something endless, is not followed.
258        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        // A FIFO with no writer would block a read forever.
267        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    /// The #725 guard on every platform kache ships on. Hardlinks exist on
378    /// NTFS too, and the count must not come from `MetadataExt`, which only
379    /// Unix has — a Unix-gated guard let Windows GC evict entries whose blobs
380    /// a target directory still held, freeing nothing.
381    #[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        // Nothing on disk to compare: the path stays as `absolute` spells it,
465        // which on Windows has already resolved the `..`.
466        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            // `..` after a symlink leaves the link's target, so the name
479            // `root/shared` would be another directory.
480            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}