1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
41#[non_exhaustive]
42pub enum Sharing {
43 Unknown,
45 None,
47 Partial,
49 Full,
51}
52
53#[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 #[default]
67 Estimated,
68}
69
70impl Confidence {
71 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#[derive(Debug, Clone, Copy)]
88#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
89pub struct FileSizing {
90 pub logical: u64,
92 pub allocated: u64,
95 pub unique: Option<u64>,
98 pub sharing: Sharing,
99 pub inode: InodeId,
100 pub nlink: u64,
101 pub clone_id: Option<u64>,
105 #[cfg_attr(feature = "serde", serde(default))]
110 pub clone_refcount: Option<u32>,
111}
112
113impl FileSizing {
114 pub fn shared(&self) -> Option<u64> {
116 self.unique.map(|u| self.allocated.saturating_sub(u))
117 }
118
119 pub fn snapshot_held(&self) -> Option<u64> {
133 match self.clone_refcount? {
134 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 pub fn frees_nothing(&self) -> bool {
147 self.nlink > 1 || matches!(self.unique, Some(0))
148 }
149}
150
151#[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 #[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 pub fn insert(&mut self, device: u64, id: u64) {
186 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 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#[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 #[cfg_attr(feature = "serde", serde(default))]
240 pub unknown_files: u64,
241 #[cfg_attr(feature = "serde", serde(default))]
243 pub unknown_allocated_bytes: u64,
244 pub files: u64,
245 pub fully_shared_files: u64,
247 #[cfg_attr(feature = "serde", serde(default))]
249 pub hardlink_pinned_inodes: u64,
250 #[cfg_attr(feature = "serde", serde(default))]
252 pub hardlink_pinned_bytes: u64,
253 pub clones: CloneSketch,
256 pub unreadable_dirs: u64,
263 pub confidence: Confidence,
264}
265
266impl DirSizing {
267 pub fn is_partial(&self) -> bool {
269 self.unreadable_dirs > 0
270 }
271
272 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#[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#[derive(Debug, Clone)]
294pub enum DirEntry {
295 Dir(std::path::PathBuf),
297 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 fn read_dir(&self, dir: &Path) -> io::Result<Vec<DirEntry>> {
315 read_dir_one_by_one(self, dir)
316 }
317
318 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
343pub 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; }
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 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; }
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 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#[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
437pub 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 if unsafe { libc::statvfs(c.as_ptr(), &mut s) } != 0 {
459 return None;
460 }
461 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
484pub 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
501pub 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#[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 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 TempDir(std::fs::canonicalize(p).expect("canonicalize temp dir"))
545 }
546
547 pub fn path(&self) -> &Path {
548 &self.0
549 }
550
551 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 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 #[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 #[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 #[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 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 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 #[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 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 #[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 #[cfg(unix)]
874 fn nix_running_as_root() -> bool {
875 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 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 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 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 #[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; }
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 assert_eq!(
1241 s.fully_shared_files, 2,
1242 "neither member frees anything alone"
1243 );
1244 }
1245}