Skip to main content

asupersync/net/atp/chunk/
change_detect.rs

1//! B-8.6 — zero-scan change detection: don't re-hash what the OS already tells us
2//! is unchanged.
3//!
4//! The incremental re-sync (B-8, the rsync-killer delta path) must not chunk-hash
5//! an entire tree to discover the handful of changed files — that is O(tree) work
6//! for an O(delta) result. This module is the cheap **pre-pass** that runs before
7//! any content hashing:
8//!
9//! * [`FileSignature`] — the rsync-style quick-check fingerprint `(size, mtime)`
10//!   plus an advisory `ctime`. It is the metadata the OS already tracks, so an
11//!   unchanged file is recognised **without reading or hashing its bytes**.
12//! * [`classify`] — compare a prior signature to the current one: [`ChangeVerdict::Unchanged`]
13//!   (skip chunk-hashing entirely) vs [`ChangeVerdict::SuspectChanged`] (must
14//!   chunk-hash to find the real delta). Faithful to rsync's quick-check: only
15//!   `size` + `mtime` gate the decision; a `ctime`-only change (e.g. `chmod`,
16//!   ownership) must **not** trigger a re-hash, so `ctime` is recorded for audit
17//!   but excluded from the comparison.
18//! * [`simhash64`] / [`hamming_similarity`] / [`best_rename_source`] — a
19//!   locality-sensitive fingerprint over a file's FastCDC chunk-id set so a
20//!   renamed or copied file can be deltaed against its best prior match instead of
21//!   re-sent whole. Identical content (a pure rename) yields an identical simhash
22//!   (similarity `1.0`); a small edit keeps most chunk-ids and stays highly
23//!   similar.
24//!
25//! Persistent dirty-set maintenance via `inotify`/`fanotify`/an FS change-journal
26//! is an optional, platform-specific layer that plugs in on top by feeding paths
27//! into the same [`classify`]; this module is the portable, allocation-light core
28//! plus its tests. Inputs are plain values, so the logic is deterministic and unit
29//! testable without touching the filesystem.
30
31use serde::{Deserialize, Serialize};
32
33/// Cheap per-file fingerprint: the metadata the OS already maintains. Comparing
34/// two signatures recognises an unchanged file without reading its content.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36pub struct FileSignature {
37    /// File length in bytes.
38    pub size: u64,
39    /// Modification time, nanoseconds since the Unix epoch. Part of the quick-check.
40    pub mtime_nanos: u64,
41    /// Inode change time, nanoseconds since the Unix epoch. Advisory only —
42    /// recorded for forensics but deliberately excluded from [`classify`] so a
43    /// metadata-only change (chmod/chown) does not force a content re-hash.
44    pub ctime_nanos: u64,
45}
46
47impl FileSignature {
48    /// Build a signature from raw parts (the unit-testable constructor).
49    #[must_use]
50    pub const fn new(size: u64, mtime_nanos: u64, ctime_nanos: u64) -> Self {
51        Self {
52            size,
53            mtime_nanos,
54            ctime_nanos,
55        }
56    }
57
58    /// Build from `std::fs::Metadata`: `size` from the length, `mtime` from
59    /// `modified()`, and `ctime` from the platform inode change-time where
60    /// available (Unix); elsewhere `ctime` is `0` (advisory only, never gates
61    /// [`classify`], so this is sound on every platform).
62    #[must_use]
63    pub fn from_metadata(meta: &std::fs::Metadata) -> Self {
64        let mtime_nanos = meta
65            .modified()
66            .ok()
67            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
68            .map_or(0, |d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX));
69        let ctime_nanos = platform_ctime_nanos(meta);
70        Self {
71            size: meta.len(),
72            mtime_nanos,
73            ctime_nanos,
74        }
75    }
76
77    /// The rsync quick-check key: two files with the same `(size, mtime)` are
78    /// presumed unchanged. `ctime` is intentionally not part of the key.
79    #[must_use]
80    const fn quick_check_key(&self) -> (u64, u64) {
81        (self.size, self.mtime_nanos)
82    }
83}
84
85#[cfg(unix)]
86fn platform_ctime_nanos(meta: &std::fs::Metadata) -> u64 {
87    use std::os::unix::fs::MetadataExt;
88    let secs = u64::try_from(meta.ctime()).unwrap_or(0);
89    let nanos = u64::try_from(meta.ctime_nsec()).unwrap_or(0);
90    secs.saturating_mul(1_000_000_000).saturating_add(nanos)
91}
92
93#[cfg(not(unix))]
94fn platform_ctime_nanos(_meta: &std::fs::Metadata) -> u64 {
95    0
96}
97
98/// Outcome of the pre-pass for one file.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum ChangeVerdict {
101    /// `(size, mtime)` match the prior signature → skip chunk-hashing entirely.
102    Unchanged,
103    /// New file, or `size`/`mtime` differs → must chunk-hash to find the delta.
104    SuspectChanged,
105}
106
107impl ChangeVerdict {
108    /// Whether the file may be skipped (no content read / no chunk-hashing).
109    #[must_use]
110    pub const fn is_skippable(self) -> bool {
111        matches!(self, Self::Unchanged)
112    }
113}
114
115/// Classify a file by comparing its current signature to the prior-sync baseline.
116///
117/// Returns [`ChangeVerdict::Unchanged`] only when a prior signature exists and its
118/// `(size, mtime)` match — rsync's quick-check. A missing prior, or any `size`/
119/// `mtime` difference, yields [`ChangeVerdict::SuspectChanged`]. A `ctime`-only
120/// difference is treated as unchanged on purpose.
121#[must_use]
122pub fn classify(prior: Option<&FileSignature>, current: &FileSignature) -> ChangeVerdict {
123    match prior {
124        Some(p) if p.quick_check_key() == current.quick_check_key() => ChangeVerdict::Unchanged,
125        _ => ChangeVerdict::SuspectChanged,
126    }
127}
128
129const SIMHASH_BITS: usize = 64;
130
131/// 64-bit SimHash over a multiset of `u64` features.
132///
133/// Each bit position accumulates `+1` when set in a feature and `-1` when clear;
134/// the output bit is `1` iff the accumulator is positive. Similar feature sets
135/// (e.g. two files sharing most FastCDC chunk-ids) collapse to similar hashes, so
136/// near-duplicates are found by Hamming distance rather than exact match.
137#[must_use]
138pub fn simhash64(features: impl IntoIterator<Item = u64>) -> u64 {
139    let mut acc = [0i64; SIMHASH_BITS];
140    let mut any = false;
141    for feature in features {
142        any = true;
143        for (bit, slot) in acc.iter_mut().enumerate() {
144            if (feature >> bit) & 1 == 1 {
145                *slot += 1;
146            } else {
147                *slot -= 1;
148            }
149        }
150    }
151    if !any {
152        return 0;
153    }
154    let mut hash = 0u64;
155    for (bit, slot) in acc.iter().enumerate() {
156        if *slot > 0 {
157            hash |= 1u64 << bit;
158        }
159    }
160    hash
161}
162
163/// SimHash over a file's FastCDC chunk-id set.
164///
165/// Each 32-byte chunk content hash is folded to a `u64` feature (its leading 8
166/// bytes), so a file's identity is the distribution of its chunk-ids —
167/// order-independent and robust to local edits.
168#[must_use]
169pub fn simhash_of_chunk_ids<'a>(chunk_ids: impl IntoIterator<Item = &'a [u8; 32]>) -> u64 {
170    simhash64(chunk_ids.into_iter().map(|id| {
171        let mut bytes = [0u8; 8];
172        bytes.copy_from_slice(&id[..8]);
173        u64::from_le_bytes(bytes)
174    }))
175}
176
177/// Similarity in `[0.0, 1.0]` between two SimHashes: `1 - hamming_distance / 64`.
178/// `1.0` is identical; `0.5` is the expected value for unrelated inputs.
179#[must_use]
180pub fn hamming_similarity(a: u64, b: u64) -> f64 {
181    let distance = (a ^ b).count_ones();
182    1.0 - f64::from(distance) / SIMHASH_BITS as f64
183}
184
185/// A prior file proposed as the delta source for a renamed/copied current file.
186#[derive(Debug, Clone, PartialEq)]
187pub struct RenameMatch {
188    /// Transfer-relative path of the prior file to delta against.
189    pub prior_path: String,
190    /// SimHash similarity in `[0.0, 1.0]`.
191    pub similarity: f64,
192}
193
194/// Find the prior file whose SimHash is most similar to `current_simhash`.
195///
196/// The match must clear `min_similarity`. Use it to delta a renamed/copied file
197/// against its nearest prior instead of re-sending it whole. Returns `None` when
198/// nothing is similar enough (the file is genuinely new → send it).
199#[must_use]
200pub fn best_rename_source<'a>(
201    current_simhash: u64,
202    candidates: impl IntoIterator<Item = (&'a str, u64)>,
203    min_similarity: f64,
204) -> Option<RenameMatch> {
205    let mut best: Option<RenameMatch> = None;
206    for (path, simhash) in candidates {
207        let similarity = hamming_similarity(current_simhash, simhash);
208        if similarity < min_similarity {
209            continue;
210        }
211        if best.as_ref().is_none_or(|b| similarity > b.similarity) {
212            best = Some(RenameMatch {
213                prior_path: path.to_string(),
214                similarity,
215            });
216        }
217    }
218    best
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    fn sig(size: u64, mtime: u64, ctime: u64) -> FileSignature {
226        FileSignature::new(size, mtime, ctime)
227    }
228
229    #[test]
230    fn unchanged_when_size_and_mtime_match() {
231        let prior = sig(4096, 1_000, 500);
232        let current = sig(4096, 1_000, 500);
233        assert_eq!(classify(Some(&prior), &current), ChangeVerdict::Unchanged);
234        assert!(classify(Some(&prior), &current).is_skippable());
235    }
236
237    #[test]
238    fn ctime_only_change_is_ignored() {
239        // chmod/chown bump ctime but not content — must NOT force a re-hash.
240        let prior = sig(4096, 1_000, 500);
241        let current = sig(4096, 1_000, 9_999); // ctime differs, size+mtime same
242        assert_eq!(classify(Some(&prior), &current), ChangeVerdict::Unchanged);
243    }
244
245    #[test]
246    fn mtime_change_is_suspect() {
247        let prior = sig(4096, 1_000, 500);
248        let current = sig(4096, 2_000, 500);
249        assert_eq!(
250            classify(Some(&prior), &current),
251            ChangeVerdict::SuspectChanged
252        );
253    }
254
255    #[test]
256    fn size_change_is_suspect() {
257        let prior = sig(4096, 1_000, 500);
258        let current = sig(8192, 1_000, 500);
259        assert_eq!(
260            classify(Some(&prior), &current),
261            ChangeVerdict::SuspectChanged
262        );
263    }
264
265    #[test]
266    fn no_prior_is_suspect() {
267        let current = sig(4096, 1_000, 500);
268        assert_eq!(classify(None, &current), ChangeVerdict::SuspectChanged);
269        assert!(!classify(None, &current).is_skippable());
270    }
271
272    #[test]
273    fn simhash_is_deterministic_and_order_independent() {
274        let a = simhash64([1u64, 2, 3, 4]);
275        let b = simhash64([4u64, 3, 2, 1]);
276        assert_eq!(a, b, "simhash must not depend on feature order");
277        assert_eq!(simhash64(std::iter::empty()), 0);
278    }
279
280    #[test]
281    fn identical_chunk_sets_are_a_perfect_rename_match() {
282        // A pure rename: identical content → identical chunk-ids → similarity 1.0.
283        let ids = [[7u8; 32], [9u8; 32], [11u8; 32]];
284        let prior = simhash_of_chunk_ids(ids.iter());
285        let renamed = simhash_of_chunk_ids(ids.iter());
286        assert_eq!(prior, renamed);
287        assert!((hamming_similarity(prior, renamed) - 1.0).abs() < f64::EPSILON);
288    }
289
290    #[test]
291    fn small_edit_stays_highly_similar() {
292        let base: Vec<[u8; 32]> = (0..32u8).map(|i| [i; 32]).collect();
293        let mut edited = base.clone();
294        edited[0] = [200u8; 32]; // one chunk changed out of 32
295        let h_base = simhash_of_chunk_ids(base.iter());
296        let h_edited = simhash_of_chunk_ids(edited.iter());
297        assert!(
298            hamming_similarity(h_base, h_edited) > 0.7,
299            "a one-chunk edit should remain a strong delta candidate"
300        );
301    }
302
303    #[test]
304    fn best_rename_source_picks_the_nearest_above_threshold() {
305        let current = 0xfeed_face_cafe_beefu64;
306        let near = current ^ 0x0000_0000_0000_0001;
307        let far = current ^ 0x0000_0000_0000_ffff;
308
309        let candidates = [("old/far.bin", far), ("old/near.bin", near)];
310        let m = best_rename_source(current, candidates.iter().map(|(p, h)| (*p, *h)), 0.6)
311            .expect("a near match clears the threshold");
312        assert_eq!(m.prior_path, "old/near.bin");
313    }
314
315    #[test]
316    fn best_rename_source_returns_none_when_nothing_is_similar_enough() {
317        let current = simhash_of_chunk_ids([[1u8; 32], [2u8; 32]].iter());
318        let far = simhash_of_chunk_ids([[200u8; 32], [201u8; 32]].iter());
319        assert!(best_rename_source(current, [("old/x", far)], 0.95).is_none());
320    }
321}