use std::{
fs,
io::prelude::*,
iter,
path::Path,
};
pub use arrayvec::ArrayString;
use blake3::Hasher;
use memmap2::Mmap;
use rayon::prelude::*;
use walkdir::{
DirEntry,
WalkDir,
};
pub const PATH_BATCH_SIZE: usize = 100;
pub const MAX_FILE_SIZE_FOR_UNBUFFERED_READ: u64 = 1024 + 1;
#[cfg(not(target_os = "windows"))]
pub const MIN_FILE_SIZE_FOR_MMAP_READ: u64 = 1024 * 1024 - 1;
#[cfg(target_os = "windows")]
pub const MIN_FILE_SIZE_FOR_MMAP_READ: u64 = 1024 * 1024 * 1024 - 1;
#[cfg(not(target_os = "windows"))]
pub const FILE_BUFFER_SIZE: usize = 32 * 1024;
#[cfg(target_os = "windows")]
pub const FILE_BUFFER_SIZE: usize = 128 * 1024;
#[inline]
fn is_hidden(entry: &DirEntry) -> bool {
entry
.file_name()
.to_str()
.map(|s| s != "." && s.starts_with("."))
.unwrap_or(false)
}
#[inline]
fn filter(ignore_hidden: bool) -> impl FnMut(&DirEntry) -> bool {
if ignore_hidden {
|entry: &DirEntry| -> bool { !is_hidden(entry) }
} else {
|_: &DirEntry| -> bool { true }
}
}
fn buffer_file_to_hasher(hasher: &mut Hasher, path: &Path) {
let mut file = fs::File::open(path).unwrap();
let mut buffer = [0; FILE_BUFFER_SIZE];
loop {
let buffer_size = file.read(&mut buffer[..]).unwrap();
if buffer_size == 0 { break; }
hasher.update(&buffer[..buffer_size]);
}
}
fn hash_path(root: &Path, entry: &DirEntry) -> [u8; 32] {
let path = entry.path();
let source_path = path.strip_prefix(root).unwrap().to_str().unwrap();
let source_type = entry.file_type();
let mut hasher = Hasher::new();
#[cfg(target_family = "unix")]
{
hasher.update(source_path.as_bytes());
}
#[cfg(target_family = "windows")]
{
hasher.update(source_path.replace("\\", "/").as_bytes());
}
if source_type.is_symlink() {
let symlink_target = fs::read_link(path).unwrap();
#[cfg(target_family = "unix")]
{
hasher.update(symlink_target.to_str().unwrap().as_bytes());
}
#[cfg(target_family = "windows")]
{
hasher.update(
symlink_target
.to_str()
.unwrap()
.replace("\\", "/")
.as_bytes(),
);
}
} else if source_type.is_file() {
let metadata = entry.metadata().unwrap();
let file_size = metadata.len();
if file_size == 0 {
return *hasher.finalize().as_bytes();
} else if file_size < MAX_FILE_SIZE_FOR_UNBUFFERED_READ {
let file = fs::read(path).unwrap();
hasher.update(&file);
} else if file_size > MIN_FILE_SIZE_FOR_MMAP_READ {
let file = fs::File::open(path).unwrap();
match unsafe { Mmap::map(&file) } {
Ok(mmap) => { hasher.update(&mmap); },
Err(_) => { buffer_file_to_hasher(&mut hasher, path) ; },
}
} else {
buffer_file_to_hasher(&mut hasher, path);
}
}
*hasher.finalize().as_bytes()
}
fn get_hashes_root(file_hashes: Vec<[u8; 32]>) -> ArrayString<64> {
let mut flattened_bytes = Vec::with_capacity(file_hashes.len() * 32);
for file_hash in &file_hashes {
flattened_bytes.extend_from_slice(file_hash);
}
blake3::hash(&flattened_bytes).to_hex()
}
pub fn hash_source(source: &Path, ignore_hidden: bool) -> ArrayString<64> {
let mut walker = WalkDir::new(source)
.follow_links(false)
.into_iter()
.filter_entry(filter(ignore_hidden));
let batch_iter = iter::from_fn(move || {
let mut batch = Vec::with_capacity(PATH_BATCH_SIZE);
for _ in 0..PATH_BATCH_SIZE {
match walker.next() {
Some(Ok(entry)) => batch.push(entry),
Some(Err(e)) => panic!("Critical: Failed to traverse directory: {e}"),
None => break,
}
}
if batch.is_empty() { None } else { Some(batch) }
});
let mut hashes: Vec<[u8; 32]> = batch_iter
.par_bridge()
.flat_map_iter(|batch| {
batch.into_iter().map(|entry| {
hash_path(source, &entry)
})
})
.collect();
hashes.par_sort_unstable();
get_hashes_root(hashes)
}