Skip to main content

fxcp_core/
hashing.rs

1// SPDX-License-Identifier: GPL-2.0-or-later
2// Copyright (C) 2025 Joel Wirāmu Pauling <aenertia@aenertia.net>
3//
4//! BLAKE3 hashing, Merkle tree, delta detection, directory hashing.
5
6#![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);
18/// Minimum chunk size for Merkle tree  --  defined in constants.rs for central tuning.
19pub const MIN_CHUNK_SIZE: usize = crate::constants::MIN_CHUNK_SIZE;
20/// Keep backward compat for callers using CHUNK_SIZE directly
21pub const CHUNK_SIZE: usize = MIN_CHUNK_SIZE;
22/// Max Merkle leaves per xattr  --  defined in constants.rs for central tuning.
23pub 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
29/// Enable or disable BLAKE3 hashing globally
30pub fn set_hashing_enabled(enabled: bool) {
31    HASHING_ENABLED.store(enabled, Ordering::Relaxed);
32}
33
34/// Check whether BLAKE3 hashing is currently enabled
35pub fn is_hashing_enabled() -> bool {
36    HASHING_ENABLED.load(Ordering::Relaxed)
37}
38
39/// Set the minimum file size (in KB) for lite hash computation
40pub fn set_lite_threshold_kb(kb: u64) {
41    LITE_HASH_THRESHOLD_BYTES.store(kb * 1024, Ordering::Relaxed);
42}
43
44/// Get the current lite hash threshold in bytes
45pub fn get_lite_threshold_bytes() -> u64 {
46    LITE_HASH_THRESHOLD_BYTES.load(Ordering::Relaxed)
47}
48
49/// Calculate chunk size for Merkle tree construction.
50///
51/// For files that fit in xattr (<=~115MB): uses adaptive sizing to keep <=1800 leaves.
52/// For large files (>256MB): uses 512KB chunks stored in per-target Merkle index,
53/// giving 512KB resync granularity instead of the 55MB+ that xattr limits force.
54pub fn calculate_adaptive_chunk_size(file_size: u64) -> u64 {
55    if file_size == 0 {
56        return MIN_CHUNK_SIZE as u64;
57    }
58    // Large files: use fixed 512KB chunks (stored in merkle_index, not xattr)
59    if file_size > crate::merkle_index::INDEX_THRESHOLD {
60        return crate::merkle_index::LARGE_FILE_CHUNK_SIZE;
61    }
62    // Small/medium files: adaptive sizing to fit in xattr
63    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
67/// Compute a fast BLAKE3 hash from the first and last 64KB of a file (head+tail sampling)
68pub 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    // Use read_at (pread) to avoid stateful seeks and zero-init overhead
78    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
92/// Compute a full BLAKE3 hash of an entire file.
93///
94/// Files larger than [`RAYON_HASH_THRESHOLD`] (1 MiB) use `update_rayon()`
95/// for multi-threaded hashing. Output is identical to sequential hashing.
96pub 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
120/// Lite hash from an in-memory buffer (head+tail sampling, same algorithm as `hash_file_lite`).
121pub 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
139/// Full BLAKE3 hash from an in-memory buffer (same algorithm as `hash_file_full`).
140///
141/// Buffers larger than [`RAYON_HASH_THRESHOLD`] use `update_rayon()` for
142/// multi-threaded hashing.
143pub 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
154/// Verify that src and dst have matching lite hashes (returns true if identical or below threshold)
155pub 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            // Source vanished  --  file lifecycle ended, skip verification
163            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            // Target vanished  --  needs re-copy, not a verification pass
171            return Ok(false);
172        },
173        Err(e) => return Err(e),
174    };
175
176    Ok(src_hash == dst_hash)
177}
178
179/// Compute a directory hash by reading the directory at `path`.
180/// Each child's metadata (size, mtime, type) is hashed, then all children
181/// are sorted by name and combined into a single BLAKE3 directory fingerprint.
182/// Skips foxing internal files (.foxing*, .foxing_meta sidecars).
183pub 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
204/// Compute a directory hash from sorted child (name, hash) pairs.
205/// Uses BLAKE3 over concatenation of sorted `name:hash` entries.
206/// Provides a stable, order-independent directory fingerprint.
207pub 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
218/// Compute a directory hash from S3 child metadata.
219///
220/// Each child is represented as `(name, size_bytes, blake3_hex_or_etag)`.
221/// For CAS restores: blake3_hex_or_etag = the per-file BLAKE3 from the manifest.
222/// For plain S3: blake3_hex_or_etag = the S3 ETag (MD5 or multipart hash).
223///
224/// Child hash = BLAKE3(size_le8 ++ hash_str_bytes).
225/// Dir hash = compute_dir_hash(sorted children).
226/// Sort-order independent: children are sorted by name inside compute_dir_hash.
227pub 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
240/// Accumulates S3 restore child entries per directory prefix, producing a
241/// dir hash when `finalize()` is called for a given prefix.
242///
243/// Usage:
244/// ```ignore
245/// let mut acc = DirHashAccumulator::new();
246/// acc.add("photos", "img001.jpg", 2_048_000, "abc123def456...");
247/// acc.add("photos", "img002.jpg", 1_800_000, "789abc012def...");
248/// let hash = acc.finalize("photos"); // Some([u8; 32])
249/// ```
250pub struct DirHashAccumulator {
251    /// prefix -> list of (name, size, hash_str)
252    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    /// Add a child entry to the accumulator for the given prefix (dir path).
263    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    /// Compute and return the dir hash for all children under `prefix`.
277    /// Returns None if no children were added for that prefix.
278    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    /// Drain all prefixes, returning (prefix, dir_hash) pairs.
287    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// ---------------------------------------------------------------------------
306// Merkle Tree Engine  --  BLAKE3 chunk-level delta detection
307// ---------------------------------------------------------------------------
308
309/// Controls which per-chunk computations `MerkleTree::from_file()` performs.
310///
311/// Variants are ordered so that `WithSimilarity > WithEntropy > Blake3Only`,
312/// enabling `compute_mode >= ComputeMode::WithEntropy` guards.
313#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
314pub enum ComputeMode {
315    /// BLAKE3 hash only (current default). Entropy = 0.0, similarity fields zeroed.
316    Blake3Only,
317    /// BLAKE3 + Shannon entropy per chunk.
318    WithEntropy,
319    /// BLAKE3 + Shannon entropy + SimHash/TLSH per chunk (requires `similarity` feature).
320    WithSimilarity,
321}
322
323/// Per-chunk hash with file offset metadata.
324pub struct ChunkHash {
325    /// Byte offset of this chunk within the file
326    pub offset: u64,
327    /// Length of this chunk in bytes
328    pub length: u32,
329    /// BLAKE3 hash of the chunk data
330    pub hash: Hash,
331    /// Shannon entropy (bits/byte, 0.0-8.0). Zero when `ComputeMode::Blake3Only`.
332    pub entropy: f32,
333    /// SimHash locality-sensitive fingerprint. Zero when not computed.
334    #[cfg(feature = "similarity")]
335    pub simhash: u64,
336    /// TLSH fuzzy hash digest. None when not computed.
337    #[cfg(feature = "similarity")]
338    pub tlsh_digest: Option<Vec<u8>>,
339}
340
341/// BLAKE3 Merkle tree over fixed-size file chunks.
342pub struct MerkleTree {
343    /// Fixed chunk size used for this tree
344    pub chunk_size: u64,
345    /// Total file size at tree construction time
346    pub file_size: u64,
347    /// Root hash computed from all leaf hashes
348    pub root: Hash,
349    /// Per-chunk hashes with offset metadata
350    pub leaves: Vec<ChunkHash>,
351}
352
353/// A contiguous byte range that differs between source and target.
354#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
355pub struct DirtyRange {
356    /// Start offset of the dirty region
357    pub offset: u64,
358    /// Length of the dirty region in bytes
359    pub length: u64,
360}
361
362/// Serializable Merkle signature for xattr/sidecar storage.
363#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
364pub struct MerkleSignature {
365    /// BLAKE3 root hash of the Merkle tree
366    pub root: [u8; 32],
367    /// Chunk size used when building the tree
368    pub chunk_size: u64,
369    /// File size at signature creation time
370    pub file_size: u64,
371    /// Ordered leaf hashes (one per chunk)
372    pub leaf_hashes: Vec<[u8; 32]>,
373    /// Per-chunk Shannon entropy (bits/byte, 0.0-8.0). Present when any leaf has non-zero entropy.
374    #[serde(default)]
375    pub chunk_entropies: Option<Vec<f32>>,
376    /// Per-chunk SimHash fingerprints. Present when similarity feature computed the hashes.
377    #[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    /// Build a Merkle tree by hashing a file in fixed-size chunks.
385    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        // Try sparse-aware path: map data extents via SEEK_HOLE/SEEK_DATA
393        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        // Dense file: sequential read (original path)
404        // CRITICAL: map_data_extents() uses lseek(SEEK_DATA/SEEK_HOLE) which moves the
405        // file position to EOF. We must seek back to 0 before reading sequentially.
406        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    /// Build Merkle tree for a sparse file, skipping holes.
454    /// Hole chunks get a constant sentinel hash  --  no I/O needed.
455    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    /// XOR-fold all chunk SimHash fingerprints into a single file-level SimHash.
526    ///
527    /// Returns 0 if no simhashes were computed (e.g. `Blake3Only` or `WithEntropy` mode).
528    #[cfg(feature = "similarity")]
529    pub fn file_simhash(&self) -> u64 {
530        self.leaves.iter().fold(0u64, |acc, chunk| acc ^ chunk.simhash)
531    }
532
533    /// Compute a root hash from leaf hashes by hashing them together.
534    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    /// Compare two Merkle trees and return ranges that differ.
549    /// Adjacent dirty chunks are merged into contiguous ranges.
550    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,  // source has extra chunks (file grew)
563                (None, Some(_)) => false, // target has extra chunks (file shrunk  --  handled by truncate)
564                (None, None) => false,
565            };
566
567            if is_dirty {
568                let s = src_chunk.unwrap();
569                match &mut current_dirty {
570                    Some(range) => {
571                        // Extend existing dirty range
572                        range.length = (s.offset + s.length as u64) - range.offset;
573                    }
574                    None => {
575                        // Start new dirty range
576                        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        // Flush trailing dirty range
588        if let Some(range) = current_dirty {
589            ranges.push(range);
590        }
591
592        ranges
593    }
594
595    /// Convert to a serializable signature.
596    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    /// Reconstruct a MerkleTree from a stored signature.
623    pub fn from_signature(sig: &MerkleSignature) -> Option<Self> {
624        // Bounds check: chunk_size must be positive, leaf count must be reasonable
625        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    /// Estimated serialized size for xattr storage bounds checking.
677    pub fn serialized_size(&self) -> usize {
678        // root(32) + chunk_size(8) + file_size(8) + vec_len(8) + hashes(32 each)
679        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    /// Check if this signature fits within xattr limits.
686    pub fn fits_in_xattr(&self) -> bool {
687        self.serialized_size() <= MAX_MERKLE_XATTR_BYTES
688    }
689}
690
691/// Compare a source file's Merkle root against a stored signature.
692///
693/// Builds the source Merkle tree using the stored signature's chunk size,
694/// then compares roots. Returns `Ok(true)` if the roots match (file unchanged),
695/// `Ok(false)` if they differ (file needs resync).
696pub 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
701// ---------------------------------------------------------------------------
702// Sparse file helpers  --  SEEK_HOLE/SEEK_DATA extent mapping
703// ---------------------------------------------------------------------------
704
705/// Map allocated data extents in a file using SEEK_DATA/SEEK_HOLE.
706/// Returns Vec of (offset, length) pairs for data regions.
707/// Returns Err if the filesystem doesn't support SEEK_HOLE.
708pub 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        // SAFETY: fd is a valid open file descriptor from the caller.
714        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; // No more data regions
719            }
720            return Err(FxcpError::Io(err));
721        }
722        let data_start = data_start as u64;
723        if data_start >= file_size { break; }
724
725        // SAFETY: fd is a valid open file descriptor, data_start is a valid offset.
726        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
738/// Check if a chunk is entirely within a hole (no data extents overlap it).
739fn 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        // Check for any overlap
744        if ext_offset < chunk_end && ext_end > chunk_offset {
745            return false;
746        }
747        // Extents are sorted; if we're past the chunk, stop
748        if ext_offset >= chunk_end { break; }
749    }
750    true
751}
752
753
754
755// ---------------------------------------------------------------------------
756// Shannon entropy  --  bits-per-symbol measurement for data classification
757// ---------------------------------------------------------------------------
758
759/// Compute Shannon entropy of a byte slice, returning bits per symbol (0.0-8.0).
760///
761/// Single-pass O(n) time, O(1) space (256-entry frequency table).
762/// Returns 0.0 for empty input.
763///
764/// Reference ranges:
765/// - **English text:** ~4.0-5.5 bits/byte
766/// - **Compiled binaries / structured data:** ~5.0-7.0 bits/byte
767/// - **Compressed data (zstd, gzip):** ~7.5-7.9 bits/byte
768/// - **Encrypted / random data:** ~7.95-8.0 bits/byte
769///
770/// Used by the copy pipeline to classify chunk content (e.g. skip compression
771/// on already-compressed or encrypted data).
772pub 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
791// ---------------------------------------------------------------------------
792// Crypto acceleration probe  --  log detected hardware paths
793// ---------------------------------------------------------------------------
794
795/// Log detected cryptographic hardware acceleration at startup.
796/// Called once during initialization to inform operators which hash
797/// algorithms benefit from hardware acceleration on this platform.
798pub 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        // SHA-NI is available on Ice Lake+ (2019), Zen 1+ (2017)
812        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// ---------------------------------------------------------------------------
843// Checksum sidecar file support  --  standard verification formats
844// ---------------------------------------------------------------------------
845
846/// Checksum algorithm selection for sidecar file generation.
847/// BLAKE3 is FREE (reuses existing computation). Others require extra I/O.
848#[derive(Debug, Clone, Copy, PartialEq, Eq)]
849pub enum ChecksumType {
850    /// BLAKE3  --  FREE: already computed during copy pipeline
851    Blake3,
852    /// SHA-256  --  AT COST: requires full re-read of file
853    #[cfg(feature = "checksums")]
854    Sha256,
855    /// SHA-1  --  AT COST: requires full re-read (legacy, not recommended)
856    #[cfg(feature = "checksums")]
857    Sha1,
858    /// MD5  --  AT COST: requires full re-read (legacy, not recommended)
859    #[cfg(feature = "checksums")]
860    Md5,
861}
862
863impl ChecksumType {
864    /// File extension for the checksum sidecar file.
865    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    /// Whether this checksum type is free (reuses existing BLAKE3 computation).
878    pub fn is_free(&self) -> bool {
879        matches!(self, Self::Blake3)
880    }
881
882    /// Parse a single checksum type name.
883    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    /// Parse a comma-separated list of checksum types.
897    pub fn parse_list(s: &str) -> Vec<Self> {
898        s.split(',').filter_map(Self::parse).collect()
899    }
900}
901
902/// Compute a hex-encoded checksum of a file using the specified algorithm.
903pub fn hash_file_checksum(path: &Path, checksum_type: ChecksumType) -> Result<String> {
904    match checksum_type {
905        ChecksumType::Blake3 => {
906            // Reuse existing full hash  --  FREE
907            match hash_file_full(path)? {
908                Some(h) => Ok(h.to_hex().to_string()),
909                None => {
910                    // Hashing disabled globally  --  compute anyway for explicit checksum request
911                    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
965/// Write checksum sidecar files next to a destination file.
966/// Format: GNU coreutils-compatible `<hash>  <filename>\n`
967/// When `cid_format` is true and checksum type is BLAKE3, outputs CID base32lower instead of hex.
968pub 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        // 1KB file with threshold at 1024 -> size < threshold returns None
1004        let result = hash_file_lite(&path, 1024).unwrap();
1005        assert!(result.is_none());
1006
1007        // Lower threshold so the file qualifies
1008        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        // Different content -> different hash
1014        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        // Restore default
1020        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        // Same entries in different order -> same hash (sorted internally)
1035        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        // Different entries -> different hash
1043        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        // 56 + 1*32 = 88 bytes
1138        assert_eq!(small_sig.serialized_size(), 88);
1139
1140        // 2000 leaves: 56 + 2000*32 = 64056 bytes -> exceeds 64KB (65536)
1141        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        // 56 + 2048*32 = 65592 > 65536
1150        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        // Matches direct blake3::hash
1166        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, // invalid
1175            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        // Simple LCG pseudo-random generator for deterministic "random" bytes
1192        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        // Regression test: from_file_sparse must retry read_at in a loop
1236        // (like the dense path) and set ChunkHash.length to actual bytes read,
1237        // not the requested chunk_len. Also must propagate I/O errors (no unwrap_or).
1238        //
1239        // Strategy: create a 100-byte file but call from_file_sparse with
1240        // file_size=65536, forcing read_at to return 100 bytes (short read at EOF).
1241        // Bug: length would be 65536 (requested). Fix: length must be 100 (actual).
1242
1243        let dir = TempDir::new().unwrap();
1244        let path = dir.path().join("short_read.bin");
1245
1246        // Write exactly 100 bytes of known data
1247        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; // 65536  --  lie about file size
1252        let chunk_size = MIN_CHUNK_SIZE as u64;
1253
1254        // Claim the entire range is data (not a hole)
1255        let data_extents = vec![(0u64, fake_file_size)];
1256
1257        // from_file_sparse will try to read 65536 bytes from a 100-byte file.
1258        // read_at at offset 0 returns 100 bytes (short read).
1259        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        // KEY ASSERTION: length must reflect actual bytes read (100),
1266        // not the requested chunk_len (65536).
1267        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        // Hash must cover exactly the 100 data bytes (buf pre-filled with zeros,
1274        // then 100 bytes overwritten by read_at)
1275        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        // Same input in reversed order -> same hash (sort-order independent)
1294        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        // Different hash_str -> different dir hash
1301        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        // compute_dir_hash_from_s3_children directly
1308        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        // Build a valid MerkleTree from known leaves
1319        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        // Get a valid signature, then corrupt the root
1334        let mut sig = tree.to_signature();
1335        sig.root[0] ^= 0xFF; // flip one byte
1336
1337        // from_signature must reject the corrupted root
1338        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        // Nonexistent dir returns None (logged, not panicked)
1395        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]; // 1MB of zeros
1459        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        // LCG pseudo-random for deterministic test
1477        let mut buf = vec![0u8; 1024 * 1024]; // 1MB
1478        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        // Mix of data  --  entropy would be non-zero if computed
1501        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        // At least the first full chunk should have English text entropy
1535        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] // Benchmark: run with `cargo test -- --ignored`
1544    fn test_entropy_benchmark_overhead() {
1545        let dir = TempDir::new().unwrap();
1546        let path = dir.path().join("bench.bin");
1547        // 100MB pseudo-random file
1548        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        // Warm up
1560        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        // Use pseudo-random data so each chunk has a unique simhash (avoids XOR cancellation)
1594        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}