Skip to main content

kache_fs/
lib.rs

1//! Portable file operations and allocation measurements.
2//!
3//! Clone and copy helpers keep native filesystem calls behind one API.
4//! Measurements distinguish logical length, allocated bytes and private
5//! extents. Unknown private allocation stays `None`; directory totals carry
6//! a confidence level and account for hardlinks through an inode ledger.
7//! Callers own file placement, permissions policy and deletion decisions.
8
9use std::io;
10use std::path::Path;
11
12mod copy;
13#[cfg(windows)]
14pub use copy::windows_cluster_size;
15pub use copy::{copy_writable, set_writable_permissions, try_clone_extents_into, try_reflink};
16
17mod identity;
18pub use identity::{directory_identity, file_identity, handle_identity};
19
20#[cfg(feature = "staging")]
21mod staging;
22#[cfg(feature = "staging")]
23pub use staging::StagedFile;
24
25mod ledger;
26pub use ledger::{InodeId, InodeLedger};
27
28#[cfg(target_os = "macos")]
29mod apfs;
30#[cfg(any(target_os = "macos", test))]
31mod apfs_values;
32#[cfg(any(target_os = "linux", test))]
33mod extents;
34mod fallback;
35#[cfg(target_os = "linux")]
36mod linux;
37
38/// How a file's blocks are shared with other files.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
41#[non_exhaustive]
42pub enum Sharing {
43    /// The platform gave us no sharing information.
44    Unknown,
45    /// No blocks shared with any other file.
46    None,
47    /// Some blocks shared; deleting frees `unique` bytes.
48    Partial,
49    /// Every block shared; deleting this file frees nothing.
50    Full,
51}
52
53/// How much to trust an aggregate.
54///
55/// Exact set-reclaim is not obtainable on every platform: macOS exposes no
56/// public API enumerating all owners of a *partially* shared extent, and the
57/// Linux reverse-mapping ioctls generally need `CAP_SYS_ADMIN`. So a sum of
58/// per-file unique bytes is a defensible lower bound, and says so.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
60#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
61#[non_exhaustive]
62pub enum Confidence {
63    Exact,
64    LowerBound,
65    /// The safe default: claim nothing until a probe proves otherwise.
66    #[default]
67    Estimated,
68}
69
70impl Confidence {
71    /// The canonical word for this level, for anything that has to name it.
72    ///
73    /// Here rather than in each caller because this enum is `#[non_exhaustive]`:
74    /// a caller outside this crate cannot match it exhaustively, and the
75    /// wildcard arm it would be forced to write is precisely where a later
76    /// variant would silently render as something it is not.
77    pub fn label(self) -> &'static str {
78        match self {
79            Confidence::Exact => "exact",
80            Confidence::LowerBound => "lower_bound",
81            Confidence::Estimated => "estimated",
82        }
83    }
84}
85
86/// What one file costs, and what dies with it.
87#[derive(Debug, Clone, Copy)]
88#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
89pub struct FileSizing {
90    /// Logical length (`st_size` / `ATTR_FILE_DATALENGTH`).
91    pub logical: u64,
92    /// Allocated bytes, counting shared blocks in full. On platforms without
93    /// allocation metadata the fallback uses logical length as an estimate.
94    pub allocated: u64,
95    /// Bytes private to this inode. Unlinking one of several hardlinks does not
96    /// free them; callers must also account for `nlink`. `None` when unsupported.
97    pub unique: Option<u64>,
98    pub sharing: Sharing,
99    pub inode: InodeId,
100    pub nlink: u64,
101    /// Identifies the clone group this file's blocks belong to, when the
102    /// platform reports one. IDs are scoped to `inode.dev` and to the observation;
103    /// they are not durable content identities.
104    pub clone_id: Option<u64>,
105    /// Live inodes in this file's clone group, itself included, when the
106    /// platform reports it (APFS `ATTR_CMNEXT_CLONE_REFCNT`). At most one
107    /// means no other file shares these blocks: whatever is not private is
108    /// held by filesystem snapshots, and is freed once those snapshots go.
109    #[cfg_attr(feature = "serde", serde(default))]
110    pub clone_refcount: Option<u32>,
111}
112
113impl FileSizing {
114    /// Blocks shared with other inodes, when known.
115    pub fn shared(&self) -> Option<u64> {
116        self.unique.map(|u| self.allocated.saturating_sub(u))
117    }
118
119    /// Shared bytes that no other live file holds, only snapshots. `None`
120    /// when the platform does not report the clone group's size, or when the
121    /// group's size cannot tell a snapshot from a live file.
122    ///
123    /// APFS counts only the files still in an unmodified clone group. A
124    /// clone that is written to leaves the group and gets a new clone id,
125    /// while it keeps sharing every block it did not rewrite. Partly
126    /// rewriting a clone therefore leaves both files partly shared with a
127    /// refcount of 1 each. A store blob is never written after it is stored,
128    /// so a blob that is partly shared was partly rewritten through a live
129    /// clone, and its shared bytes stay unknown. Appending to a clone leaves
130    /// the blob fully shared with a refcount of 1, which reads the same as
131    /// a snapshot and is counted as one (kunobi-ninja/kache#1195).
132    pub fn snapshot_held(&self) -> Option<u64> {
133        match self.clone_refcount? {
134            // APFS also reports `Partial` for a former clone that shares
135            // nothing any more, which leaves no bytes to attribute.
136            0 | 1 => match self.shared()? {
137                0 => Some(0),
138                _ if self.sharing == Sharing::Partial => None,
139                shared => Some(shared),
140            },
141            _ => Some(0),
142        }
143    }
144
145    /// True when deleting this file alone reclaims nothing.
146    pub fn frees_nothing(&self) -> bool {
147        self.nlink > 1 || matches!(self.unique, Some(0))
148    }
149}
150
151/// A bounded sample of the clone groups a directory's files belong to.
152///
153/// Keeps the smallest N ids seen, which makes it deterministic and comparable
154/// across directories: two directories that share extents will, if they share
155/// many, tend to sample the same low ids.
156///
157/// An intersection identifies a reported clone group on the same device. It
158/// does not quantify shared extents or authorize deletion. An empty intersection
159/// does not establish independence: the bounded sample may have missed a group.
160/// Compare sketches from the same observation period; device IDs can be reused.
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
163pub struct CloneSketch {
164    ids: [u64; Self::CAP],
165    len: usize,
166    /// Older serialized sketches have no device scope and cannot prove sharing.
167    #[cfg_attr(feature = "serde", serde(default))]
168    devices: [Option<u64>; Self::CAP],
169}
170
171impl Default for CloneSketch {
172    fn default() -> Self {
173        Self {
174            ids: [0; Self::CAP],
175            len: 0,
176            devices: [None; Self::CAP],
177        }
178    }
179}
180
181impl CloneSketch {
182    pub const CAP: usize = 32;
183
184    /// Record a clone group in the device's identity domain.
185    pub fn insert(&mut self, device: u64, id: u64) {
186        // Discard an old snapshot whose entries have no device scope.
187        if self.len > Self::CAP || self.devices[..self.len].iter().any(Option::is_none) {
188            *self = Self::default();
189        }
190        let key = (id, Some(device));
191        let position = (0..self.len)
192            .find(|&i| (self.ids[i], self.devices[i]) >= key)
193            .unwrap_or(self.len);
194        if position == Self::CAP
195            || (position < self.len && (self.ids[position], self.devices[position]) == key)
196        {
197            return;
198        }
199        let end = self.len.min(Self::CAP - 1);
200        self.ids.copy_within(position..end, position + 1);
201        self.devices.copy_within(position..end, position + 1);
202        self.ids[position] = id;
203        self.devices[position] = Some(device);
204        self.len = (self.len + 1).min(Self::CAP);
205    }
206
207    pub fn ids(&self) -> &[u64] {
208        &self.ids[..self.len.min(Self::CAP)]
209    }
210
211    pub fn is_empty(&self) -> bool {
212        self.len == 0
213    }
214
215    /// Clone groups this sketch and `other` both sampled.
216    pub fn shares_with(&self, other: &CloneSketch) -> usize {
217        self.scoped_ids()
218            .filter(|identity| other.scoped_ids().any(|other| other == *identity))
219            .count()
220    }
221
222    fn scoped_ids(&self) -> impl Iterator<Item = (u64, u64)> + '_ {
223        self.ids()
224            .iter()
225            .copied()
226            .zip(self.devices)
227            .filter_map(|(id, dev)| dev.map(|dev| (id, dev)))
228    }
229}
230
231/// Aggregate over a directory.
232#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
233#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
234pub struct DirSizing {
235    pub logical: u64,
236    pub allocated: u64,
237    pub unique: u64,
238    /// Files whose physical reclaim could not be measured.
239    #[cfg_attr(feature = "serde", serde(default))]
240    pub unknown_files: u64,
241    /// Allocated bytes excluded from `unique` because sharing is unknown.
242    #[cfg_attr(feature = "serde", serde(default))]
243    pub unknown_allocated_bytes: u64,
244    pub files: u64,
245    /// Files whose blocks were reported fully shared.
246    pub fully_shared_files: u64,
247    /// Multiply-linked inodes with at least one name outside this tree.
248    #[cfg_attr(feature = "serde", serde(default))]
249    pub hardlink_pinned_inodes: u64,
250    /// Otherwise-unique bytes retained by those outside names.
251    #[cfg_attr(feature = "serde", serde(default))]
252    pub hardlink_pinned_bytes: u64,
253    /// A sample of the clone groups this directory's shared files belong to,
254    /// for identifying which other directories hold the other half.
255    pub clones: CloneSketch,
256    /// Directories that could not be read, so their contents are missing from
257    /// every figure above.
258    ///
259    /// Silently returning a smaller number for an unreadable tree is the worst
260    /// failure this type can have: "0 bytes" reads as "nothing to reclaim"
261    /// when the truth is "could not look".
262    pub unreadable_dirs: u64,
263    pub confidence: Confidence,
264}
265
266impl DirSizing {
267    /// Were parts of this tree invisible to the measurement?
268    pub fn is_partial(&self) -> bool {
269        self.unreadable_dirs > 0
270    }
271
272    /// Fraction of allocated bytes shared with data outside this file set.
273    pub fn shared_ratio(&self) -> Option<f64> {
274        if self.allocated == 0 || self.confidence == Confidence::Estimated {
275            return None;
276        }
277        Some(1.0 - (self.unique as f64 / self.allocated as f64))
278    }
279}
280
281/// What a platform probe can actually report.
282#[derive(Debug, Clone, Copy)]
283pub struct ProbeCaps {
284    pub unique_bytes: bool,
285    pub clone_refcount: bool,
286    pub name: &'static str,
287}
288
289/// One entry of a directory as a probe reports it, already classified.
290///
291/// Symlinks are absent: a walk never follows them, so a probe need not
292/// describe them.
293#[derive(Debug, Clone)]
294pub enum DirEntry {
295    /// A subdirectory to descend into.
296    Dir(std::path::PathBuf),
297    /// A file (or any other non-directory, non-symlink object) and its cost.
298    File(FileSizing),
299}
300
301pub trait SizeProbe: Send + Sync {
302    fn measure_file(&self, path: &Path) -> io::Result<FileSizing>;
303    fn capabilities(&self) -> ProbeCaps;
304
305    /// One directory's entries with their sizings, without recursing.
306    ///
307    /// The default lists names with `read_dir` and probes each file on its
308    /// own, which is at least two system calls per file. A platform with a
309    /// bulk attribute interface overrides this to answer for a whole
310    /// directory per call; the numbers must be the same either way.
311    ///
312    /// An error means the directory itself could not be read. Entries that
313    /// cannot be measured are skipped, as they are in the default.
314    fn read_dir(&self, dir: &Path) -> io::Result<Vec<DirEntry>> {
315        read_dir_one_by_one(self, dir)
316    }
317
318    /// Recursive sum, counting each inode at most once via `ledger`.
319    ///
320    /// Unreadable entries are skipped rather than failing the whole walk — a
321    /// permission error deep in a tree must not lose the rest of the answer.
322    fn measure_dir(&self, path: &Path, ledger: &mut InodeLedger) -> io::Result<DirSizing> {
323        let pinned_before = ledger.pinned_outside();
324        let mut out = DirSizing {
325            confidence: if self.capabilities().unique_bytes {
326                Confidence::LowerBound
327            } else {
328                Confidence::Estimated
329            },
330            ..Default::default()
331        };
332        walk(self, path, ledger, &mut out);
333        let pinned_after = ledger.pinned_outside();
334        out.hardlink_pinned_inodes = pinned_after.inodes.saturating_sub(pinned_before.inodes);
335        out.hardlink_pinned_bytes = pinned_after
336            .unique_bytes
337            .saturating_sub(pinned_before.unique_bytes);
338        out.unique = out.unique.saturating_sub(out.hardlink_pinned_bytes);
339        Ok(out)
340    }
341}
342
343/// The portable directory reader: names from `read_dir`, one probe per file.
344pub fn read_dir_one_by_one(
345    probe: &(impl SizeProbe + ?Sized),
346    dir: &Path,
347) -> io::Result<Vec<DirEntry>> {
348    let mut entries = Vec::new();
349    for entry in std::fs::read_dir(dir)?.flatten() {
350        let Ok(ft) = entry.file_type() else { continue };
351        if ft.is_symlink() {
352            continue; // never follow: the target may live outside the set
353        }
354        if ft.is_dir() {
355            entries.push(DirEntry::Dir(entry.path()));
356            continue;
357        }
358        if let Ok(sizing) = probe.measure_file(&entry.path()) {
359            entries.push(DirEntry::File(sizing));
360        }
361    }
362    Ok(entries)
363}
364
365fn walk(
366    probe: &(impl SizeProbe + ?Sized),
367    dir: &Path,
368    ledger: &mut InodeLedger,
369    out: &mut DirSizing,
370) {
371    let Ok(entries) = probe.read_dir(dir) else {
372        // Record rather than ignore: the caller must be able to tell "this
373        // directory is empty" from "this directory could not be opened".
374        out.unreadable_dirs += 1;
375        return;
376    };
377    for entry in entries {
378        let s = match entry {
379            DirEntry::Dir(path) => {
380                walk(probe, &path, ledger, out);
381                continue;
382            }
383            DirEntry::File(s) => s,
384        };
385        if !ledger.admit(&s) {
386            continue; // another link to an inode we already counted
387        }
388        out.files += 1;
389        out.logical += s.logical;
390        out.allocated += s.allocated;
391        if let Some(unique) = s.unique {
392            out.unique += unique;
393        } else {
394            out.unknown_files += 1;
395            out.unknown_allocated_bytes += s.allocated;
396            out.confidence = Confidence::Estimated;
397        }
398        if matches!(s.sharing, Sharing::Full) {
399            out.fully_shared_files += 1;
400        }
401        // Only shared files can pair this directory with another one.
402        if let Some(id) = s.clone_id
403            && matches!(s.sharing, Sharing::Partial | Sharing::Full)
404        {
405            out.clones.insert(s.inode.dev, id);
406        }
407    }
408}
409
410/// Free and total bytes on the volume holding a path.
411///
412/// This is the ground truth a reclaim is ultimately graded against: predicted
413/// bytes versus the actual `statvfs` delta. Expect the two to differ — on APFS
414/// a container shares free space across volumes, and snapshots can hold deleted
415/// bytes for hours.
416#[derive(Debug, Clone, Copy, PartialEq, Eq)]
417#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
418pub struct VolumeUsage {
419    pub total: u64,
420    pub free: u64,
421}
422
423impl VolumeUsage {
424    pub fn used(&self) -> u64 {
425        self.total.saturating_sub(self.free)
426    }
427
428    pub fn used_ratio(&self) -> f64 {
429        if self.total == 0 {
430            0.0
431        } else {
432            self.used() as f64 / self.total as f64
433        }
434    }
435}
436
437/// Volume usage for the filesystem holding `path`.
438pub fn volume_usage(path: &Path) -> Option<VolumeUsage> {
439    #[cfg(unix)]
440    {
441        volume_usage_unix(path)
442    }
443    #[cfg(not(unix))]
444    {
445        let _ = path;
446        None
447    }
448}
449
450#[cfg(unix)]
451fn volume_usage_unix(path: &Path) -> Option<VolumeUsage> {
452    use std::ffi::CString;
453    use std::os::unix::ffi::OsStrExt;
454
455    let c = CString::new(path.as_os_str().as_bytes()).ok()?;
456    let mut s: libc::statvfs = unsafe { std::mem::zeroed() };
457    // SAFETY: `c` is a valid NUL-terminated path and `s` is a valid out-param.
458    if unsafe { libc::statvfs(c.as_ptr(), &mut s) } != 0 {
459        return None;
460    }
461    // f_frsize is the fragment size; f_bavail is what a non-root user can use,
462    // which is the number a human recognises as "free".
463    Some(volume_bytes(
464        s.f_blocks as u64,
465        s.f_bavail as u64,
466        s.f_frsize as u64,
467        s.f_bsize as u64,
468    ))
469}
470
471#[cfg(any(unix, test))]
472fn volume_bytes(blocks: u64, available: u64, fragment_size: u64, block_size: u64) -> VolumeUsage {
473    let unit = if fragment_size > 0 {
474        fragment_size
475    } else {
476        block_size
477    };
478    VolumeUsage {
479        total: blocks * unit,
480        free: available * unit,
481    }
482}
483
484/// Best probe for the filesystem holding `path`.
485pub fn probe_for(_path: &Path) -> Box<dyn SizeProbe> {
486    #[cfg(target_os = "macos")]
487    {
488        if let Some(p) = apfs::ApfsProbe::new(_path) {
489            return Box::new(p);
490        }
491    }
492    #[cfg(target_os = "linux")]
493    {
494        if let Some(p) = linux::LinuxProbe::new(_path) {
495            return Box::new(p);
496        }
497    }
498    Box::new(fallback::FallbackProbe)
499}
500
501/// Measure one file without running a separate capability probe first.
502/// Missing or unreadable files return an error; unavailable sharing information
503/// remains unknown in the returned measurement.
504pub fn measure_file(path: &Path) -> io::Result<FileSizing> {
505    #[cfg(target_os = "macos")]
506    {
507        apfs::measure_file(path)
508    }
509    #[cfg(target_os = "linux")]
510    {
511        linux::measure_file(path)
512    }
513    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
514    {
515        fallback::FallbackProbe.measure_file(path)
516    }
517}
518
519/// A scratch directory for tests.
520///
521/// Behind a feature rather than `#[cfg(test)]` because `consumer`'s own tests
522/// need it, and a dependency's test code is not compiled for its dependents.
523#[cfg(any(test, feature = "testing"))]
524pub mod testutil {
525    use std::io::Write;
526    use std::path::{Path, PathBuf};
527    use std::sync::atomic::{AtomicU32, Ordering};
528
529    static SEQ: AtomicU32 = AtomicU32::new(0);
530
531    /// A scratch directory removed on drop. Avoids a dev-dependency for a
532    /// handful of tests.
533    pub struct TempDir(PathBuf);
534
535    impl TempDir {
536        pub fn new(tag: &str) -> Self {
537            let n = SEQ.fetch_add(1, Ordering::Relaxed);
538            let p = std::env::temp_dir().join(format!("kache-fs-{}-{tag}-{n}", std::process::id()));
539            let _ = std::fs::remove_dir_all(&p);
540            std::fs::create_dir_all(&p).expect("create temp dir");
541            // macOS exposes the temporary directory through `/var`, which is
542            // a system symlink to `/private/var`. Keep fixtures in the same
543            // canonical namespace that filesystem APIs and Git report.
544            TempDir(std::fs::canonicalize(p).expect("canonicalize temp dir"))
545        }
546
547        pub fn path(&self) -> &Path {
548            &self.0
549        }
550
551        /// Write a file of `bytes` with varied content.
552        ///
553        /// Deliberately not a repeated byte: APFS can store highly
554        /// compressible data in far fewer blocks, which makes `allocated`
555        /// unstable and size-ordering assertions flaky.
556        pub fn write(&self, name: &str, bytes: usize) -> PathBuf {
557            let p = self.0.join(name);
558            let mut buf = Vec::with_capacity(bytes);
559            let mut x: u32 = 0x9E37_79B9;
560            while buf.len() < bytes {
561                x = x.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
562                buf.extend_from_slice(&x.to_le_bytes());
563            }
564            buf.truncate(bytes);
565            let mut file = std::fs::File::create(&p).expect("create file");
566            file.write_all(&buf).expect("write");
567            // Allocation probes need the writes on disk, including on APFS.
568            // Windows requires the writable handle for this flush.
569            file.sync_all().expect("sync");
570            p
571        }
572    }
573
574    impl Drop for TempDir {
575        fn drop(&mut self) {
576            let _ = std::fs::remove_dir_all(&self.0);
577        }
578    }
579
580    /// Write `contents` to `path` as an executable the caller is about to run.
581    ///
582    /// Do not write such a file with `std::fs::write` in a test. On Linux,
583    /// running a file that any process holds open for writing fails with
584    /// ETXTBSY. `std::fs::write` closes its descriptor, but a test binary
585    /// runs tests on many threads, and another thread can fork between the
586    /// open and the close. The child inherits the writable descriptor until
587    /// it execs, and a spawn of the script in that window fails. Under fork
588    /// pressure that was 19% of 4,000 scripts. Renaming a finished file into
589    /// place does not help: the inode is the same one the child holds.
590    ///
591    /// Here a child shell writes the file, so the writable descriptor never
592    /// exists in this process and no fork of it can inherit one: 0 of 4,000.
593    /// The spawn often happens inside the code under test, which is why the
594    /// fix is on the writing side and not a retry around the spawn.
595    ///
596    /// The shell uses builtins only (`read`, `printf`). Tests rewrite the
597    /// process-wide `PATH`, sometimes to directories with no `cat` in them,
598    /// and a writer that looked anything up there would fail at random. The
599    /// mode is set from here with `chmod(2)` by path, which opens nothing.
600    /// Contents must be text without NUL bytes, which a shell cannot carry.
601    #[cfg(unix)]
602    pub fn write_executable(path: &Path, contents: impl AsRef<[u8]>) {
603        let contents = contents.as_ref();
604        assert!(
605            !contents.contains(&0),
606            "write_executable carries text only: {}",
607            path.display()
608        );
609        write_executable_with(path, contents, |_| {});
610    }
611
612    /// [`write_executable`] with a hook on the writer's command, so a test
613    /// can take `PATH` away from it.
614    #[cfg(unix)]
615    pub(crate) fn write_executable_with(
616        path: &Path,
617        contents: &[u8],
618        configure: impl FnOnce(&mut std::process::Command),
619    ) {
620        use std::os::unix::fs::PermissionsExt;
621        use std::process::{Command, Stdio};
622        const COPY_STDIN: &str = "while IFS= read -r line; do printf '%s\\n' \"$line\"; done > \"$0\"; \
623                                  printf '%s' \"$line\" >> \"$0\"";
624        let mut command = Command::new("/bin/sh");
625        command
626            .arg("-c")
627            .arg(COPY_STDIN)
628            .arg(path)
629            .stdin(Stdio::piped());
630        configure(&mut command);
631        let mut writer = command.spawn().expect("spawn the script writer");
632        writer
633            .stdin
634            .take()
635            .expect("script writer stdin")
636            .write_all(contents)
637            .expect("send the script to the writer");
638        let status = writer.wait().expect("wait for the script writer");
639        assert!(
640            status.success(),
641            "writing {} failed: {status}",
642            path.display()
643        );
644        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))
645            .expect("mark the script executable");
646    }
647
648    /// Windows has no ETXTBSY and no mode bits, so a plain write is enough.
649    #[cfg(not(unix))]
650    pub fn write_executable(path: &Path, contents: impl AsRef<[u8]>) {
651        std::fs::write(path, contents).expect("write the script");
652    }
653}
654
655#[cfg(test)]
656mod tests {
657    use super::testutil::TempDir;
658    use super::*;
659
660    const BLOCK: usize = 64 * 1024;
661
662    #[cfg(unix)]
663    #[test]
664    fn write_executable_writes_the_bytes_and_the_result_runs() {
665        use std::os::unix::fs::PermissionsExt;
666        let dir = TempDir::new("write-executable");
667        let script = dir.path().join("a script with spaces");
668        super::testutil::write_executable(&script, "#!/bin/sh\nprintf '%s' \"$1\"\nexit 7\n");
669        assert_eq!(
670            std::fs::read_to_string(&script).unwrap(),
671            "#!/bin/sh\nprintf '%s' \"$1\"\nexit 7\n"
672        );
673        let mode = std::fs::metadata(&script).unwrap().permissions().mode();
674        assert_eq!(mode & 0o777, 0o755);
675        let out = std::process::Command::new(&script)
676            .arg("hi")
677            .output()
678            .unwrap();
679        assert_eq!(out.stdout, b"hi");
680        assert_eq!(out.status.code(), Some(7));
681
682        // Overwriting replaces the contents instead of appending.
683        super::testutil::write_executable(&script, b"#!/bin/sh\nexit 0\n");
684        assert_eq!(std::fs::read(&script).unwrap(), b"#!/bin/sh\nexit 0\n");
685
686        // Byte-exact for the shapes scripts take: no final newline, blank
687        // lines, leading and trailing blanks, backslashes, a leading dash.
688        for text in [
689            "",
690            "one line, no newline",
691            "\n\n  indented \\n kept\\\n-n not an option\n\ttab \n\nlast",
692            "%s %d are data\n",
693        ] {
694            super::testutil::write_executable(&script, text);
695            assert_eq!(std::fs::read_to_string(&script).unwrap(), text, "{text:?}");
696        }
697    }
698
699    /// Tests rewrite the process-wide PATH; the writer must not need it.
700    #[cfg(unix)]
701    #[test]
702    fn write_executable_needs_nothing_from_path() {
703        let dir = TempDir::new("write-executable-no-path");
704        let script = dir.path().join("script");
705        super::testutil::write_executable_with(&script, b"#!/bin/sh\nexit 3\n", |command| {
706            command.env("PATH", "/nonexistent");
707        });
708        assert_eq!(std::fs::read(&script).unwrap(), b"#!/bin/sh\nexit 3\n");
709        let status = std::process::Command::new(&script).status().unwrap();
710        assert_eq!(status.code(), Some(3));
711    }
712
713    #[cfg(unix)]
714    #[test]
715    #[should_panic(expected = "text only")]
716    fn write_executable_refuses_nul_bytes() {
717        let dir = TempDir::new("write-executable-nul");
718        super::testutil::write_executable(&dir.path().join("script"), b"a\0b");
719    }
720
721    #[test]
722    fn confidence_labels_remain_distinct_for_consumers() {
723        assert_eq!(Confidence::Exact.label(), "exact");
724        assert_eq!(Confidence::LowerBound.label(), "lower_bound");
725        assert_eq!(Confidence::Estimated.label(), "estimated");
726    }
727
728    #[test]
729    fn empty_clone_samples_gain_identity_after_an_observation() {
730        let mut sample = CloneSketch::default();
731        assert!(sample.is_empty());
732        assert!(sample.ids().is_empty());
733        sample.insert(3, 27);
734        assert!(!sample.is_empty());
735        assert_eq!(sample.ids(), &[27]);
736        assert_eq!(sample.shares_with(&CloneSketch::default()), 0);
737    }
738
739    #[test]
740    fn volume_usage_handles_empty_and_overreported_free_space() {
741        let occupied = VolumeUsage {
742            total: 80,
743            free: 20,
744        };
745        assert_eq!(occupied.used(), 60);
746        assert_eq!(occupied.used_ratio(), 0.75);
747        for usage in [
748            VolumeUsage { total: 0, free: 0 },
749            VolumeUsage { total: 0, free: 10 },
750            VolumeUsage {
751                total: 10,
752                free: 20,
753            },
754        ] {
755            assert_eq!(usage.used(), 0);
756            assert_eq!(usage.used_ratio(), 0.0);
757        }
758    }
759
760    #[test]
761    fn volume_bytes_use_fragment_units_when_available() {
762        let fragments = volume_bytes(3, 1, 4096, 16384);
763        assert_eq!(fragments.total, 12288);
764        assert_eq!(fragments.free, 4096);
765        let blocks = volume_bytes(3, 1, 0, 16384);
766        assert_eq!(blocks.total, 49152);
767        assert_eq!(blocks.free, 16384);
768    }
769
770    #[cfg(unix)]
771    #[test]
772    fn volume_query_requires_a_real_path() {
773        let dir = TempDir::new("volume-usage");
774        let usage = volume_usage(dir.path()).unwrap();
775        assert!(usage.total > 0);
776        assert!(usage.free > 0);
777        assert!(usage.free <= usage.total);
778        assert!(volume_usage(&dir.path().join("missing")).is_none());
779        assert!(volume_usage(Path::new("\0")).is_none());
780    }
781
782    #[cfg(not(unix))]
783    #[test]
784    fn unavailable_volume_usage_remains_unknown() {
785        let dir = TempDir::new("volume-usage");
786        assert!(volume_usage(dir.path()).is_none());
787    }
788
789    #[test]
790    fn clone_samples_are_bounded_and_scoped_to_the_device() {
791        let mut a = CloneSketch::default();
792        let mut b = CloneSketch::default();
793        for id in (0..100).rev() {
794            a.insert(1, id);
795        }
796        for id in 0..100 {
797            b.insert(1, id);
798        }
799        assert_eq!(a, b);
800        assert_eq!(a.ids(), &(0..32).collect::<Vec<_>>());
801        b.insert(1, 0);
802        assert_eq!(a, b);
803        let mut other_device = CloneSketch::default();
804        other_device.insert(2, 0);
805        assert_eq!(a.shares_with(&other_device), 0);
806        other_device.insert(1, 0);
807        assert_eq!(a.shares_with(&other_device), 1);
808    }
809
810    #[cfg(feature = "serde")]
811    #[test]
812    fn legacy_clone_samples_cannot_prove_sharing() {
813        let mut a = CloneSketch::default();
814        a.insert(1, 42);
815        let mut value = serde_json::to_value(a).unwrap();
816        let round_trip: CloneSketch = serde_json::from_value(value.clone()).unwrap();
817        assert_eq!(a, round_trip);
818        let mut uncounted = value.clone();
819        uncounted["len"] = serde_json::json!(0);
820        let mut uncounted: CloneSketch = serde_json::from_value(uncounted).unwrap();
821        assert!(uncounted.is_empty());
822        uncounted.insert(1, 42);
823        assert_eq!(
824            uncounted, a,
825            "an unused slot cannot suppress an observation"
826        );
827        value.as_object_mut().unwrap().remove("devices");
828        let legacy: CloneSketch = serde_json::from_value(value.clone()).unwrap();
829        assert_eq!(legacy.shares_with(&a), 0);
830        value["len"] = serde_json::json!(1000);
831        let mut malformed: CloneSketch = serde_json::from_value(value).unwrap();
832        assert_eq!(malformed.shares_with(&a), 0);
833        malformed.insert(1, 42);
834        assert_eq!(malformed, a);
835    }
836
837    #[test]
838    fn an_unreadable_directory_is_reported_not_silently_counted_as_empty() {
839        // The dangerous failure: a permission error making a large tree look
840        // like 0 bytes, i.e. "nothing to reclaim" instead of "cannot look".
841        let d = TempDir::new("unreadable");
842        let locked = d.path().join("locked");
843        std::fs::create_dir_all(&locked).unwrap();
844        std::fs::write(locked.join("payload"), vec![1u8; 8192]).unwrap();
845        d.write("visible", 4096);
846
847        #[cfg(unix)]
848        {
849            use std::os::unix::fs::PermissionsExt;
850            std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
851        }
852
853        let probe = probe_for(d.path());
854        let mut ledger = InodeLedger::new();
855        let s = probe.measure_dir(d.path(), &mut ledger).unwrap();
856
857        // Restore so the TempDir can clean itself up.
858        #[cfg(unix)]
859        {
860            use std::os::unix::fs::PermissionsExt;
861            let _ = std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755));
862        }
863
864        #[cfg(unix)]
865        if !nix_running_as_root() {
866            assert_eq!(s.unreadable_dirs, 1, "the locked directory must be counted");
867            assert!(s.is_partial(), "and the total must admit it is incomplete");
868        }
869        assert!(s.files >= 1, "what could be read is still measured");
870    }
871
872    /// Root can read anything, so the permission test proves nothing there.
873    #[cfg(unix)]
874    fn nix_running_as_root() -> bool {
875        // SAFETY: getuid is always safe.
876        unsafe { libc::getuid() == 0 }
877    }
878
879    #[test]
880    fn a_fully_readable_tree_reports_no_gaps() {
881        let d = TempDir::new("readable");
882        d.write("a", 4096);
883        let probe = probe_for(d.path());
884        let mut ledger = InodeLedger::new();
885        let s = probe.measure_dir(d.path(), &mut ledger).unwrap();
886
887        assert_eq!(s.unreadable_dirs, 0);
888        assert!(!s.is_partial());
889    }
890
891    #[test]
892    fn hard_links_are_counted_once() {
893        let d = TempDir::new("hardlink");
894        let a = d.write("a", BLOCK);
895        d.write("b", BLOCK);
896        std::fs::hard_link(&a, d.path().join("a-link")).expect("hard link");
897
898        let probe = probe_for(d.path());
899        let mut ledger = InodeLedger::new();
900        let s = probe.measure_dir(d.path(), &mut ledger).unwrap();
901
902        // Three directory entries, two distinct inodes.
903        assert_eq!(s.files, 2, "the second link must not be counted again");
904        assert!(s.allocated >= 2 * BLOCK as u64);
905        assert!(
906            s.allocated < 3 * BLOCK as u64,
907            "counting the link would inflate this to 3 blocks"
908        );
909        // Both names of the linked inode are inside the set, so nothing is
910        // pinned from outside.
911        assert_eq!(ledger.pinned_outside().inodes, 0);
912    }
913
914    #[test]
915    fn links_reaching_outside_the_measured_set_are_reported() {
916        let outer = TempDir::new("outside");
917        let inner = outer.path().join("inner");
918        std::fs::create_dir_all(&inner).unwrap();
919
920        let a = outer.write("kept-outside", BLOCK);
921        std::fs::hard_link(&a, inner.join("linked")).expect("hard link");
922
923        let probe = probe_for(&inner);
924        let mut ledger = InodeLedger::new();
925        let s = probe.measure_dir(&inner, &mut ledger).unwrap();
926
927        assert_eq!(s.files, 1);
928        let pinned = ledger.pinned_outside();
929        assert_eq!(pinned.inodes, 1, "the other name lives outside the walk");
930        assert!(pinned.bytes >= BLOCK as u64);
931        assert_eq!(s.unique, 0, "another name keeps the inode alive");
932        assert_eq!(s.hardlink_pinned_inodes, 1);
933        if probe.capabilities().unique_bytes {
934            assert!(s.hardlink_pinned_bytes >= BLOCK as u64);
935        } else {
936            assert_eq!(s.hardlink_pinned_bytes, 0, "private allocation is unknown");
937        }
938    }
939
940    #[test]
941    fn symlinks_are_never_followed() {
942        let d = TempDir::new("symlink");
943        let _target = d.write("real", BLOCK);
944        #[cfg(unix)]
945        std::os::unix::fs::symlink(&_target, d.path().join("link")).expect("symlink");
946
947        let probe = probe_for(d.path());
948        let mut ledger = InodeLedger::new();
949        let s = probe.measure_dir(d.path(), &mut ledger).unwrap();
950
951        assert_eq!(s.files, 1, "the symlink must not add its target again");
952    }
953
954    #[test]
955    fn nested_directories_are_summed() {
956        let d = TempDir::new("nested");
957        let sub = d.path().join("a/b/c");
958        std::fs::create_dir_all(&sub).unwrap();
959        d.write("top", BLOCK);
960        std::fs::write(sub.join("deep"), vec![1u8; BLOCK]).unwrap();
961
962        let probe = probe_for(d.path());
963        let mut ledger = InodeLedger::new();
964        let s = probe.measure_dir(d.path(), &mut ledger).unwrap();
965
966        assert_eq!(s.files, 2);
967        assert!(s.allocated >= 2 * BLOCK as u64);
968    }
969
970    #[test]
971    fn an_unreadable_subtree_does_not_lose_the_rest_of_the_answer() {
972        let d = TempDir::new("missing");
973        d.write("present", BLOCK);
974
975        let probe = probe_for(d.path());
976        let mut ledger = InodeLedger::new();
977        // A path that does not exist yields an empty measurement, not an error.
978        let s = probe
979            .measure_dir(&d.path().join("nope"), &mut ledger)
980            .unwrap();
981        assert_eq!(s.files, 0);
982        assert_eq!(s.allocated, 0);
983    }
984
985    #[test]
986    fn shared_ratio_is_withheld_when_the_platform_cannot_tell() {
987        let estimated = DirSizing {
988            allocated: 100,
989            unique: 100,
990            confidence: Confidence::Estimated,
991            ..Default::default()
992        };
993        assert_eq!(estimated.shared_ratio(), None);
994
995        let known = DirSizing {
996            allocated: 100,
997            unique: 25,
998            confidence: Confidence::LowerBound,
999            ..Default::default()
1000        };
1001        assert_eq!(known.shared_ratio(), Some(0.75));
1002
1003        let empty = DirSizing {
1004            confidence: Confidence::Exact,
1005            ..Default::default()
1006        };
1007        assert_eq!(empty.shared_ratio(), None, "no divide by zero");
1008    }
1009
1010    #[test]
1011    fn directory_totals_keep_private_shared_and_unknown_measurements_distinct() {
1012        struct FixtureProbe(Vec<FileSizing>);
1013        impl SizeProbe for FixtureProbe {
1014            fn measure_file(&self, _path: &Path) -> io::Result<FileSizing> {
1015                unreachable!("fixture supplies directory measurements")
1016            }
1017
1018            fn capabilities(&self) -> ProbeCaps {
1019                ProbeCaps {
1020                    unique_bytes: true,
1021                    clone_refcount: false,
1022                    name: "fixture",
1023                }
1024            }
1025
1026            fn read_dir(&self, _dir: &Path) -> io::Result<Vec<DirEntry>> {
1027                Ok(self.0.iter().copied().map(DirEntry::File).collect())
1028            }
1029        }
1030
1031        let partial = FileSizing {
1032            logical: 10,
1033            allocated: 20,
1034            unique: Some(12),
1035            sharing: Sharing::Partial,
1036            inode: InodeId { dev: 3, ino: 1 },
1037            nlink: 1,
1038            clone_id: Some(7),
1039            clone_refcount: None,
1040        };
1041        let mut probe = FixtureProbe(vec![
1042            partial,
1043            FileSizing {
1044                logical: 30,
1045                allocated: 40,
1046                unique: Some(0),
1047                sharing: Sharing::Full,
1048                inode: InodeId { dev: 3, ino: 2 },
1049                clone_id: Some(8),
1050                ..partial
1051            },
1052            FileSizing {
1053                logical: 50,
1054                allocated: 60,
1055                unique: Some(60),
1056                sharing: Sharing::None,
1057                inode: InodeId { dev: 3, ino: 3 },
1058                clone_id: Some(9),
1059                ..partial
1060            },
1061        ]);
1062        let known = probe
1063            .measure_dir(Path::new("."), &mut InodeLedger::new())
1064            .unwrap();
1065        assert_eq!(known.confidence, Confidence::LowerBound);
1066        assert_eq!(
1067            (known.files, known.logical, known.allocated, known.unique),
1068            (3, 90, 120, 72)
1069        );
1070        assert_eq!(known.fully_shared_files, 1);
1071        assert_eq!(known.clones.ids(), &[7, 8]);
1072        assert_eq!((known.unknown_files, known.unknown_allocated_bytes), (0, 0));
1073
1074        probe.0.push(FileSizing {
1075            logical: 70,
1076            allocated: 80,
1077            unique: None,
1078            sharing: Sharing::Unknown,
1079            inode: InodeId { dev: 3, ino: 4 },
1080            clone_id: Some(10),
1081            ..partial
1082        });
1083        let unknown = probe
1084            .measure_dir(Path::new("."), &mut InodeLedger::new())
1085            .unwrap();
1086        assert_eq!(unknown.confidence, Confidence::Estimated);
1087        assert_eq!(
1088            (
1089                unknown.files,
1090                unknown.logical,
1091                unknown.allocated,
1092                unknown.unique
1093            ),
1094            (4, 160, 200, 72)
1095        );
1096        assert_eq!(
1097            (unknown.unknown_files, unknown.unknown_allocated_bytes),
1098            (1, 80)
1099        );
1100        assert_eq!(unknown.fully_shared_files, 1);
1101        assert_eq!(unknown.clones.ids(), &[7, 8]);
1102    }
1103
1104    #[test]
1105    fn file_sizing_reports_shared_bytes_and_worthlessness() {
1106        let full = FileSizing {
1107            logical: 4096,
1108            allocated: 4096,
1109            unique: Some(0),
1110            sharing: Sharing::Full,
1111            inode: InodeId { dev: 1, ino: 1 },
1112            nlink: 1,
1113            clone_id: None,
1114            clone_refcount: None,
1115        };
1116        assert_eq!(full.shared(), Some(4096));
1117        assert!(full.frees_nothing());
1118        assert_eq!(full.snapshot_held(), None, "no refcount, no verdict");
1119        assert_eq!(
1120            FileSizing {
1121                clone_refcount: Some(2),
1122                ..full
1123            }
1124            .snapshot_held(),
1125            Some(0),
1126            "another live clone holds the blocks"
1127        );
1128        for alone in [0, 1] {
1129            assert_eq!(
1130                FileSizing {
1131                    clone_refcount: Some(alone),
1132                    ..full
1133                }
1134                .snapshot_held(),
1135                Some(4096),
1136                "no other live clone: only snapshots hold the shared blocks"
1137            );
1138        }
1139        assert_eq!(
1140            FileSizing {
1141                unique: None,
1142                clone_refcount: Some(1),
1143                ..full
1144            }
1145            .snapshot_held(),
1146            None,
1147            "unknown private bytes leave the snapshot share unknown"
1148        );
1149        for alone in [0, 1] {
1150            assert_eq!(
1151                FileSizing {
1152                    unique: Some(1024),
1153                    sharing: Sharing::Partial,
1154                    clone_refcount: Some(alone),
1155                    ..full
1156                }
1157                .snapshot_held(),
1158                None,
1159                "a partly rewritten live clone also reports a group of one"
1160            );
1161            assert_eq!(
1162                FileSizing {
1163                    unique: Some(4096),
1164                    sharing: Sharing::Partial,
1165                    clone_refcount: Some(alone),
1166                    ..full
1167                }
1168                .snapshot_held(),
1169                Some(0),
1170                "a former clone that shares nothing holds nothing in snapshots"
1171            );
1172        }
1173        assert_eq!(
1174            FileSizing {
1175                unique: Some(1024),
1176                sharing: Sharing::Partial,
1177                clone_refcount: Some(2),
1178                ..full
1179            }
1180            .snapshot_held(),
1181            Some(0),
1182            "a partly shared file in a live group is held by that group"
1183        );
1184
1185        let unknown = FileSizing {
1186            unique: None,
1187            sharing: Sharing::Unknown,
1188            ..full
1189        };
1190        assert_eq!(
1191            unknown.shared(),
1192            None,
1193            "unknown must not read as fully shared"
1194        );
1195        assert!(!unknown.frees_nothing());
1196        assert!(
1197            FileSizing {
1198                nlink: 2,
1199                ..unknown
1200            }
1201            .frees_nothing()
1202        );
1203    }
1204
1205    /// The behaviour the whole module exists for, at directory level: a clone
1206    /// pair occupies one file's worth of blocks, and `du`-style summation of
1207    /// `allocated` would report two.
1208    #[cfg(target_os = "macos")]
1209    #[test]
1210    fn cloned_files_do_not_double_count_against_unique() {
1211        let d = TempDir::new("clone");
1212        let orig = d.write("orig", 4 * BLOCK);
1213        let st = std::process::Command::new("cp")
1214            .arg("-c")
1215            .arg(&orig)
1216            .arg(d.path().join("clone"))
1217            .status();
1218        if !matches!(st, Ok(s) if s.success()) {
1219            return; // no clonefile support here
1220        }
1221
1222        let probe = probe_for(d.path());
1223        if !probe.capabilities().unique_bytes {
1224            return;
1225        }
1226        let mut ledger = InodeLedger::new();
1227        let s = probe.measure_dir(d.path(), &mut ledger).unwrap();
1228
1229        assert_eq!(s.files, 2, "clones are independent inodes");
1230        assert!(s.allocated >= 8 * BLOCK as u64, "du bills both copies");
1231        assert!(
1232            s.unique < BLOCK as u64,
1233            "yet the blocks exist once, so almost nothing is private (unique={})",
1234            s.unique
1235        );
1236        // Both members report zero private bytes: sharing is symmetric, with no
1237        // "original" that owns the extents. Deleting either one alone frees
1238        // nothing — the bytes only return when the whole clone group goes, which
1239        // is why reclaim is modelled as a set function and not a per-item sum.
1240        assert_eq!(
1241            s.fully_shared_files, 2,
1242            "neither member frees anything alone"
1243        );
1244    }
1245}