Skip to main content

duped/
lib.rs

1//! This library can be used to find duplicated files starting from a particular
2//! root directory. To use it, please look over the following docs: [find],
3//! [Params], and [Stats].
4//!
5//! # Examples
6//!
7//! ```no_run
8//! use file_duplicates::{find, Params};
9//!
10//! let params = Params::new(0, vec!["./".into()], "test.db".into());
11//! let stats = find(&params).unwrap();
12//! ```
13
14use blake3::Hash;
15use filetime::FileTime;
16use std::{
17    collections::{
18        hash_map::Entry::{Occupied, Vacant},
19        HashMap, VecDeque,
20    },
21    fs::File,
22    io::{self, BufRead, BufReader},
23    path::{Path, PathBuf},
24    sync::mpsc::{self, Receiver, SyncSender},
25};
26use walkdir::WalkDir;
27
28mod db;
29pub use db::{Entry, HashDb};
30
31use crate::db::retry_on_busy;
32
33/// Used to configure the [find] function.
34#[derive(Debug)]
35pub struct Params {
36    /// If the size of the file is under `lower_limit` bytes, it is not taken
37    /// into account.
38    lower_limit: u64,
39    /// Where to start the search from.
40    roots: Vec<PathBuf>,
41    /// Where to store the hash database.
42    db: PathBuf,
43}
44
45impl Params {
46    /// Create a new instance of [`Params`].
47    ///
48    /// # Arguments
49    ///
50    /// * `lower_limit` - If the size of the file is under `lower_limit` bytes, it is not taken
51    ///                   into account.
52    /// * `root` - Where to start the search from.
53    /// * `db` - Where to store the hash database.
54    pub fn new(lower_limit: u64, roots: Vec<PathBuf>, db: PathBuf) -> Self {
55        Self { lower_limit, roots, db }
56    }
57
58    /// Get the roots that this instance was initialized with.
59    ///
60    /// A root is a path where searching starts from.
61    pub fn roots(&self) -> &[PathBuf] {
62        &self.roots
63    }
64
65    /// Get the path to the database.
66    pub fn db_path(&self) -> &Path {
67        &self.db
68    }
69}
70
71pub type Duplicates = HashMap<(u64, Hash), Vec<PathBuf>>;
72
73/// Useful stats about a successful [find] operation.
74pub struct Stats {
75    /// A map from hashes to paths. If a hash points to multiple paths, then it
76    /// means the files had the same hash, and are most likely duplicates of each
77    /// other.
78    pub duplicates: Duplicates,
79    /// The number of files that have been hashed.
80    pub total_files_processed: usize,
81    /// The number of bytes that have been processed.
82    pub total_bytes_processed: u64,
83}
84
85/// Finds and returns duplicated files on disk.
86pub fn find(params: &Params) -> io::Result<Stats> {
87    // TODO: what's a good minimum number?
88    let num_threads = num_cpus::get().min(16);
89    // give some leeway so that we don't hit the limit by accident
90    let fds = rlimit::getrlimit(rlimit::Resource::NOFILE)?.0 as usize - 4 * num_threads;
91
92    let (tx, rx) = mpsc::sync_channel(fds);
93    let mut threads = VecDeque::with_capacity(num_threads);
94    for _ in 0..num_threads {
95        let (thread_tx, thread_rx) = mpsc::sync_channel(fds / num_threads);
96        let db = params.db.clone();
97        let tx = tx.clone();
98        let handle = std::thread::spawn(move || hasher_task(db, thread_rx, tx));
99        threads.push_back((handle, thread_tx));
100    }
101    let collector = std::thread::spawn(|| collect(rx));
102    drop(tx);
103
104    let mut total_files_processed = 0;
105    let mut total_bytes_processed = 0;
106    let mut next_worker = 0;
107    for root in &params.roots {
108        for entry in WalkDir::new(root) {
109            let mut path = match entry {
110                Ok(p) => p.into_path(),
111                Err(e) => {
112                    eprintln!("io error occured: {}", e);
113                    continue;
114                }
115            };
116            if path.is_dir() || path.is_symlink() {
117                continue;
118            }
119            let md = match path.metadata() {
120                Ok(md) => md,
121                Err(e) => {
122                    eprintln!("io error when reading metadata {}: {}", path.display(), e);
123                    continue;
124                }
125            };
126
127            let size = md.len();
128            // TODO: other filters?
129            if size < params.lower_limit {
130                continue;
131            }
132            let mtime = FileTime::from_last_modification_time(&md);
133
134            let mut file = match File::open(&path) {
135                Ok(f) => f,
136                Err(e) => {
137                    eprintln!("failed to open {}: {}", path.display(), e);
138                    continue;
139                }
140            };
141
142            total_files_processed += 1;
143            total_bytes_processed += size;
144            'outer: loop {
145                for _ in 0..threads.len() {
146                    // we want to have each thread doing something, hence why we go
147                    // round-robin
148                    let tx = &threads[next_worker].1;
149                    next_worker = (next_worker + 1) % num_threads;
150                    match tx.try_send((path, file, mtime)) {
151                        Ok(()) => break 'outer,
152                        // if a thread crashed, we continue on, since we'll
153                        // see the error after we process all files
154                        Err(mpsc::TrySendError::Full((p, f, _)))
155                        | Err(mpsc::TrySendError::Disconnected((p, f, _))) => {
156                            path = p;
157                            file = f;
158                        }
159                    }
160                }
161                std::thread::yield_now();
162            }
163        }
164    }
165    // XXX: why doesn't rust "drop in place" rx if I use `_`?
166    for (t, rx) in threads {
167        drop(rx);
168        t.join().expect("failed to join with thread").expect("db operation failed");
169    }
170    Ok(Stats {
171        duplicates: collector.join().expect("failed to join with collector"),
172        total_files_processed,
173        total_bytes_processed,
174    })
175}
176
177fn hash_file(file: File) -> io::Result<(u64, Hash)> {
178    // blake3 docs suggest a 16 KiB buffer for best performance
179    let mut reader = BufReader::with_capacity(16 * 1024 * 1024, file);
180    let mut hasher = blake3::Hasher::new();
181    let mut size = 0;
182    loop {
183        let data = reader.fill_buf()?;
184        let len = data.len();
185        if len == 0 {
186            return Ok((size, hasher.finalize()));
187        } else {
188            hasher.update(data);
189            reader.consume(len);
190            size += len as u64;
191        }
192    }
193}
194
195fn hasher_task(
196    db: PathBuf,
197    tasks: Receiver<(PathBuf, File, FileTime)>,
198    tx: SyncSender<(PathBuf, io::Result<(u64, Hash)>)>,
199) -> rusqlite::Result<()> {
200    // each task has a connection to our db
201    let db = db::HashDb::try_new(db)?;
202    while let Ok((path, file, mtime)) = tasks.recv() {
203        let res = match retry_on_busy(|| db.select(&path))? {
204            // we found a matching file, so we don't need to compute the hash
205            Some(entry) if entry.mtime == mtime.unix_seconds() => Ok((entry.size, entry.hash)),
206            // we need to compute the hash and update our db
207            _ => {
208                let res = hash_file(file);
209                if let Ok((size, hash)) = res {
210                    let entry = db::Entry { path: &path, mtime: mtime.unix_seconds(), size, hash };
211                    retry_on_busy(|| db.insert(&entry))?;
212                    res
213                } else {
214                    res
215                }
216            }
217        };
218        if tx.send((path, res)).is_err() {
219            eprintln!("failed to send hash, quiting...");
220            break;
221        }
222    }
223    Ok(())
224}
225
226fn collect(rx: Receiver<(PathBuf, io::Result<(u64, Hash)>)>) -> Duplicates {
227    let mut entries: Duplicates = HashMap::new();
228    while let Ok((path, res)) = rx.recv() {
229        let (size, hash) = match res {
230            Ok(h) => h,
231            Err(e) => {
232                eprintln!("failed to read from {}: {}", path.display(), e);
233                continue;
234            }
235        };
236        match entries.entry((size, hash)) {
237            Occupied(mut v) => v.get_mut().push(path),
238            Vacant(v) => {
239                v.insert(vec![path]);
240            }
241        }
242    }
243    entries
244}