Intermediate Projects
Duplicate File Detector
A duplicate file detector identifies identical files across a directory tree. Rather than hashing every byte of every file on disk upfront (which wastes gigabytes of disk I/O on files that have unique sizes), a high-performance detector uses a three-stage progressive filtering pipeline:
- Stage 1 — Group by File Size (
Metadata::len): Any file whose byte length is unique in the directory tree cannot possibly have a duplicate—eliminating 80%–95% of files with zero file reads! - Stage 2 — Group by Partial Header Hash (First 4 KiB): For files sharing the exact same size, hash only the first 4,096 bytes. Files that differ in their header are eliminated without reading their remaining megabytes.
- Stage 3 — Full Content Hash: Compute the full content hash only for files that matched in both exact byte size and first-4-KiB hash!
Three-Stage Progressive Deduplication Pipeline:
All Files in Directory Tree (e.g., 10,000 files)
|
v Stage 1: Group by `file_size: u64` (`stat` metadata only, 0 file bytes read!)
Keep Only Groups with `len >= 2` (e.g., 800 candidate files)
|
v Stage 2: Group by `(file_size, hash_first_4k)` (Reads at most 4 KiB per candidate)
Keep Only Groups with `len >= 2` (e.g., 50 candidate files)
|
v Stage 3: Group by Full Content Hash (`hash_full_stream`)
Confirmed Duplicate Groups (`Vec<Vec<PathBuf>>`)!
Complete Runnable Implementation
use std::collections::HashMap;
use std::env;
use std::fs::{self, File};
use std::io::{self, Read};
use std::path::{Path, PathBuf};
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
const HEADER_SAMPLE_BYTES: usize = 4096;
/// Computes a 64-bit FNV-1a hash over a `Read` stream in 8 KiB chunks.
pub fn hash_reader<R: Read>(mut reader: R) -> io::Result<u64> {
let mut hash = FNV_OFFSET_BASIS;
let mut buf = [0u8; 8192];
loop {
let n = reader.read(&mut buf)?;
if n == 0 {
break;
}
for &b in &buf[..n] {
hash ^= u64::from(b);
hash = hash.wrapping_mul(FNV_PRIME);
}
}
Ok(hash)
}
pub fn hash_file_prefix(path: &Path, max_bytes: usize) -> io::Result<u64> {
let file = File::open(path)?;
hash_reader(file.take(max_bytes as u64))
}
pub fn hash_full_file(path: &Path) -> io::Result<u64> {
let file = File::open(path)?;
hash_reader(file)
}
/// Finds all duplicate non-empty files in `root` using the 3-stage progressive filter.
pub fn find_duplicate_groups(root: impl AsRef<Path>) -> io::Result<Vec<Vec<PathBuf>>> {
// Stage 1: Group files by exact byte size (skipping 0-byte empty files)
let mut by_size: HashMap<u64, Vec<PathBuf>> = HashMap::new();
let mut stack = vec![root.as_ref().to_path_buf()];
while let Some(dir) = stack.pop() {
for entry_res in fs::read_dir(&dir)? {
let entry = entry_res?;
let ftype = entry.file_type()?;
if ftype.is_symlink() {
continue;
}
let path = entry.path();
if ftype.is_dir() {
stack.push(path);
} else if ftype.is_file() {
let len = entry.metadata()?.len();
if len > 0 {
by_size.entry(len).or_default().push(path);
}
}
}
}
// Stage 2: For size collisions (`paths.len() >= 2`), group by 4 KiB header hash
let mut by_prefix: HashMap<(u64, u64), Vec<PathBuf>> = HashMap::new();
for (size, paths) in by_size {
if paths.len() < 2 {
continue; // Unique file size -> impossible to be a duplicate!
}
for path in paths {
if let Ok(prefix_hash) = hash_file_prefix(&path, HEADER_SAMPLE_BYTES) {
by_prefix.entry((size, prefix_hash)).or_default().push(path);
}
}
}
// Stage 3: For header collisions (`paths.len() >= 2`), group by full-file hash
let mut by_full_hash: HashMap<(u64, u64), Vec<PathBuf>> = HashMap::new();
for ((size, _), paths) in by_prefix {
if paths.len() < 2 {
continue;
}
for path in paths {
if let Ok(full_hash) = hash_full_file(&path) {
by_full_hash.entry((size, full_hash)).or_default().push(path);
}
}
}
let mut duplicates: Vec<Vec<PathBuf>> = by_full_hash
.into_values()
.filter(|group| group.len() >= 2)
.map(|mut group| {
group.sort_unstable();
group
})
.collect();
duplicates.sort_unstable_by(|a, b| a[0].cmp(&b[0]));
Ok(duplicates)
}
fn main() -> io::Result<()> {
let dir = env::temp_dir().join("rust_ch67_dedup_demo");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join("backup"))?;
// Two identical files (`a.txt` and `backup/a_copy.txt`)
fs::write(dir.join("a.txt"), "identical payload bytes 12345")?;
fs::write(dir.join("backup/a_copy.txt"), "identical payload bytes 12345")?;
// Same exact byte length (29 bytes), but different content (`b.txt`)
fs::write(dir.join("b.txt"), "different payload bytes 99999")?;
// Unique byte length (`unique.txt`) -> filtered out at Stage 1 without reading!
fs::write(dir.join("unique.txt"), "short")?;
let groups = find_duplicate_groups(&dir)?;
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].len(), 2);
println!("Found {} duplicate group(s):", groups.len());
for group in &groups {
let names: Vec<_> = group
.iter()
.map(|p| p.strip_prefix(&dir).unwrap().display().to_string())
.collect();
println!(" Duplicates: {names:?}");
}
fs::remove_dir_all(&dir)?;
Ok(())
}
Found 1 duplicate group(s):
Duplicates: ["a.txt", "backup/a_copy.txt"]