kernel/discovery/
duplicates.rs1use std::collections::{BTreeMap, BTreeSet};
4use std::fs::File;
5use std::io::{Read, Seek, SeekFrom};
6use std::path::{Path, PathBuf};
7
8use sha2::{Digest, Sha256};
9
10pub const DEFAULT_THRESHOLD: i64 = 256 << 20;
13
14const SAMPLE_SIZE: u64 = 1 << 20;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct DuplicateGroup {
19 pub names: Vec<String>,
21 pub paths: Vec<String>,
23 pub wasted_bytes: i64,
25}
26
27pub fn detect(candidates: &[(String, PathBuf)], threshold: i64) -> Vec<DuplicateGroup> {
33 let mut seen_paths: BTreeSet<PathBuf> = BTreeSet::new();
34 let mut by_size: BTreeMap<i64, Vec<(&str, &Path)>> = BTreeMap::new();
35 for (name, path) in candidates {
36 let identity = std::fs::canonicalize(path).unwrap_or_else(|_| path.clone());
37 if !seen_paths.insert(identity) {
38 continue;
39 }
40 let Ok(size) = file_size(path) else {
41 continue;
42 };
43 if size >= threshold && size > 0 {
44 by_size.entry(size).or_default().push((name, path));
45 }
46 }
47
48 let mut groups: Vec<DuplicateGroup> = Vec::new();
49 for (size, bucket) in by_size {
50 if bucket.len() < 2 {
51 continue;
52 }
53 let mut by_fingerprint: BTreeMap<[u8; 32], Vec<(&str, &Path)>> = BTreeMap::new();
54 for (name, path) in bucket {
55 if let Some(digest) = fingerprint(path, size as u64) {
56 by_fingerprint.entry(digest).or_default().push((name, path));
57 }
58 }
59 for matches in by_fingerprint.into_values() {
60 if matches.len() < 2 {
61 continue;
62 }
63 let mut names: Vec<String> =
64 matches.iter().map(|(name, _)| (*name).to_owned()).collect();
65 let mut paths: Vec<String> = matches
66 .iter()
67 .map(|(_, path)| path.to_string_lossy().into_owned())
68 .collect();
69 names.sort();
70 paths.sort();
71 groups.push(DuplicateGroup {
72 names,
73 paths,
74 wasted_bytes: (matches.len() as i64 - 1) * size,
75 });
76 }
77 }
78
79 groups.sort_by_key(|group| std::cmp::Reverse(group.wasted_bytes));
80 groups
81}
82
83fn file_size(path: &Path) -> std::io::Result<i64> {
84 Ok(std::fs::metadata(path)?.len() as i64)
85}
86
87pub fn content_fingerprint(path: &Path) -> Option<String> {
91 let size = std::fs::metadata(path).ok()?.len();
92 fingerprint(path, size).map(hex::encode)
93}
94
95fn fingerprint(path: &Path, size: u64) -> Option<[u8; 32]> {
96 let mut file = File::open(path).ok()?;
97 let mut hasher = Sha256::new();
98 if size <= SAMPLE_SIZE * 2 {
99 let mut whole = Vec::new();
100 file.take(size).read_to_end(&mut whole).ok()?;
101 hasher.update(&whole);
102 } else {
103 let mut head = vec![0u8; SAMPLE_SIZE as usize];
104 file.read_exact(&mut head).ok()?;
105 file.seek(SeekFrom::Start(size - SAMPLE_SIZE)).ok()?;
106 let mut tail = vec![0u8; SAMPLE_SIZE as usize];
107 file.read_exact(&mut tail).ok()?;
108 hasher.update(&head);
109 hasher.update(&tail);
110 }
111 Some(hasher.finalize().into())
112}