ripsync_core/checksum.rs
1//! Checksums for the delta engine.
2//!
3//! Two layers, exactly as in the rsync algorithm:
4//!
5//! * a **weak rolling checksum** ([`RollingChecksum`]) that is cheap to compute
6//! and `O(1)` to slide one byte along a buffer, used to *cheaply reject*
7//! non-matching windows; and
8//! * a **strong hash** ([`strong_hash`]) — the first 16 bytes of a BLAKE3 digest —
9//! used to *confirm* a candidate match found via the weak checksum.
10//!
11//! # Rolling checksum definition
12//!
13//! With modulus `M = 65536` and a window of length `L`:
14//!
15//! ```text
16//! a = (Σ_k bytes[k]) mod M
17//! b = (Σ_k (L - k) * bytes[k]) mod M // position-weighted, k = 0..L
18//! checksum = a + b * M
19//! ```
20//!
21//! Because `M = 2^16`, reducing modulo `M` is the same as masking the low 16
22//! bits, and ordinary wrapping (`mod 2^32`) arithmetic is congruent modulo `M`.
23//! We therefore accumulate with wrapping `u32` ops and mask only when reading the
24//! value out — this keeps rolling branch-free and exact.
25
26/// Modulus used by the weak checksum (`2^16`).
27const M: u32 = 1 << 16;
28
29/// Length of the strong hash prefix kept per block, in bytes.
30pub const STRONG_LEN: usize = 16;
31
32/// A 16-byte strong hash (BLAKE3 prefix).
33pub type StrongHash = [u8; STRONG_LEN];
34
35/// Strong hash of `data`: the first [`STRONG_LEN`] bytes of its BLAKE3 digest.
36#[must_use]
37pub fn strong_hash(data: &[u8]) -> StrongHash {
38 let digest = blake3::hash(data);
39 let mut out = [0u8; STRONG_LEN];
40 out.copy_from_slice(&digest.as_bytes()[..STRONG_LEN]);
41 out
42}
43
44/// Rolling weak checksum over a fixed-length window.
45///
46/// Construct from a window with [`RollingChecksum::new`], read the current value
47/// with [`RollingChecksum::value`], and slide forward one byte at a time with
48/// [`RollingChecksum::roll`].
49#[derive(Debug, Clone, Copy)]
50pub struct RollingChecksum {
51 a: u32,
52 b: u32,
53 window_len: u32,
54}
55
56impl RollingChecksum {
57 /// Compute the checksum freshly over `window`.
58 ///
59 /// The window length is fixed for the life of this value; [`roll`] keeps it
60 /// constant.
61 ///
62 /// [`roll`]: RollingChecksum::roll
63 #[must_use]
64 pub fn new(window: &[u8]) -> Self {
65 let len = window.len();
66 let window_len = u32::try_from(len).unwrap_or(u32::MAX);
67 let mut a: u32 = 0;
68 let mut b: u32 = 0;
69
70 // Scalar unrolling: process 8 bytes per iteration.
71 // This reduces loop overhead and lets the CPU schedule independent
72 // adds/muls in parallel. For a 128 KiB window this is ~16× fewer
73 // loop iterations than the naive byte-by-byte loop.
74 let chunks = len / 8;
75 for chunk in 0..chunks {
76 let base = chunk * 8;
77 let w = window_len.wrapping_sub(base as u32);
78
79 let b0 = u32::from(window[base]);
80 let b1 = u32::from(window[base + 1]);
81 let b2 = u32::from(window[base + 2]);
82 let b3 = u32::from(window[base + 3]);
83 let b4 = u32::from(window[base + 4]);
84 let b5 = u32::from(window[base + 5]);
85 let b6 = u32::from(window[base + 6]);
86 let b7 = u32::from(window[base + 7]);
87
88 a = a.wrapping_add(
89 b0.wrapping_add(b1)
90 .wrapping_add(b2)
91 .wrapping_add(b3)
92 .wrapping_add(b4)
93 .wrapping_add(b5)
94 .wrapping_add(b6)
95 .wrapping_add(b7),
96 );
97
98 // b += Σ (w - i) * byte[i] for i = 0..7
99 // Rewrite as: b += w * Σ bytes - Σ (i * byte[i])
100 let sum_bytes = b0
101 .wrapping_add(b1)
102 .wrapping_add(b2)
103 .wrapping_add(b3)
104 .wrapping_add(b4)
105 .wrapping_add(b5)
106 .wrapping_add(b6)
107 .wrapping_add(b7);
108
109 let weighted = b1
110 .wrapping_add(b2.wrapping_mul(2))
111 .wrapping_add(b3.wrapping_mul(3))
112 .wrapping_add(b4.wrapping_mul(4))
113 .wrapping_add(b5.wrapping_mul(5))
114 .wrapping_add(b6.wrapping_mul(6))
115 .wrapping_add(b7.wrapping_mul(7));
116
117 b = b.wrapping_add(w.wrapping_mul(sum_bytes).wrapping_sub(weighted));
118 }
119
120 // Handle the remaining 0–7 bytes.
121 let remainder_start = chunks * 8;
122 for i in remainder_start..len {
123 let k = i;
124 let weight = window_len.wrapping_sub(u32::try_from(k).unwrap_or(0));
125 let byte = u32::from(window[i]);
126 a = a.wrapping_add(byte);
127 b = b.wrapping_add(weight.wrapping_mul(byte));
128 }
129
130 Self { a, b, window_len }
131 }
132
133 /// Slide the window one byte to the right.
134 ///
135 /// `out_byte` is the byte leaving the window on the left; `in_byte` is the
136 /// byte entering on the right. The window length is preserved.
137 pub fn roll(&mut self, out_byte: u8, in_byte: u8) {
138 let out = u32::from(out_byte);
139 let in_ = u32::from(in_byte);
140 self.a = self.a.wrapping_sub(out).wrapping_add(in_);
141 self.b = self
142 .b
143 .wrapping_sub(self.window_len.wrapping_mul(out))
144 .wrapping_add(self.a);
145 }
146
147 /// The current checksum value, `a + b * M`, with `a` and `b` reduced mod `M`.
148 #[must_use]
149 pub fn value(&self) -> u32 {
150 (self.a & (M - 1)) | ((self.b & (M - 1)) << 16)
151 }
152
153 /// The fixed window length this checksum tracks.
154 #[must_use]
155 pub fn window_len(&self) -> u32 {
156 self.window_len
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 #[test]
165 fn fresh_matches_manual() {
166 // a = 1+2+3 = 6 ; b = 3*1 + 2*2 + 1*3 = 10 ; checksum = 6 + 10*65536.
167 let rc = RollingChecksum::new(&[1, 2, 3]);
168 assert_eq!(rc.value(), 6 + 10 * M);
169 }
170
171 #[test]
172 fn roll_equals_recompute() {
173 let data = b"the quick brown fox jumps over the lazy dog";
174 let w = 7usize;
175 let mut rc = RollingChecksum::new(&data[..w]);
176 for i in 1..=(data.len() - w) {
177 rc.roll(data[i - 1], data[i + w - 1]);
178 let fresh = RollingChecksum::new(&data[i..i + w]);
179 assert_eq!(rc.value(), fresh.value(), "mismatch at offset {i}");
180 }
181 }
182
183 #[test]
184 fn strong_hash_is_stable_and_16_bytes() {
185 let h1 = strong_hash(b"hello");
186 let h2 = strong_hash(b"hello");
187 let h3 = strong_hash(b"world");
188 assert_eq!(h1, h2);
189 assert_ne!(h1, h3);
190 assert_eq!(h1.len(), 16);
191 }
192}