1#![allow(clippy::unwrap_used, clippy::ptr_arg)]
7use blake3::{Hasher, Hash};
8use std::io::{Read, Seek};
9use std::os::unix::fs::FileExt;
10use std::os::unix::io::AsRawFd;
11use std::path::Path;
12use std::fs::File;
13use crate::error::{Result, FxcpError};
14use crate::metrics;
15use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
16
17static LITE_HASH_THRESHOLD_BYTES: AtomicU64 = AtomicU64::new(crate::constants::LITE_HASH_DEFAULT_THRESHOLD);
18pub const MIN_CHUNK_SIZE: usize = crate::constants::MIN_CHUNK_SIZE;
20pub const CHUNK_SIZE: usize = MIN_CHUNK_SIZE;
22pub const MAX_LEAVES_PER_XATTR: u64 = crate::constants::MAX_LEAVES_PER_XATTR;
24
25const RAYON_HASH_THRESHOLD: usize = crate::constants::RAYON_HASH_THRESHOLD as usize;
26
27static HASHING_ENABLED: AtomicBool = AtomicBool::new(true);
28
29pub fn set_hashing_enabled(enabled: bool) {
31 HASHING_ENABLED.store(enabled, Ordering::Relaxed);
32}
33
34pub fn is_hashing_enabled() -> bool {
36 HASHING_ENABLED.load(Ordering::Relaxed)
37}
38
39pub fn set_lite_threshold_kb(kb: u64) {
41 LITE_HASH_THRESHOLD_BYTES.store(kb * 1024, Ordering::Relaxed);
42}
43
44pub fn get_lite_threshold_bytes() -> u64 {
46 LITE_HASH_THRESHOLD_BYTES.load(Ordering::Relaxed)
47}
48
49pub fn calculate_adaptive_chunk_size(file_size: u64) -> u64 {
55 if file_size == 0 {
56 return MIN_CHUNK_SIZE as u64;
57 }
58 if file_size > crate::merkle_index::INDEX_THRESHOLD {
60 return crate::merkle_index::LARGE_FILE_CHUNK_SIZE;
61 }
62 let desired = file_size.div_ceil(MAX_LEAVES_PER_XATTR);
64 std::cmp::max(MIN_CHUNK_SIZE as u64, desired).next_power_of_two()
65}
66
67pub fn hash_file_lite(path: &Path, size: u64) -> Result<Option<Hash>> {
69 if !is_hashing_enabled() { return Ok(None); }
70 if size < get_lite_threshold_bytes() { return Ok(None); }
71
72 let _timer = metrics::HASH_COMPUTATION_DURATION.start_timer();
73 let file = File::open(path)?;
74 let mut hasher = Hasher::new();
75 hasher.update(&size.to_le_bytes());
76
77 let mut buf = vec![0u8; MIN_CHUNK_SIZE];
79
80 let head_read = file.read_at(&mut buf, 0).unwrap_or(0);
81 hasher.update(&buf[..head_read]);
82
83 if size > MIN_CHUNK_SIZE as u64 * 2 {
84 let tail_offset = size - MIN_CHUNK_SIZE as u64;
85 let tail_read = file.read_at(&mut buf, tail_offset).unwrap_or(0);
86 hasher.update(&buf[..tail_read]);
87 }
88
89 Ok(Some(hasher.finalize()))
90}
91
92pub fn hash_file_full(path: &Path) -> Result<Option<Hash>> {
97 if !is_hashing_enabled() { return Ok(None); }
98
99 let _timer = metrics::HASH_COMPUTATION_DURATION.start_timer();
100 let mut file = File::open(path)?;
101 let file_size = file.metadata()?.len() as usize;
102 let mut hasher = Hasher::new();
103
104 if file_size > RAYON_HASH_THRESHOLD {
105 let mut data = Vec::with_capacity(file_size);
106 file.read_to_end(&mut data)?;
107 hasher.update_rayon(&data);
108 } else {
109 let mut buf = vec![0u8; CHUNK_SIZE];
110 loop {
111 let n = file.read(&mut buf)?;
112 if n == 0 { break; }
113 hasher.update(&buf[..n]);
114 }
115 }
116
117 Ok(Some(hasher.finalize()))
118}
119
120pub fn hash_buffer_lite(data: &[u8], size: u64) -> Option<Hash> {
122 if !is_hashing_enabled() { return None; }
123 if size < get_lite_threshold_bytes() { return None; }
124
125 let mut hasher = Hasher::new();
126 hasher.update(&size.to_le_bytes());
127
128 let head_len = CHUNK_SIZE.min(data.len());
129 hasher.update(&data[..head_len]);
130
131 if size > CHUNK_SIZE as u64 * 2 {
132 let tail_start = data.len().saturating_sub(CHUNK_SIZE);
133 hasher.update(&data[tail_start..]);
134 }
135
136 Some(hasher.finalize())
137}
138
139pub fn hash_buffer_full(data: &[u8]) -> Option<Hash> {
144 if !is_hashing_enabled() { return None; }
145 if data.len() > RAYON_HASH_THRESHOLD {
146 let mut hasher = Hasher::new();
147 hasher.update_rayon(data);
148 Some(hasher.finalize())
149 } else {
150 Some(blake3::hash(data))
151 }
152}
153
154pub fn verify_incremental(src: &Path, dst: &Path, size: u64) -> Result<bool> {
156 if !is_hashing_enabled() { return Ok(true); }
157 if size < get_lite_threshold_bytes() { return Ok(true); }
158
159 let src_hash = match hash_file_lite(src, size) {
160 Ok(h) => h,
161 Err(crate::error::FxcpError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {
162 return Ok(true);
164 },
165 Err(e) => return Err(e),
166 };
167 let dst_hash = match hash_file_lite(dst, size) {
168 Ok(h) => h,
169 Err(crate::error::FxcpError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {
170 return Ok(false);
172 },
173 Err(e) => return Err(e),
174 };
175
176 Ok(src_hash == dst_hash)
177}
178
179pub fn compute_dir_hash_from_path(dir_path: &std::path::Path) -> Option<[u8; 32]> {
184 use std::os::unix::fs::MetadataExt;
185 let mut children: Vec<(String, [u8; 32])> = Vec::new();
186 for entry in std::fs::read_dir(dir_path).ok()? {
187 let entry = entry.ok()?;
188 let name = entry.file_name().to_string_lossy().into_owned();
189 if name.starts_with('.') && name.ends_with(".foxing_meta") { continue; }
190 if name.starts_with(".foxing") { continue; }
191 if let Ok(meta) = entry.metadata() {
192 let mut hasher = Hasher::new();
193 hasher.update(&meta.len().to_le_bytes());
194 hasher.update(&meta.mtime().to_le_bytes());
195 hasher.update(&meta.mtime_nsec().to_le_bytes());
196 if meta.is_dir() { hasher.update(b"d"); } else { hasher.update(b"f"); }
197 children.push((name, *hasher.finalize().as_bytes()));
198 }
199 }
200 if children.is_empty() { return Some(*blake3::hash(b"foxing:empty_dir").as_bytes()); }
201 Some(compute_dir_hash(&mut children))
202}
203
204pub fn compute_dir_hash(children: &mut Vec<(String, [u8; 32])>) -> [u8; 32] {
208 children.sort_by(|a, b| a.0.cmp(&b.0));
209 let mut hasher = Hasher::new();
210 for (name, hash) in children.iter() {
211 hasher.update(name.as_bytes());
212 hasher.update(b":");
213 hasher.update(hash);
214 }
215 *hasher.finalize().as_bytes()
216}
217
218pub fn compute_dir_hash_from_s3_children(children: &mut Vec<(String, u64, String)>) -> [u8; 32] {
228 let mut hashed: Vec<(String, [u8; 32])> = children
229 .iter()
230 .map(|(name, size, hash_str)| {
231 let mut h = Hasher::new();
232 h.update(&size.to_le_bytes());
233 h.update(hash_str.as_bytes());
234 (name.clone(), *h.finalize().as_bytes())
235 })
236 .collect();
237 compute_dir_hash(&mut hashed)
238}
239
240pub struct DirHashAccumulator {
251 entries: std::collections::HashMap<String, Vec<(String, u64, String)>>,
253}
254
255impl DirHashAccumulator {
256 pub fn new() -> Self {
257 Self {
258 entries: std::collections::HashMap::new(),
259 }
260 }
261
262 pub fn add(
264 &mut self,
265 prefix: impl Into<String>,
266 name: impl Into<String>,
267 size: u64,
268 hash_str: impl Into<String>,
269 ) {
270 self.entries
271 .entry(prefix.into())
272 .or_default()
273 .push((name.into(), size, hash_str.into()));
274 }
275
276 pub fn finalize(&mut self, prefix: &str) -> Option<[u8; 32]> {
279 let mut children = self.entries.remove(prefix)?;
280 if children.is_empty() {
281 return None;
282 }
283 Some(compute_dir_hash_from_s3_children(&mut children))
284 }
285
286 pub fn drain_all(&mut self) -> Vec<(String, [u8; 32])> {
288 let prefixes: Vec<String> = self.entries.keys().cloned().collect();
289 let mut result = Vec::new();
290 for prefix in prefixes {
291 if let Some(hash) = self.finalize(&prefix) {
292 result.push((prefix, hash));
293 }
294 }
295 result
296 }
297}
298
299impl Default for DirHashAccumulator {
300 fn default() -> Self {
301 Self::new()
302 }
303}
304
305#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
314pub enum ComputeMode {
315 Blake3Only,
317 WithEntropy,
319 WithSimilarity,
321}
322
323pub struct ChunkHash {
325 pub offset: u64,
327 pub length: u32,
329 pub hash: Hash,
331 pub entropy: f32,
333 #[cfg(feature = "similarity")]
335 pub simhash: u64,
336 #[cfg(feature = "similarity")]
338 pub tlsh_digest: Option<Vec<u8>>,
339}
340
341pub struct MerkleTree {
343 pub chunk_size: u64,
345 pub file_size: u64,
347 pub root: Hash,
349 pub leaves: Vec<ChunkHash>,
351}
352
353#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
355pub struct DirtyRange {
356 pub offset: u64,
358 pub length: u64,
360}
361
362#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
364pub struct MerkleSignature {
365 pub root: [u8; 32],
367 pub chunk_size: u64,
369 pub file_size: u64,
371 pub leaf_hashes: Vec<[u8; 32]>,
373 #[serde(default)]
375 pub chunk_entropies: Option<Vec<f32>>,
376 #[serde(default)]
378 pub chunk_simhashes: Option<Vec<u64>>,
379}
380
381const MAX_MERKLE_XATTR_BYTES: usize = crate::constants::MERKLE_XATTR_MAX_BYTES;
382
383impl MerkleTree {
384 pub fn from_file(path: &Path, chunk_size: u64, compute_mode: ComputeMode) -> Result<Self> {
386 let file = File::open(path)?;
387 let file_size = file.metadata()?.len();
388 if file_size == 0 {
389 return Ok(MerkleTree { chunk_size, file_size, root: blake3::hash(b""), leaves: Vec::new() });
390 }
391
392 let data_extents = map_data_extents(file.as_raw_fd(), file_size);
394 let is_sparse = data_extents.as_ref().map(|e| !e.is_empty() && {
395 let data_bytes: u64 = e.iter().map(|&(_, len)| len).sum();
396 data_bytes < file_size
397 }).unwrap_or(false);
398
399 if is_sparse {
400 return Self::from_file_sparse(&file, file_size, chunk_size, &data_extents.unwrap(), compute_mode);
401 }
402
403 let mut file = file;
407 file.seek(std::io::SeekFrom::Start(0))?;
408 let mut leaves = Vec::new();
409 let mut buf = vec![0u8; chunk_size as usize];
410 let mut offset = 0u64;
411
412 loop {
413 let mut read_total = 0;
414 loop {
415 let n = file.read(&mut buf[read_total..])?;
416 if n == 0 { break; }
417 read_total += n;
418 if read_total >= chunk_size as usize { break; }
419 }
420 if read_total == 0 { break; }
421
422 let hash = blake3::hash(&buf[..read_total]);
423 let entropy = if compute_mode >= ComputeMode::WithEntropy {
424 shannon_entropy(&buf[..read_total])
425 } else {
426 0.0
427 };
428 #[cfg(feature = "similarity")]
429 let simhash = if compute_mode == ComputeMode::WithSimilarity {
430 crate::similarity::simhash_64(&buf[..read_total])
431 } else {
432 0
433 };
434 leaves.push(ChunkHash {
435 offset,
436 length: u32::try_from(read_total).map_err(|_| FxcpError::Config(format!(
437 "Merkle chunk read_total {} exceeds u32::MAX", read_total
438 )))?,
439 hash,
440 entropy,
441 #[cfg(feature = "similarity")]
442 simhash,
443 #[cfg(feature = "similarity")]
444 tlsh_digest: None,
445 });
446 offset += read_total as u64;
447 }
448
449 let root = Self::compute_root(&leaves);
450 Ok(MerkleTree { chunk_size, file_size, root, leaves })
451 }
452
453 fn from_file_sparse(
456 file: &File,
457 file_size: u64,
458 chunk_size: u64,
459 data_extents: &[(u64, u64)],
460 compute_mode: ComputeMode,
461 ) -> Result<Self> {
462 let sentinel = blake3::hash(b"foxing:sparse:hole");
463 let total_chunks = file_size.div_ceil(chunk_size);
464 let mut leaves = Vec::with_capacity(total_chunks as usize);
465 let mut buf = vec![0u8; chunk_size as usize];
466
467 for chunk_idx in 0..total_chunks {
468 let chunk_offset = chunk_idx * chunk_size;
469 let chunk_len = u32::try_from(chunk_size.min(file_size - chunk_offset)).map_err(|_| FxcpError::Config(format!(
470 "Sparse Merkle chunk length {} exceeds u32::MAX", chunk_size.min(file_size - chunk_offset)
471 )))?;
472
473 if chunk_is_hole(data_extents, chunk_offset, chunk_len as u64) {
474 leaves.push(ChunkHash {
475 offset: chunk_offset,
476 length: chunk_len,
477 hash: sentinel,
478 entropy: 0.0,
479 #[cfg(feature = "similarity")]
480 simhash: 0,
481 #[cfg(feature = "similarity")]
482 tlsh_digest: None,
483 });
484 } else {
485 let read_len = chunk_len as usize;
486 buf[..read_len].fill(0);
487 let mut read_total = 0;
488 loop {
489 let n = file.read_at(&mut buf[read_total..read_len], chunk_offset + read_total as u64)?;
490 if n == 0 { break; }
491 read_total += n;
492 if read_total >= read_len { break; }
493 }
494 let hash = blake3::hash(&buf[..read_total]);
495 let entropy = if compute_mode >= ComputeMode::WithEntropy {
496 shannon_entropy(&buf[..read_total])
497 } else {
498 0.0
499 };
500 #[cfg(feature = "similarity")]
501 let simhash = if compute_mode == ComputeMode::WithSimilarity {
502 crate::similarity::simhash_64(&buf[..read_total])
503 } else {
504 0
505 };
506 leaves.push(ChunkHash {
507 offset: chunk_offset,
508 length: u32::try_from(read_total).map_err(|_| FxcpError::Config(format!(
509 "Sparse Merkle chunk read_total {} exceeds u32::MAX", read_total
510 )))?,
511 hash,
512 entropy,
513 #[cfg(feature = "similarity")]
514 simhash,
515 #[cfg(feature = "similarity")]
516 tlsh_digest: None,
517 });
518 }
519 }
520
521 let root = Self::compute_root(&leaves);
522 Ok(MerkleTree { chunk_size, file_size, root, leaves })
523 }
524
525 #[cfg(feature = "similarity")]
529 pub fn file_simhash(&self) -> u64 {
530 self.leaves.iter().fold(0u64, |acc, chunk| acc ^ chunk.simhash)
531 }
532
533 fn compute_root(leaves: &[ChunkHash]) -> Hash {
535 if leaves.is_empty() {
536 return blake3::hash(b"");
537 }
538 if leaves.len() == 1 {
539 return leaves[0].hash;
540 }
541 let mut hasher = Hasher::new();
542 for leaf in leaves {
543 hasher.update(leaf.hash.as_bytes());
544 }
545 hasher.finalize()
546 }
547
548 pub fn diff(source: &MerkleTree, target: &MerkleTree) -> Vec<DirtyRange> {
551 let mut ranges = Vec::new();
552 let max_leaves = source.leaves.len().max(target.leaves.len());
553
554 let mut current_dirty: Option<DirtyRange> = None;
555
556 for i in 0..max_leaves {
557 let src_chunk = source.leaves.get(i);
558 let tgt_chunk = target.leaves.get(i);
559
560 let is_dirty = match (src_chunk, tgt_chunk) {
561 (Some(s), Some(t)) => s.hash != t.hash,
562 (Some(_), None) => true, (None, Some(_)) => false, (None, None) => false,
565 };
566
567 if is_dirty {
568 let s = src_chunk.unwrap();
569 match &mut current_dirty {
570 Some(range) => {
571 range.length = (s.offset + s.length as u64) - range.offset;
573 }
574 None => {
575 current_dirty = Some(DirtyRange {
577 offset: s.offset,
578 length: s.length as u64,
579 });
580 }
581 }
582 } else if let Some(range) = current_dirty.take() {
583 ranges.push(range);
584 }
585 }
586
587 if let Some(range) = current_dirty {
589 ranges.push(range);
590 }
591
592 ranges
593 }
594
595 pub fn to_signature(&self) -> MerkleSignature {
597 let chunk_entropies = if self.leaves.iter().any(|c| c.entropy != 0.0) {
598 Some(self.leaves.iter().map(|c| c.entropy).collect())
599 } else {
600 None
601 };
602
603 #[cfg(feature = "similarity")]
604 let chunk_simhashes = if self.leaves.iter().any(|c| c.simhash != 0) {
605 Some(self.leaves.iter().map(|c| c.simhash).collect())
606 } else {
607 None
608 };
609 #[cfg(not(feature = "similarity"))]
610 let chunk_simhashes = None;
611
612 MerkleSignature {
613 root: *self.root.as_bytes(),
614 chunk_size: self.chunk_size,
615 file_size: self.file_size,
616 leaf_hashes: self.leaves.iter().map(|l| *l.hash.as_bytes()).collect(),
617 chunk_entropies,
618 chunk_simhashes,
619 }
620 }
621
622 pub fn from_signature(sig: &MerkleSignature) -> Option<Self> {
624 if sig.chunk_size == 0 { return None; }
626 let expected_max = sig.file_size / sig.chunk_size + 2;
627 if sig.leaf_hashes.len() as u64 > expected_max {
628 return None;
629 }
630
631 let mut leaves = Vec::with_capacity(sig.leaf_hashes.len());
632 let mut offset = 0u64;
633 for (i, hash_bytes) in sig.leaf_hashes.iter().enumerate() {
634 let remaining = sig.file_size.saturating_sub(offset);
635 let length = u32::try_from(remaining.min(sig.chunk_size)).ok()?;
636 if length == 0 && i > 0 { break; }
637
638 let entropy = sig.chunk_entropies.as_ref()
639 .and_then(|v| v.get(i).copied())
640 .unwrap_or(0.0);
641
642 #[cfg(feature = "similarity")]
643 let simhash = sig.chunk_simhashes.as_ref()
644 .and_then(|v| v.get(i).copied())
645 .unwrap_or(0);
646
647 leaves.push(ChunkHash {
648 offset,
649 length,
650 hash: Hash::from_bytes(*hash_bytes),
651 entropy,
652 #[cfg(feature = "similarity")]
653 simhash,
654 #[cfg(feature = "similarity")]
655 tlsh_digest: None,
656 });
657 offset += length as u64;
658 }
659
660 let computed = Self::compute_root(&leaves);
661 if computed.as_bytes() != &sig.root {
662 return None;
663 }
664
665 let root = Hash::from_bytes(sig.root);
666 Some(MerkleTree {
667 chunk_size: sig.chunk_size,
668 file_size: sig.file_size,
669 root,
670 leaves,
671 })
672 }
673}
674
675impl MerkleSignature {
676 pub fn serialized_size(&self) -> usize {
678 let base = 56 + self.leaf_hashes.len() * 32;
680 let entropy_size = if self.chunk_entropies.is_some() { self.leaf_hashes.len() * 4 } else { 0 };
681 let simhash_size = if self.chunk_simhashes.is_some() { self.leaf_hashes.len() * 8 } else { 0 };
682 base + entropy_size + simhash_size
683 }
684
685 pub fn fits_in_xattr(&self) -> bool {
687 self.serialized_size() <= MAX_MERKLE_XATTR_BYTES
688 }
689}
690
691pub fn verify_with_merkle(src: &Path, stored_sig: &MerkleSignature) -> Result<bool> {
697 let src_tree = MerkleTree::from_file(src, stored_sig.chunk_size, ComputeMode::Blake3Only)?;
698 Ok(src_tree.root.as_bytes() == &stored_sig.root)
699}
700
701pub fn map_data_extents(fd: std::os::unix::io::RawFd, file_size: u64) -> std::result::Result<Vec<(u64, u64)>, FxcpError> {
709 let mut extents = Vec::new();
710 let mut pos = 0u64;
711
712 while pos < file_size {
713 let data_start = unsafe { libc::lseek(fd, pos as i64, libc::SEEK_DATA) };
715 if data_start < 0 {
716 let err = std::io::Error::last_os_error();
717 if err.raw_os_error() == Some(libc::ENXIO) {
718 break; }
720 return Err(FxcpError::Io(err));
721 }
722 let data_start = data_start as u64;
723 if data_start >= file_size { break; }
724
725 let hole_start = unsafe { libc::lseek(fd, data_start as i64, libc::SEEK_HOLE) };
727 let data_end = if hole_start < 0 { file_size } else { (hole_start as u64).min(file_size) };
728
729 if data_end > data_start {
730 extents.push((data_start, data_end - data_start));
731 }
732 pos = data_end;
733 }
734
735 Ok(extents)
736}
737
738fn chunk_is_hole(data_extents: &[(u64, u64)], chunk_offset: u64, chunk_len: u64) -> bool {
740 let chunk_end = chunk_offset + chunk_len;
741 for &(ext_offset, ext_len) in data_extents {
742 let ext_end = ext_offset + ext_len;
743 if ext_offset < chunk_end && ext_end > chunk_offset {
745 return false;
746 }
747 if ext_offset >= chunk_end { break; }
749 }
750 true
751}
752
753
754
755pub fn shannon_entropy(data: &[u8]) -> f32 {
773 if data.is_empty() {
774 return 0.0;
775 }
776 let mut counts = [0u64; 256];
777 for &byte in data {
778 counts[byte as usize] += 1;
779 }
780 let len = data.len() as f64;
781 counts
782 .iter()
783 .filter(|&&c| c > 0)
784 .map(|&c| {
785 let p = c as f64 / len;
786 -(p * p.log2()) as f32
787 })
788 .sum()
789}
790
791pub fn log_crypto_capabilities() {
799 #[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
800 {
801 let blake3_accel = if is_x86_feature_detected!("avx512f") {
802 "avx512"
803 } else if is_x86_feature_detected!("avx2") {
804 "avx2"
805 } else if is_x86_feature_detected!("sse4.1") {
806 "sse4.1"
807 } else {
808 "software"
809 };
810
811 let sha256_accel = if is_x86_feature_detected!("sha") {
813 "SHA-NI (hardware)"
814 } else if is_x86_feature_detected!("avx2") {
815 "avx2 (software)"
816 } else {
817 "software"
818 };
819
820 tracing::info!("Crypto: BLAKE3={}, SHA-256={}, SHA-1={}, MD5=software",
821 blake3_accel, sha256_accel,
822 if is_x86_feature_detected!("sha") { "SHA-NI" } else { "software" });
823 }
824
825 #[cfg(target_arch = "aarch64")]
826 {
827 let sha_accel = if std::arch::is_aarch64_feature_detected!("sha2") {
828 "ARMv8-CE (hardware)"
829 } else {
830 "software"
831 };
832 tracing::info!("Crypto: BLAKE3=neon, SHA-256={}, SHA-1={}, MD5=software",
833 sha_accel, sha_accel);
834 }
835
836 #[cfg(not(any(target_arch = "x86", target_arch = "x86_64", target_arch = "aarch64")))]
837 {
838 tracing::info!("Crypto: BLAKE3=software, SHA-256=software, SHA-1=software, MD5=software");
839 }
840}
841
842#[derive(Debug, Clone, Copy, PartialEq, Eq)]
849pub enum ChecksumType {
850 Blake3,
852 #[cfg(feature = "checksums")]
854 Sha256,
855 #[cfg(feature = "checksums")]
857 Sha1,
858 #[cfg(feature = "checksums")]
860 Md5,
861}
862
863impl ChecksumType {
864 pub fn extension(&self) -> &'static str {
866 match self {
867 Self::Blake3 => "blake3",
868 #[cfg(feature = "checksums")]
869 Self::Sha256 => "sha256",
870 #[cfg(feature = "checksums")]
871 Self::Sha1 => "sha1",
872 #[cfg(feature = "checksums")]
873 Self::Md5 => "md5",
874 }
875 }
876
877 pub fn is_free(&self) -> bool {
879 matches!(self, Self::Blake3)
880 }
881
882 pub fn parse(s: &str) -> Option<Self> {
884 match s.trim().to_lowercase().as_str() {
885 "blake3" | "b3" => Some(Self::Blake3),
886 #[cfg(feature = "checksums")]
887 "sha256" | "sha-256" | "sha2" => Some(Self::Sha256),
888 #[cfg(feature = "checksums")]
889 "sha1" | "sha-1" => Some(Self::Sha1),
890 #[cfg(feature = "checksums")]
891 "md5" => Some(Self::Md5),
892 _ => None,
893 }
894 }
895
896 pub fn parse_list(s: &str) -> Vec<Self> {
898 s.split(',').filter_map(Self::parse).collect()
899 }
900}
901
902pub fn hash_file_checksum(path: &Path, checksum_type: ChecksumType) -> Result<String> {
904 match checksum_type {
905 ChecksumType::Blake3 => {
906 match hash_file_full(path)? {
908 Some(h) => Ok(h.to_hex().to_string()),
909 None => {
910 let mut file = File::open(path)?;
912 let mut hasher = Hasher::new();
913 let mut buf = vec![0u8; CHUNK_SIZE];
914 loop {
915 let n = file.read(&mut buf)?;
916 if n == 0 { break; }
917 hasher.update(&buf[..n]);
918 }
919 Ok(hasher.finalize().to_hex().to_string())
920 }
921 }
922 }
923 #[cfg(feature = "checksums")]
924 ChecksumType::Sha256 => {
925 use sha2::Digest;
926 let mut file = File::open(path)?;
927 let mut hasher = sha2::Sha256::new();
928 let mut buf = vec![0u8; CHUNK_SIZE];
929 loop {
930 let n = file.read(&mut buf)?;
931 if n == 0 { break; }
932 hasher.update(&buf[..n]);
933 }
934 Ok(format!("{:x}", hasher.finalize()))
935 }
936 #[cfg(feature = "checksums")]
937 ChecksumType::Sha1 => {
938 use sha1::Digest;
939 let mut file = File::open(path)?;
940 let mut hasher = sha1::Sha1::new();
941 let mut buf = vec![0u8; CHUNK_SIZE];
942 loop {
943 let n = file.read(&mut buf)?;
944 if n == 0 { break; }
945 hasher.update(&buf[..n]);
946 }
947 Ok(format!("{:x}", hasher.finalize()))
948 }
949 #[cfg(feature = "checksums")]
950 ChecksumType::Md5 => {
951 use md5::Digest;
952 let mut file = File::open(path)?;
953 let mut hasher = md5::Md5::new();
954 let mut buf = vec![0u8; CHUNK_SIZE];
955 loop {
956 let n = file.read(&mut buf)?;
957 if n == 0 { break; }
958 hasher.update(&buf[..n]);
959 }
960 Ok(format!("{:x}", hasher.finalize()))
961 }
962 }
963}
964
965pub fn write_checksum_files(dst: &Path, types: &[ChecksumType], overwrite: bool, cid_format: bool) -> Result<()> {
969 let filename = match dst.file_name() {
970 Some(f) => f.to_string_lossy(),
971 None => return Ok(()),
972 };
973 for ct in types {
974 let hex = hash_file_checksum(dst, *ct)?;
975 let hash_str = if cid_format && *ct == ChecksumType::Blake3 {
976 crate::cid::blake3_hex_to_cid_string(&hex).unwrap_or(hex)
977 } else {
978 hex
979 };
980 let ext = ct.extension();
981 let sidecar = dst.with_file_name(format!("{}.{}", filename, ext));
982 if !overwrite && sidecar.exists() { continue; }
983 std::fs::write(&sidecar, format!("{} {}\n", hash_str, filename))
984 .map_err(FxcpError::Io)?;
985 }
986 Ok(())
987}
988
989#[cfg(test)]
990#[allow(clippy::unwrap_used, clippy::expect_used)]
991mod hashing_tests {
992 use super::*;
993 use tempfile::TempDir;
994
995 #[test]
996 fn test_hash_file_lite_on_small_file() {
997 set_hashing_enabled(true);
998 let dir = TempDir::new().unwrap();
999 let path = dir.path().join("test.bin");
1000 let data = vec![0xABu8; 1024];
1001 std::fs::write(&path, &data).unwrap();
1002
1003 let result = hash_file_lite(&path, 1024).unwrap();
1005 assert!(result.is_none());
1006
1007 set_lite_threshold_kb(0);
1009 let hash1 = hash_file_lite(&path, 1024).unwrap().unwrap();
1010 let hash2 = hash_file_lite(&path, 1024).unwrap().unwrap();
1011 assert_eq!(hash1, hash2);
1012
1013 let path2 = dir.path().join("test2.bin");
1015 std::fs::write(&path2, vec![0xCDu8; 1024]).unwrap();
1016 let hash3 = hash_file_lite(&path2, 1024).unwrap().unwrap();
1017 assert_ne!(hash1, hash3);
1018
1019 set_lite_threshold_kb(128);
1021 }
1022
1023 #[test]
1024 fn test_compute_dir_hash_deterministic() {
1025 let entry_hash_a = *blake3::hash(b"entry_a").as_bytes();
1026 let entry_hash_b = *blake3::hash(b"entry_b").as_bytes();
1027
1028 let mut children1 = vec![
1029 ("alpha".to_string(), entry_hash_a),
1030 ("beta".to_string(), entry_hash_b),
1031 ];
1032 let hash1 = compute_dir_hash(&mut children1);
1033
1034 let mut children2 = vec![
1036 ("beta".to_string(), entry_hash_b),
1037 ("alpha".to_string(), entry_hash_a),
1038 ];
1039 let hash2 = compute_dir_hash(&mut children2);
1040 assert_eq!(hash1, hash2);
1041
1042 let mut children3 = vec![
1044 ("alpha".to_string(), entry_hash_a),
1045 ("gamma".to_string(), entry_hash_b),
1046 ];
1047 let hash3 = compute_dir_hash(&mut children3);
1048 assert_ne!(hash1, hash3);
1049 }
1050
1051 #[test]
1052 fn test_merkle_signature_roundtrip() {
1053 let chunk_a = blake3::hash(b"chunk_a");
1054 let chunk_b = blake3::hash(b"chunk_b");
1055 let chunk_c = blake3::hash(b"chunk_c");
1056
1057 let leaves = vec![
1058 ChunkHash { offset: 0, length: 65536, hash: chunk_a, entropy: 0.0, #[cfg(feature = "similarity")] simhash: 0, #[cfg(feature = "similarity")] tlsh_digest: None },
1059 ChunkHash { offset: 65536, length: 65536, hash: chunk_b, entropy: 0.0, #[cfg(feature = "similarity")] simhash: 0, #[cfg(feature = "similarity")] tlsh_digest: None },
1060 ChunkHash { offset: 131072, length: 65536, hash: chunk_c, entropy: 0.0, #[cfg(feature = "similarity")] simhash: 0, #[cfg(feature = "similarity")] tlsh_digest: None },
1061 ];
1062 let root = MerkleTree::compute_root(&leaves);
1063 let tree = MerkleTree {
1064 chunk_size: 65536,
1065 file_size: 65536 * 3,
1066 root,
1067 leaves,
1068 };
1069
1070 let sig = tree.to_signature();
1071 assert_eq!(sig.root, *tree.root.as_bytes());
1072 assert_eq!(sig.file_size, 65536 * 3);
1073 assert_eq!(sig.chunk_size, 65536);
1074 assert_eq!(sig.leaf_hashes.len(), 3);
1075
1076 let reconstructed = MerkleTree::from_signature(&sig).unwrap();
1077 assert_eq!(*reconstructed.root.as_bytes(), sig.root);
1078 assert_eq!(reconstructed.leaves.len(), 3);
1079 assert_eq!(reconstructed.file_size, 65536 * 3);
1080 }
1081
1082 #[test]
1083 fn test_merkle_diff_detects_changed_chunk() {
1084 let hash_a = blake3::hash(b"chunk_data_a");
1085 let hash_b = blake3::hash(b"chunk_data_b");
1086 let hash_same = blake3::hash(b"chunk_same");
1087
1088 let source = MerkleTree {
1089 chunk_size: 65536,
1090 file_size: 65536 * 3,
1091 root: blake3::hash(b"source_root"),
1092 leaves: vec![
1093 ChunkHash { offset: 0, length: 65536, hash: hash_a, entropy: 0.0, #[cfg(feature = "similarity")] simhash: 0, #[cfg(feature = "similarity")] tlsh_digest: None },
1094 ChunkHash { offset: 65536, length: 65536, hash: hash_same, entropy: 0.0, #[cfg(feature = "similarity")] simhash: 0, #[cfg(feature = "similarity")] tlsh_digest: None },
1095 ChunkHash { offset: 131072, length: 65536, hash: hash_same, entropy: 0.0, #[cfg(feature = "similarity")] simhash: 0, #[cfg(feature = "similarity")] tlsh_digest: None },
1096 ],
1097 };
1098 let target_identical = MerkleTree {
1099 chunk_size: 65536,
1100 file_size: 65536 * 3,
1101 root: blake3::hash(b"target_root"),
1102 leaves: vec![
1103 ChunkHash { offset: 0, length: 65536, hash: hash_a, entropy: 0.0, #[cfg(feature = "similarity")] simhash: 0, #[cfg(feature = "similarity")] tlsh_digest: None },
1104 ChunkHash { offset: 65536, length: 65536, hash: hash_same, entropy: 0.0, #[cfg(feature = "similarity")] simhash: 0, #[cfg(feature = "similarity")] tlsh_digest: None },
1105 ChunkHash { offset: 131072, length: 65536, hash: hash_same, entropy: 0.0, #[cfg(feature = "similarity")] simhash: 0, #[cfg(feature = "similarity")] tlsh_digest: None },
1106 ],
1107 };
1108 assert!(MerkleTree::diff(&source, &target_identical).is_empty());
1109
1110 let target_modified = MerkleTree {
1111 chunk_size: 65536,
1112 file_size: 65536 * 3,
1113 root: blake3::hash(b"modified_root"),
1114 leaves: vec![
1115 ChunkHash { offset: 0, length: 65536, hash: hash_b, entropy: 0.0, #[cfg(feature = "similarity")] simhash: 0, #[cfg(feature = "similarity")] tlsh_digest: None },
1116 ChunkHash { offset: 65536, length: 65536, hash: hash_same, entropy: 0.0, #[cfg(feature = "similarity")] simhash: 0, #[cfg(feature = "similarity")] tlsh_digest: None },
1117 ChunkHash { offset: 131072, length: 65536, hash: hash_same, entropy: 0.0, #[cfg(feature = "similarity")] simhash: 0, #[cfg(feature = "similarity")] tlsh_digest: None },
1118 ],
1119 };
1120 let diff = MerkleTree::diff(&source, &target_modified);
1121 assert_eq!(diff.len(), 1);
1122 assert_eq!(diff[0].offset, 0);
1123 assert_eq!(diff[0].length, 65536);
1124 }
1125
1126 #[test]
1127 fn test_merkle_signature_fits_in_xattr() {
1128 let small_sig = MerkleSignature {
1129 root: [0u8; 32],
1130 chunk_size: 65536,
1131 file_size: 65536,
1132 leaf_hashes: vec![[0u8; 32]; 1],
1133 chunk_entropies: None,
1134 chunk_simhashes: None,
1135 };
1136 assert!(small_sig.fits_in_xattr());
1137 assert_eq!(small_sig.serialized_size(), 88);
1139
1140 let large_sig = MerkleSignature {
1142 root: [0u8; 32],
1143 chunk_size: 65536,
1144 file_size: 65536 * 2000,
1145 leaf_hashes: vec![[0u8; 32]; 2048],
1146 chunk_entropies: None,
1147 chunk_simhashes: None,
1148 };
1149 assert!(!large_sig.fits_in_xattr());
1151 }
1152
1153 #[test]
1154 fn test_hash_file_full_consistency() {
1155 set_hashing_enabled(true);
1156 let dir = TempDir::new().unwrap();
1157 let path = dir.path().join("full_hash.bin");
1158 let data = b"deterministic content for hashing test";
1159 std::fs::write(&path, data).unwrap();
1160
1161 let hash1 = hash_file_full(&path).unwrap().unwrap();
1162 let hash2 = hash_file_full(&path).unwrap().unwrap();
1163 assert_eq!(hash1, hash2);
1164
1165 let expected = blake3::hash(data);
1167 assert_eq!(hash1, expected);
1168 }
1169
1170 #[test]
1171 fn test_merkle_from_signature_rejects_invalid() {
1172 let bad_sig = MerkleSignature {
1173 root: [0u8; 32],
1174 chunk_size: 0, file_size: 1024,
1176 leaf_hashes: vec![[0u8; 32]; 1],
1177 chunk_entropies: None,
1178 chunk_simhashes: None,
1179 };
1180 assert!(MerkleTree::from_signature(&bad_sig).is_none());
1181 }
1182
1183 #[test]
1184 fn test_entropy_zeros() {
1185 assert_eq!(shannon_entropy(&[0u8; 1000]), 0.0);
1186 assert_eq!(shannon_entropy(&[]), 0.0);
1187 }
1188
1189 #[test]
1190 fn test_entropy_random() {
1191 let mut buf = vec![0u8; 10000];
1193 let mut state: u64 = 0xDEAD_BEEF_CAFE_BABE;
1194 for b in buf.iter_mut() {
1195 state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
1196 *b = (state >> 33) as u8;
1197 }
1198 let e = shannon_entropy(&buf);
1199 assert!(
1200 e > 7.9,
1201 "pseudo-random entropy should be > 7.9, got {e}"
1202 );
1203 }
1204
1205 #[test]
1206 fn test_entropy_text() {
1207 let phrase = b"The quick brown fox jumps over the lazy dog ";
1208 let mut text = Vec::with_capacity(1000);
1209 while text.len() < 1000 {
1210 let remaining = 1000 - text.len();
1211 text.extend_from_slice(&phrase[..remaining.min(phrase.len())]);
1212 }
1213 let e = shannon_entropy(&text);
1214 assert!(
1215 (4.0..=5.5).contains(&e),
1216 "English text entropy should be 4.0-5.5, got {e}"
1217 );
1218 }
1219
1220 #[test]
1221 fn test_entropy_two_symbols() {
1222 let mut buf = vec![0u8; 1000];
1223 for (i, b) in buf.iter_mut().enumerate() {
1224 *b = (i % 2) as u8;
1225 }
1226 let e = shannon_entropy(&buf);
1227 assert!(
1228 (0.9..=1.1).contains(&e),
1229 "two-symbol entropy should be ~1.0, got {e}"
1230 );
1231 }
1232
1233 #[test]
1234 fn test_sparse_merkle_short_read() {
1235 let dir = TempDir::new().unwrap();
1244 let path = dir.path().join("short_read.bin");
1245
1246 let data = vec![0xABu8; 100];
1248 std::fs::write(&path, &data).unwrap();
1249
1250 let file = File::open(&path).unwrap();
1251 let fake_file_size = MIN_CHUNK_SIZE as u64; let chunk_size = MIN_CHUNK_SIZE as u64;
1253
1254 let data_extents = vec![(0u64, fake_file_size)];
1256
1257 let tree = MerkleTree::from_file_sparse(
1260 &file, fake_file_size, chunk_size, &data_extents, ComputeMode::Blake3Only,
1261 ).unwrap();
1262
1263 assert_eq!(tree.leaves.len(), 1, "should have exactly 1 chunk");
1264
1265 assert_eq!(
1268 tree.leaves[0].length, 100,
1269 "ChunkHash.length should be actual bytes read (100), not requested ({})",
1270 MIN_CHUNK_SIZE
1271 );
1272
1273 let mut expected_buf = vec![0u8; 100];
1276 expected_buf[..100].copy_from_slice(&data);
1277 let expected_hash = blake3::hash(&expected_buf);
1278 assert_eq!(tree.leaves[0].hash, expected_hash, "hash should cover actual read bytes only");
1279 }
1280
1281 #[test]
1282 fn test_dir_hash_accumulator() {
1283 let mut acc = DirHashAccumulator::new();
1284 acc.add("photos", "a.jpg", 1000, "abc123");
1285 acc.add("photos", "b.jpg", 2000, "def456");
1286 acc.add("docs", "readme.txt", 500, "xyz789");
1287
1288 let photos_hash = acc.finalize("photos").unwrap();
1289 let docs_hash = acc.finalize("docs").unwrap();
1290
1291 assert_ne!(photos_hash, docs_hash);
1292
1293 let mut acc2 = DirHashAccumulator::new();
1295 acc2.add("photos", "b.jpg", 2000, "def456");
1296 acc2.add("photos", "a.jpg", 1000, "abc123");
1297 let photos_hash2 = acc2.finalize("photos").unwrap();
1298 assert_eq!(photos_hash, photos_hash2, "Must be sort-order independent");
1299
1300 let mut acc3 = DirHashAccumulator::new();
1302 acc3.add("photos", "a.jpg", 1000, "changed");
1303 acc3.add("photos", "b.jpg", 2000, "def456");
1304 let photos_hash3 = acc3.finalize("photos").unwrap();
1305 assert_ne!(photos_hash, photos_hash3);
1306
1307 let mut children = vec![
1309 ("a.jpg".to_string(), 1000u64, "abc123".to_string()),
1310 ("b.jpg".to_string(), 2000u64, "def456".to_string()),
1311 ];
1312 let direct = compute_dir_hash_from_s3_children(&mut children);
1313 assert_eq!(direct, photos_hash);
1314 }
1315
1316 #[test]
1317 fn test_from_signature_detects_corrupted_root() {
1318 let chunk_a = blake3::hash(b"chunk_alpha");
1320 let chunk_b = blake3::hash(b"chunk_beta");
1321 let leaves = vec![
1322 ChunkHash { offset: 0, length: 65536, hash: chunk_a, entropy: 0.0, #[cfg(feature = "similarity")] simhash: 0, #[cfg(feature = "similarity")] tlsh_digest: None },
1323 ChunkHash { offset: 65536, length: 65536, hash: chunk_b, entropy: 0.0, #[cfg(feature = "similarity")] simhash: 0, #[cfg(feature = "similarity")] tlsh_digest: None },
1324 ];
1325 let root = MerkleTree::compute_root(&leaves);
1326 let tree = MerkleTree {
1327 chunk_size: 65536,
1328 file_size: 65536 * 2,
1329 root,
1330 leaves,
1331 };
1332
1333 let mut sig = tree.to_signature();
1335 sig.root[0] ^= 0xFF; assert!(
1339 MerkleTree::from_signature(&sig).is_none(),
1340 "from_signature must return None when root hash does not match recomputed root"
1341 );
1342 }
1343
1344 #[test]
1345 fn test_from_signature_valid_roundtrip() {
1346 let dir = TempDir::new().unwrap();
1347 let path = dir.path().join("roundtrip.bin");
1348 let data = vec![0xCDu8; 200 * 1024];
1349 std::fs::write(&path, &data).unwrap();
1350
1351 let chunk_size = MIN_CHUNK_SIZE as u64;
1352 let tree = MerkleTree::from_file(&path, chunk_size, ComputeMode::Blake3Only).unwrap();
1353
1354 let sig = tree.to_signature();
1355 let reconstructed = MerkleTree::from_signature(&sig);
1356 assert!(reconstructed.is_some(), "valid signature must round-trip");
1357
1358 let reconstructed = reconstructed.unwrap();
1359 assert_eq!(reconstructed.root, tree.root, "root must match");
1360 assert_eq!(reconstructed.leaves.len(), tree.leaves.len(), "leaf count must match");
1361 }
1362
1363 #[test]
1364 fn test_dir_hash_empty_is_deterministic() {
1365 let dir1 = TempDir::new().unwrap();
1366 let hash1 = compute_dir_hash_from_path(dir1.path());
1367 assert!(hash1.is_some(), "empty dir must return Some(hash), not None");
1368 let hash2 = compute_dir_hash_from_path(dir1.path());
1369 assert_eq!(hash1, hash2, "empty dir hash must be deterministic");
1370 }
1371
1372 #[test]
1373 fn test_dir_hash_empty_vs_populated_differ() {
1374 let empty_dir = TempDir::new().unwrap();
1375 let populated_dir = TempDir::new().unwrap();
1376 std::fs::write(populated_dir.path().join("file.txt"), b"content").unwrap();
1377
1378 let empty_hash = compute_dir_hash_from_path(empty_dir.path());
1379 let populated_hash = compute_dir_hash_from_path(populated_dir.path());
1380
1381 assert!(empty_hash.is_some(), "empty dir must return Some");
1382 assert!(populated_hash.is_some(), "populated dir must return Some");
1383 assert_ne!(empty_hash, populated_hash, "empty and populated dir hashes must differ");
1384 }
1385
1386 #[test]
1387 fn test_dir_hash_skips_bad_entries() {
1388 let dir = TempDir::new().unwrap();
1389 std::fs::write(dir.path().join("a.txt"), b"hello").unwrap();
1390 std::fs::write(dir.path().join("b.txt"), b"world").unwrap();
1391 let hash = compute_dir_hash_from_path(dir.path());
1392 assert!(hash.is_some(), "dir hash must succeed on readable directory");
1393
1394 let bad = std::path::PathBuf::from("/tmp/foxing_nonexistent_dir_test_12345");
1396 let hash_bad = compute_dir_hash_from_path(&bad);
1397 assert!(hash_bad.is_none(), "nonexistent dir must return None");
1398 }
1399
1400 #[test]
1401 fn test_merkle_signature_backward_compat_deserialize() {
1402 let old_json = r#"{
1403 "root": [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
1404 "chunk_size": 65536,
1405 "file_size": 131072,
1406 "leaf_hashes": [
1407 [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32],
1408 [32,31,30,29,28,27,26,25,24,23,22,21,20,19,18,17,16,15,14,13,12,11,10,9,8,7,6,5,4,3,2,1]
1409 ]
1410 }"#;
1411 let sig: MerkleSignature = serde_json::from_str(old_json)
1412 .expect("old MerkleSignature without optional vectors must deserialize");
1413 assert_eq!(sig.leaf_hashes.len(), 2);
1414 assert!(sig.chunk_entropies.is_none(), "missing field must default to None");
1415 assert!(sig.chunk_simhashes.is_none(), "missing field must default to None");
1416
1417 let reserialized = serde_json::to_string(&sig).unwrap();
1418 let sig2: MerkleSignature = serde_json::from_str(&reserialized).unwrap();
1419 assert_eq!(sig2.leaf_hashes, sig.leaf_hashes);
1420 }
1421
1422 #[test]
1423 fn test_merkle_signature_fat_xattr_size() {
1424 let leaf_count = crate::constants::MAX_LEAVES_PER_XATTR as usize;
1425 let sig = MerkleSignature {
1426 root: [0u8; 32],
1427 chunk_size: 65536,
1428 file_size: leaf_count as u64 * 65536,
1429 leaf_hashes: vec![[0u8; 32]; leaf_count],
1430 chunk_entropies: Some(vec![0.0f32; leaf_count]),
1431 chunk_simhashes: Some(vec![0u64; leaf_count]),
1432 };
1433 assert!(
1434 sig.serialized_size() <= crate::constants::MERKLE_XATTR_MAX_BYTES,
1435 "fat signature with {} leaves ({} bytes) must fit in 64KB xattr",
1436 leaf_count, sig.serialized_size()
1437 );
1438
1439 let one_more = MerkleSignature {
1440 root: [0u8; 32],
1441 chunk_size: 65536,
1442 file_size: (leaf_count + 1) as u64 * 65536,
1443 leaf_hashes: vec![[0u8; 32]; leaf_count + 1],
1444 chunk_entropies: Some(vec![0.0f32; leaf_count + 1]),
1445 chunk_simhashes: Some(vec![0u64; leaf_count + 1]),
1446 };
1447 assert!(
1448 one_more.serialized_size() > crate::constants::MERKLE_XATTR_MAX_BYTES,
1449 "fat signature with {} leaves must exceed 64KB xattr",
1450 leaf_count + 1
1451 );
1452 }
1453
1454 #[test]
1455 fn test_entropy_from_file_zeros() {
1456 let dir = TempDir::new().unwrap();
1457 let path = dir.path().join("zeros.bin");
1458 let data = vec![0u8; 1024 * 1024]; std::fs::write(&path, &data).unwrap();
1460
1461 let tree = MerkleTree::from_file(&path, MIN_CHUNK_SIZE as u64, ComputeMode::WithEntropy).unwrap();
1462 assert!(!tree.leaves.is_empty());
1463 for (i, chunk) in tree.leaves.iter().enumerate() {
1464 assert_eq!(
1465 chunk.entropy, 0.0,
1466 "chunk {i} of all-zeros file should have entropy 0.0, got {}",
1467 chunk.entropy
1468 );
1469 }
1470 }
1471
1472 #[test]
1473 fn test_entropy_from_file_random() {
1474 let dir = TempDir::new().unwrap();
1475 let path = dir.path().join("random.bin");
1476 let mut buf = vec![0u8; 1024 * 1024]; let mut state: u64 = 0xDEAD_BEEF_CAFE_BABE;
1479 for b in buf.iter_mut() {
1480 state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
1481 *b = (state >> 33) as u8;
1482 }
1483 std::fs::write(&path, &buf).unwrap();
1484
1485 let tree = MerkleTree::from_file(&path, MIN_CHUNK_SIZE as u64, ComputeMode::WithEntropy).unwrap();
1486 assert!(!tree.leaves.is_empty());
1487 for (i, chunk) in tree.leaves.iter().enumerate() {
1488 assert!(
1489 chunk.entropy > 7.9,
1490 "chunk {i} of random file should have entropy > 7.9, got {}",
1491 chunk.entropy
1492 );
1493 }
1494 }
1495
1496 #[test]
1497 fn test_entropy_from_file_blake3only() {
1498 let dir = TempDir::new().unwrap();
1499 let path = dir.path().join("blake3only.bin");
1500 let mut buf = vec![0u8; 1024 * 1024];
1502 let mut state: u64 = 0x1234_5678_9ABC_DEF0;
1503 for b in buf.iter_mut() {
1504 state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
1505 *b = (state >> 33) as u8;
1506 }
1507 std::fs::write(&path, &buf).unwrap();
1508
1509 let tree = MerkleTree::from_file(&path, MIN_CHUNK_SIZE as u64, ComputeMode::Blake3Only).unwrap();
1510 assert!(!tree.leaves.is_empty());
1511 for (i, chunk) in tree.leaves.iter().enumerate() {
1512 assert_eq!(
1513 chunk.entropy, 0.0,
1514 "chunk {i} in Blake3Only mode must have entropy 0.0, got {}",
1515 chunk.entropy
1516 );
1517 }
1518 }
1519
1520 #[test]
1521 fn test_entropy_from_file_text() {
1522 let dir = TempDir::new().unwrap();
1523 let path = dir.path().join("text.bin");
1524 let phrase = b"The quick brown fox jumps over the lazy dog. ";
1525 let mut text = Vec::with_capacity(MIN_CHUNK_SIZE + 100);
1526 while text.len() < MIN_CHUNK_SIZE + 100 {
1527 let remaining = MIN_CHUNK_SIZE + 100 - text.len();
1528 text.extend_from_slice(&phrase[..remaining.min(phrase.len())]);
1529 }
1530 std::fs::write(&path, &text).unwrap();
1531
1532 let tree = MerkleTree::from_file(&path, MIN_CHUNK_SIZE as u64, ComputeMode::WithEntropy).unwrap();
1533 assert!(!tree.leaves.is_empty());
1534 let e = tree.leaves[0].entropy;
1536 assert!(
1537 (4.0..=5.5).contains(&e),
1538 "English text chunk entropy should be 4.0-5.5, got {e}"
1539 );
1540 }
1541
1542 #[test]
1543 #[ignore] fn test_entropy_benchmark_overhead() {
1545 let dir = TempDir::new().unwrap();
1546 let path = dir.path().join("bench.bin");
1547 let mut buf = vec![0u8; 100 * 1024 * 1024];
1549 let mut state: u64 = 0xCAFE_BABE_DEAD_BEEF;
1550 for b in buf.iter_mut() {
1551 state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
1552 *b = (state >> 33) as u8;
1553 }
1554 std::fs::write(&path, &buf).unwrap();
1555
1556 let chunk_size = MIN_CHUNK_SIZE as u64;
1557 let runs = 10;
1558
1559 let _ = MerkleTree::from_file(&path, chunk_size, ComputeMode::Blake3Only).unwrap();
1561 let _ = MerkleTree::from_file(&path, chunk_size, ComputeMode::WithEntropy).unwrap();
1562
1563 let start_blake3 = std::time::Instant::now();
1564 for _ in 0..runs {
1565 let _ = MerkleTree::from_file(&path, chunk_size, ComputeMode::Blake3Only).unwrap();
1566 }
1567 let elapsed_blake3 = start_blake3.elapsed();
1568
1569 let start_entropy = std::time::Instant::now();
1570 for _ in 0..runs {
1571 let _ = MerkleTree::from_file(&path, chunk_size, ComputeMode::WithEntropy).unwrap();
1572 }
1573 let elapsed_entropy = start_entropy.elapsed();
1574
1575 let ratio = elapsed_entropy.as_secs_f64() / elapsed_blake3.as_secs_f64();
1576 eprintln!(
1577 "Blake3Only: {:.1}ms, WithEntropy: {:.1}ms, ratio: {:.3}x",
1578 elapsed_blake3.as_secs_f64() * 1000.0 / runs as f64,
1579 elapsed_entropy.as_secs_f64() * 1000.0 / runs as f64,
1580 ratio
1581 );
1582 assert!(
1583 ratio < 1.20,
1584 "WithEntropy overhead must be < 20% vs Blake3Only, got {:.1}%",
1585 (ratio - 1.0) * 100.0
1586 );
1587 }
1588
1589 #[cfg(feature = "similarity")]
1590 #[test]
1591 fn test_simhash_identical_files() {
1592 let dir = tempfile::tempdir().unwrap();
1593 let mut data = vec![0u8; 200_000];
1595 let mut state: u64 = 0xDEAD_BEEF_CAFE_1234;
1596 for b in data.iter_mut() {
1597 state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
1598 *b = (state >> 33) as u8;
1599 }
1600
1601 let path_a = dir.path().join("a.bin");
1602 let path_b = dir.path().join("b.bin");
1603 std::fs::write(&path_a, &data).unwrap();
1604 std::fs::write(&path_b, &data).unwrap();
1605
1606 let tree_a = MerkleTree::from_file(&path_a, MIN_CHUNK_SIZE as u64, ComputeMode::WithSimilarity).unwrap();
1607 let tree_b = MerkleTree::from_file(&path_b, MIN_CHUNK_SIZE as u64, ComputeMode::WithSimilarity).unwrap();
1608
1609 assert!(tree_a.leaves.iter().any(|c| c.simhash != 0), "at least one chunk should have non-zero simhash");
1610 assert_eq!(tree_a.file_simhash(), tree_b.file_simhash(), "identical files must have identical simhash");
1611 }
1612
1613 #[cfg(feature = "similarity")]
1614 #[test]
1615 fn test_simhash_near_identical() {
1616 let dir = tempfile::tempdir().unwrap();
1617 let mut data = vec![0u8; 200_000];
1618 let mut state: u64 = 0xDEAD_BEEF_CAFE_1234;
1619 for b in data.iter_mut() {
1620 state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
1621 *b = (state >> 33) as u8;
1622 }
1623
1624 let path_a = dir.path().join("a.bin");
1625 std::fs::write(&path_a, &data).unwrap();
1626
1627 data[1000] ^= 0xFF;
1628 let path_b = dir.path().join("b.bin");
1629 std::fs::write(&path_b, &data).unwrap();
1630
1631 let tree_a = MerkleTree::from_file(&path_a, MIN_CHUNK_SIZE as u64, ComputeMode::WithSimilarity).unwrap();
1632 let tree_b = MerkleTree::from_file(&path_b, MIN_CHUNK_SIZE as u64, ComputeMode::WithSimilarity).unwrap();
1633
1634 let dist = crate::similarity::hamming_distance(tree_a.file_simhash(), tree_b.file_simhash());
1635 assert!(
1636 dist < 10,
1637 "1-byte difference should yield hamming_distance < 10, got {}",
1638 dist
1639 );
1640 }
1641
1642 #[cfg(feature = "similarity")]
1643 #[test]
1644 fn test_simhash_blake3only_zero() {
1645 let dir = tempfile::tempdir().unwrap();
1646 let data = b"Some test data for Blake3Only mode checking".repeat(3000);
1647 let path = dir.path().join("test.bin");
1648 std::fs::write(&path, &data).unwrap();
1649
1650 let tree = MerkleTree::from_file(&path, MIN_CHUNK_SIZE as u64, ComputeMode::Blake3Only).unwrap();
1651
1652 for chunk in &tree.leaves {
1653 assert_eq!(chunk.simhash, 0, "Blake3Only mode must leave simhash as 0");
1654 }
1655 assert_eq!(tree.file_simhash(), 0, "file_simhash must be 0 in Blake3Only mode");
1656 }
1657}