1use std::io::Read;
11
12pub const DEFAULT_CHUNK_MIN: usize = 2 * 1024; pub const DEFAULT_CHUNK_AVG: usize = 64 * 1024; pub const DEFAULT_CHUNK_MAX: usize = 2 * 1024 * 1024; pub struct Chunk {
21 pub data: Vec<u8>,
22 pub hash: blake3::Hash,
23}
24
25#[derive(Debug)]
27pub struct GearChunker {
28 pub min: usize,
29 pub avg: usize,
30 pub max: usize,
31 pub normalization_level: u8,
41}
42
43impl Default for GearChunker {
44 fn default() -> Self {
45 Self {
46 min: DEFAULT_CHUNK_MIN,
47 avg: DEFAULT_CHUNK_AVG,
48 max: DEFAULT_CHUNK_MAX,
49 normalization_level: 0,
50 }
51 }
52}
53
54#[cfg(feature = "cloud")]
57pub const S3_WAN: GearChunker = GearChunker {
58 min: 5 * 1024 * 1024,
59 avg: 8 * 1024 * 1024,
60 max: 64 * 1024 * 1024,
61 normalization_level: 0,
62};
63
64#[cfg(feature = "cloud")]
67pub const S3_LAN: GearChunker = GearChunker {
68 min: 5 * 1024 * 1024,
69 avg: 16 * 1024 * 1024,
70 max: 128 * 1024 * 1024,
71 normalization_level: 0,
72};
73
74#[cfg(feature = "cloud")]
77pub const S3_ARCHIVE: GearChunker = GearChunker {
78 min: 5 * 1024 * 1024,
79 avg: 32 * 1024 * 1024,
80 max: 256 * 1024 * 1024,
81 normalization_level: 0,
82};
83
84impl GearChunker {
85 pub fn new(min: usize, avg: usize, max: usize) -> crate::Result<Self> {
87 if min == 0 {
88 return Err(crate::FxcpError::Config("GearChunker: min must be > 0".into()));
89 }
90 if avg < min {
91 return Err(crate::FxcpError::Config(
92 format!("GearChunker: avg ({avg}) must be >= min ({min})"),
93 ));
94 }
95 if max < avg {
96 return Err(crate::FxcpError::Config(
97 format!("GearChunker: max ({max}) must be >= avg ({avg})"),
98 ));
99 }
100 if !avg.is_power_of_two() {
101 return Err(crate::FxcpError::Config(format!(
102 "GearChunker: avg ({avg}) must be a power of two (try {})",
103 avg.next_power_of_two()
104 )));
105 }
106 Ok(Self { min, avg, max, normalization_level: 0 })
107 }
108
109 pub fn with_normalization(mut self, level: u8) -> crate::Result<Self> {
115 if level > 2 {
116 return Err(crate::FxcpError::Config(format!(
117 "GearChunker: normalization level {level} is out of range (max 2)"
118 )));
119 }
120 let bits = logarithm2(self.avg);
121 if level > 0 && bits <= level as u32 {
122 return Err(crate::FxcpError::Config(format!(
123 "GearChunker: avg {} (log2={bits}) is too small for normalization level {level}; \
124 need avg >= {} to avoid degenerate zero mask",
125 self.avg,
126 1usize << (level as u32 + 1),
127 )));
128 }
129 self.normalization_level = level;
130 Ok(self)
131 }
132
133 #[deprecated(note = "Use StreamingChunker for files > 16 MB")]
139 pub fn chunk_reader<R: Read>(&self, mut reader: R) -> std::io::Result<Vec<Chunk>> {
140 let mut chunks = Vec::new();
141 let mut buf = Vec::with_capacity(self.max.min(crate::constants::CHUNK_READER_MAX_INPUT));
142 let mut read_buf = [0u8; crate::constants::CHUNK_READER_READ_BUFFER_SIZE];
143
144 loop {
145 let n = reader.read(&mut read_buf)?;
146 if n == 0 { break; }
147 buf.extend_from_slice(&read_buf[..n]);
148 if buf.len() > crate::constants::CHUNK_READER_MAX_INPUT {
149 return Err(std::io::Error::new(
150 std::io::ErrorKind::InvalidInput,
151 "chunk_reader: input exceeds 16 MB limit; use StreamingChunker instead",
152 ));
153 }
154 }
155
156 if buf.is_empty() {
157 return Ok(chunks);
158 }
159
160 let boundaries = self.find_boundaries(&buf);
161 let mut start = 0;
162 for end in boundaries {
163 let data = buf[start..end].to_vec();
164 let hash = blake3::hash(&data);
165 chunks.push(Chunk { data, hash });
166 start = end;
167 }
168
169 Ok(chunks)
170 }
171
172 pub fn find_boundaries(&self, data: &[u8]) -> Vec<usize> {
178 let mut boundaries = Vec::new();
179 let mut start = 0;
180 let mut hash: u64 = 0;
181
182 if self.normalization_level == 0 {
183 let mask = (self.avg - 1) as u64;
184 for i in 0..data.len() {
185 hash = hash.wrapping_shl(1).wrapping_add(GEAR_TABLE[data[i] as usize]);
186 let chunk_len = i - start + 1;
187 if chunk_len >= self.min && (hash & mask == 0 || chunk_len >= self.max) {
188 boundaries.push(i + 1);
189 start = i + 1;
190 hash = 0;
191 }
192 }
193 } else {
194 let bits = logarithm2(self.avg);
195 let level = self.normalization_level as u32;
196 let mask_s = MASKS[(bits + level).min(63) as usize];
197 let mask_l = MASKS[bits.saturating_sub(level) as usize];
198 for i in 0..data.len() {
199 hash = hash.wrapping_shl(1).wrapping_add(GEAR_TABLE[data[i] as usize]);
200 let chunk_len = i - start + 1;
201 let active_mask = if chunk_len < self.avg { mask_s } else { mask_l };
202 if chunk_len >= self.min && (hash & active_mask == 0 || chunk_len >= self.max) {
203 boundaries.push(i + 1);
204 start = i + 1;
205 hash = 0;
206 }
207 }
208 }
209
210 if start < data.len() {
211 boundaries.push(data.len());
212 }
213
214 boundaries
215 }
216
217 pub fn chunk_slice(&self, data: &[u8]) -> Vec<Chunk> {
219 if data.is_empty() {
220 return Vec::new();
221 }
222
223 let boundaries = self.find_boundaries(data);
224 let mut chunks = Vec::with_capacity(boundaries.len());
225 let mut start = 0;
226 for end in boundaries {
227 let slice = &data[start..end];
228 chunks.push(Chunk {
229 data: slice.to_vec(),
230 hash: blake3::hash(slice),
231 });
232 start = end;
233 }
234 chunks
235 }
236}
237
238const MASKS: [u64; 64] = {
242 let mut table = [0u64; 64];
243 table[0] = u64::MAX; let mut i = 1usize;
245 while i < 64 {
246 table[i] = (1u64 << i) - 1;
247 i += 1;
248 }
249 table
250};
251
252#[inline]
254fn logarithm2(value: usize) -> u32 {
255 value.trailing_zeros()
256}
257
258const GEAR_TABLE: [u64; 256] = {
261 [
265 0x6b5f_f821_3c4a_e15d, 0x3e2c_4a59_81fb_d7c3, 0x9d17_e3f0_a264_58b9, 0xc4a8_6b2f_d903_7e41,
266 0x1f93_d5a4_27c8_b60e, 0x72e4_0b98_cf56_1da3, 0xa51c_83d7_640f_9eb2, 0x4de9_7a13_b85c_02f6,
267 0x8376_1ed5_4ca9_f0b8, 0x0ab4_62c1_9f7d_e384, 0xd648_f5a7_30b9_1c2e, 0x5c01_9de3_a872_4fb6,
268 0xe7ba_3f84_d215_c60a, 0x29d5_c416_7eb3_80f9, 0xf103_8a6d_54e9_27cb, 0x68cf_b1a2_e374_950d,
269 0xb42e_d789_16fc_a053, 0x0d71_4e36_c5a8_9bf2, 0x94ad_23f5_781c_d0e6, 0x3f86_cb17_a9e0_524d,
270 0xe210_7d93_f4b6_a8c1, 0x57c9_01a8_2de5_3f74, 0xab34_e6c2_809f_1db7, 0x1ea7_580d_c3b4_96f2,
271 0xc5f3_9a21_67de_0b48, 0x4018_d7b5_e2c9_a36f, 0x86ac_3f70_1d54_e8b2, 0xd961_c4a8_f30b_72e5,
272 0x237e_a5d1_b846_9fc0, 0x7cb2_10e9_6d3f_5a84, 0xf5d4_8c36_a1e7_0b29, 0x41a9_37cb_58f2_d6e0,
273 0xba65_fc12_8d07_a493, 0x0e38_b4d7_c9a1_526f, 0x97c2_608a_3e15_fdb4, 0x5a0d_e1f3_c478_b926,
274 0xc391_7b25_a0ec_4d18, 0x2cd6_a940_f587_13be, 0xf41b_3e82_d9c6_a075, 0x6807_d5c4_1ab9_2ef3,
275 0xad73_4f96_e201_c8b5, 0x15be_8a63_7dc4_f021, 0x89e1_c7d4_026b_3fa8, 0x34a6_12f5_cb89_70de,
276 0xe75c_d438_916a_b2c0, 0x50c3_a917_6ef4_d58b, 0xbc29_5d80_a3e1_47f6, 0x03f7_8e14_d5b2_c96a,
277 0xcb84_a3f6_1027_de59, 0x4610_dbc5_87f9_6a23, 0x91f5_26a8_e4d3_0cb7, 0xdf42_c519_73b8_ea04,
278 0x27be_9170_a4d6_3cf8, 0x7a03_e8d2_1fc5_b946, 0xf5c8_3da6_42b1_970e, 0x384d_76c1_ef02_5ab9,
279 0xc916_ab53_d478_e0f2, 0x14e2_c087_b935_6da4, 0xa8bf_31d4_52c0_7e19, 0x5d04_f928_3ea7_c1b6,
280 0xe261_8db5_c0f3_4a27, 0x2fb0_54c3_79e8_16da, 0x93c7_ae19_0d64_f285, 0x46da_0372_b5c1_8f9e,
281 0xb185_6fad_c832_04e7, 0x0c4e_d2b1_f697_a358, 0x7923_a8e5_4db0_c1f6, 0xd4f6_1cb7_28a3_950e,
282 0x2a81_e594_f3d0_467b, 0x67b3_0fc8_a512_de94, 0xfb48_c261_d7e9_30a5, 0x35d9_7a0e_8cb4_f123,
283 0xce14_b583_61d7_a9f0, 0x40af_e826_9d03_cb57, 0x9d63_24b1_f0c5_7e8a, 0x58c0_91f7_a43e_d2b6,
284 0xa27b_4dc9_1586_e0f3, 0x1fe6_30a4_cb79_8d52, 0x8c59_d7e2_a41b_063f, 0xd302_a5f6_19e8_4cb7,
285 0x27c4_f831_de90_ba65, 0x7a18_6dbc_43f5_0e29, 0xf6b5_c204_87da_31e9, 0x4be1_39d7_5c06_a8f2,
286 0xb09c_7ea3_c1d4_5b28, 0x0d57_a2e0_f4b3_96c1, 0x82e3_cb14_39a0_d7f5, 0xdf26_50a9_b7c4_138e,
287 0x369a_8d47_2ce1_f5b0, 0x7b04_f1d2_85a6_3ec9, 0xf8c1_a593_60d7_2b4e, 0x45b6_2ef0_9dc8_71a3,
288 0xbd73_c418_0a5f_e962, 0x01e8_3fb5_c297_d4a6, 0x9e54_d061_78bc_a3f2, 0xd2a9_1784_cb30_5fe6,
289 0x2c16_e5b3_a049_8df7, 0x73db_a826_5cf1_40b9, 0xfa07_31c4_e8bd_926e, 0x46c2_9f58_13d7_a4b0,
290 0xbe8d_5a01_7c43_e6f9, 0x05f4_c7b3_da21_908e, 0x8b31_0ed6_a5f8_4c27, 0xd7e6_9240_1cb3_f5a8,
291 0x2179_abd5_e804_3c6f, 0x7e04_68b1_c3d9_f527, 0xf2b5_d34a_810e_67c9, 0x4ca8_1f97_56e2_b0d3,
292 0xa163_c4e0_2db8_f975, 0x1dbe_70a3_f945_8c21, 0x894c_d517_a3e0_2fb6, 0xd600_a928_7fb4_c3e1,
293 0x23d7_8e45_b1c6_0af9, 0x78ab_f213_9cd0_6e47, 0xf43e_2db6_c581_a709, 0x4b91_c0d8_37a4_fe62,
294 0xb254_79a1_e06c_1db3, 0x0fc3_e687_54a9_b2d0, 0x9618_ad34_c2f7_605b, 0xdb75_4a02_1fc8_93e6,
295 0x24c1_f59a_8d36_70eb, 0x7986_0bd3_e4a2_cf18, 0xf10a_c845_3b67_d29e, 0x4ed7_31b9_a0fc_5624,
296 0xa342_9c06_d8b1_ef73, 0x1ab5_e0d4_6379_28cf, 0x876c_24f1_bea5_d038, 0xd493_5b17_02ce_a6f9,
297 0x21e8_b7c0_f43d_5a96, 0x7e5f_03a4_c912_d8b3, 0xf2c4_6e31_85a7_0bd9, 0x4db1_9258_c3f6_ae04,
298 0xaa76_cd83_1049_5fb2, 0x1503_81bf_e7d4_a2c6, 0x88d4_ae62_5b07_f139, 0xd629_43b5_90ec_78a1,
299 0x239e_f7c0_4db1_2a68, 0x7c41_5a92_e3b8_0fd4, 0xf0b2_c637_a905_81de, 0x45e7_39d4_1cba_f628,
300 0xb91c_8da0_6743_e5f1, 0x06a3_f278_db14_c905, 0x9250_b4e1_3dc7_0fa6, 0xdf87_6123_a4e9_cb50,
301 0x2a1c_d5f6_80b3_4e97, 0x75e0_a849_c327_1fb6, 0xf39b_0c24_d6e5_7a81, 0x4856_d1b3_2fa0_c7e9,
302 0xbc2d_9fe4_a178_3065, 0x01e4_738b_5dc6_a9f2, 0x8d97_b250_c41e_6fa3, 0xd06a_2e85_f1c3_94b7,
303 0x2cb1_d704_8e69_53fa, 0x7f46_a8c1_32bd_e019, 0xf3d2_140b_c5a6_789e, 0x4e09_6ba7_d8f3_c251,
304 0xab84_c3f0_1d27_95e6, 0x1671_ae52_c098_4bf3, 0x8a2d_f584_67b1_c039, 0xd5c0_3916_a2fd_8e74,
305 0x28a7_e4d3_5b10_cf69, 0x7d53_01be_c8a4_2f96, 0xf1e8_bc47_3d65_a20d, 0x4c34_5f92_e0b7_d1a8,
306 0xb0c9_a216_7d4e_f853, 0x0512_dc83_a9b0_674f, 0x99e6_7014_c253_bda8, 0xd42b_a5f0_8e97_31c6,
307 0x2794_c831_d5be_0af7, 0x7ae1_3f06_82c4_59d3, 0xf6bc_d245_1e73_a0b9, 0x4308_a7b9_c561_dfe2,
308 0xbd51_e460_3a98_7cf1, 0x02c6_89f3_e7a4_b510, 0x8e7d_b428_51c0_3fa9, 0xd1a0_67b5_2cf4_e893,
309 0x2e3f_c1d0_b586_49a7, 0x7b82_4ea3_c0d9_f715, 0xf715_930c_4ea2_d8b3, 0x42c8_b6a1_d350_7fe9,
310 0xaf54_0d38_91e7_c2b6, 0x1429_e8c5_a6f3_7d01, 0x87e3_51b6_dc0a_4f98, 0xd096_2c43_f5b8_a1e7,
311 0x2b41_f790_8ced_3a56, 0x76d8_a025_e1b4_cf93, 0xfaed_3481_5726_b0c9, 0x4f12_c9b7_a3d0_6e85,
312 0xb3a7_5e04_c819_df62, 0x0e6c_d1a2_3fb5_8740, 0x928b_47e1_d0f3_6ca5, 0xde50_bc36_14a9_87f2,
313 0x23c5_f049_6dbe_2a18, 0x7e98_3ad6_c104_5fb1, 0xf201_c78b_9ed3_a465, 0x4f74_152c_a3b8_e9d0,
314 0xa4e9_b863_17c0_5df2, 0x1136_4db7_e29a_08c5, 0x8dc2_a0e4_5b17_f639, 0xd05f_31b2_ce84_a976,
315 0x2d84_e6c7_a320_5fb1, 0x7013_9ba4_dc67_82e5, 0xfca8_d451_03b9_2e7f, 0x4965_0fc8_b7e2_a134,
316 0xb5da_c203_6e18_4fb7, 0x0241_7db6_a5c3_f809, 0x9ebc_a061_c834_d5f2, 0xd3f7_529c_01e6_ba48,
317 0x260c_bf21_94d5_3ea7, 0x7b93_e8a4_c072_1d5f, 0xf7c8_461d_3ba5_90e2, 0x42a1_d370_e50c_bf69,
318 0xbe5c_0f8b_7a43_d296, 0x0327_c4e6_d1b8_a5f0, 0x8f60_b932_54cd_e1a7, 0xdc15_ae03_b897_4c62,
319 0x21a8_73d5_0fc4_e2b9, 0x7cd4_e092_b318_5fa6, 0xf06b_2dc1_84e9_a735, 0x4d92_c1b4_67a3_08fe,
320 0xa247_56e0_1bcd_9f83, 0x1fb0_8d13_c429_e5a7, 0x83c5_f420_e976_1adb, 0xde08_31a7_b2c4_5f96,
321 0x2a9d_e654_0cb1_73f8, 0x7760_b9c3_f1de_a402, 0xfb15_48a6_3dc0_927e, 0x46c2_0f71_8ab5_e3d9,
322 0xba97_d438_c501_6eaf, 0x054e_a1c7_f8d2_3b60, 0x99f3_6c80_2db7_a5e4, 0xd420_b5f1_c368_9a27,
323 0x2dbc_4a06_e5f1_73c9, 0x7845_f3c2_0abd_e691, 0xf4d1_8e37_c962_ab05, 0x4106_c2a4_b5d8_3f7e,
324 0xad7b_3058_e4c9_1fa6, 0x12e4_cd81_7ba0_5643, 0x8e59_a4b2_0fc7_31d8, 0xd380_1fc5_e2b6_a749,
325 0x2a6f_e3c8_1594_bd07, 0x75b4_0a91_c8d6_3fe2, 0xf9c3_5d24_87b0_a16e, 0x4618_a2d7_3bcf_9058,
326 0xb087_c4f1_d259_6ea3, 0x0b5e_91a3_67c4_d8f2, 0x964d_b0e8_c123_7fa5, 0xd1a2_3f75_8ec0_b469,
327 0x2e89_c416_f5d3_a0b7, 0x73f0_5b84_29e6_cd13, 0xfc47_d2a1_b038_5e96, 0x410c_8fb3_e7a5_d248,
328 0xbe93_d714_a268_5fc0, 0x05a8_41c6_f3bd_927e, 0x9c74_e0b2_5d31_a8f6, 0xd1bf_56a0_83c4_7e29,
329 ]
330};
331
332pub struct StreamingChunker {
355 min: usize,
356 avg: usize,
357 max: usize,
358 normalization_level: u8,
359 mask: u64,
360 mask_s: u64,
361 mask_l: u64,
362 buffer: Vec<u8>,
363 hash: u64,
364 file_hasher: blake3::Hasher,
365}
366
367impl StreamingChunker {
368 pub fn new(chunker: &GearChunker) -> Self {
370 let bits = logarithm2(chunker.avg);
371 let level = chunker.normalization_level as u32;
372 let mask_s = MASKS[(bits + level).min(63) as usize];
373 let mask_l = MASKS[bits.saturating_sub(level) as usize];
374 Self {
375 min: chunker.min,
376 avg: chunker.avg,
377 max: chunker.max,
378 normalization_level: chunker.normalization_level,
379 mask: (chunker.avg - 1) as u64,
380 mask_s,
381 mask_l,
382 buffer: Vec::with_capacity(chunker.max),
383 hash: 0,
384 file_hasher: blake3::Hasher::new(),
385 }
386 }
387
388 pub fn feed(&mut self, data: &[u8]) -> Vec<Chunk> {
390 let mut chunks = Vec::new();
391 self.file_hasher.update(data);
392
393 for &byte in data {
394 self.buffer.push(byte);
395 self.hash = self.hash.wrapping_shl(1).wrapping_add(GEAR_TABLE[byte as usize]);
396
397 let chunk_len = self.buffer.len();
398 let active_mask = if self.normalization_level == 0 {
399 self.mask
400 } else if chunk_len < self.avg {
401 self.mask_s
402 } else {
403 self.mask_l
404 };
405 if chunk_len >= self.min && (self.hash & active_mask == 0 || chunk_len >= self.max) {
406 let chunk_data = std::mem::replace(&mut self.buffer, Vec::with_capacity(self.max));
407 let chunk_hash = blake3::hash(&chunk_data);
408 chunks.push(Chunk { data: chunk_data, hash: chunk_hash });
409 self.hash = 0;
410 }
411 }
412
413 chunks
414 }
415
416 pub fn finish(self) -> (Option<Chunk>, blake3::Hash) {
421 let file_hash = self.file_hasher.finalize();
422 if self.buffer.is_empty() {
423 (None, file_hash)
424 } else {
425 let chunk_hash = blake3::hash(&self.buffer);
426 (Some(Chunk { data: self.buffer, hash: chunk_hash }), file_hash)
427 }
428 }
429
430 pub fn buffered(&self) -> usize {
432 self.buffer.len()
433 }
434}
435
436#[cfg(test)]
437mod tests {
438 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
439 use super::*;
440
441 #[test]
442 fn test_deterministic_chunking() {
443 let data = vec![42u8; 200_000];
444 let chunker = GearChunker::default();
445 let chunks1 = chunker.chunk_slice(&data);
446 let chunks2 = chunker.chunk_slice(&data);
447
448 assert_eq!(chunks1.len(), chunks2.len());
449 for (a, b) in chunks1.iter().zip(chunks2.iter()) {
450 assert_eq!(a.hash, b.hash);
451 assert_eq!(a.data.len(), b.data.len());
452 }
453 }
454
455 #[test]
456 fn test_min_max_boundaries() {
457 let chunker = GearChunker::new(1024, 4096, 8192).unwrap();
458 let data = vec![0xABu8; 50_000];
459 let chunks = chunker.chunk_slice(&data);
460
461 let total: usize = chunks.iter().map(|c| c.data.len()).sum();
462 assert_eq!(total, data.len());
463
464 for (i, chunk) in chunks.iter().enumerate() {
465 if i < chunks.len() - 1 {
467 assert!(chunk.data.len() >= 1024, "chunk too small: {}", chunk.data.len());
468 }
469 assert!(chunk.data.len() <= 8192, "chunk too large: {}", chunk.data.len());
470 }
471 }
472
473 #[test]
474 fn test_small_file_single_chunk() {
475 let chunker = GearChunker::default();
476 let data = vec![0x42u8; 100]; let chunks = chunker.chunk_slice(&data);
478 assert_eq!(chunks.len(), 1);
479 assert_eq!(chunks[0].data.len(), 100);
480 }
481
482 #[test]
483 fn test_empty_input() {
484 let chunker = GearChunker::default();
485 let chunks = chunker.chunk_slice(&[]);
486 assert!(chunks.is_empty());
487 }
488
489 #[test]
490 #[allow(deprecated)]
491 fn test_empty_reader() {
492 let chunker = GearChunker::default();
493 let chunks = chunker.chunk_reader(std::io::empty()).unwrap();
494 assert!(chunks.is_empty());
495 }
496
497 #[test]
498 fn test_rolling_hash_stability() {
499 let chunker = GearChunker::new(512, 4096, 16384).unwrap();
501 let mut data_a = vec![0u8; 100_000];
502 for (i, b) in data_a.iter_mut().enumerate() {
504 *b = (i.wrapping_mul(0x9E3779B9) >> 24) as u8;
505 }
506 let mut data_b = data_a.clone();
507 data_b.insert(500, 0xFF);
509
510 let chunks_a = chunker.chunk_slice(&data_a);
511 let chunks_b = chunker.chunk_slice(&data_b);
512
513 let hashes_a: std::collections::HashSet<[u8; 32]> =
516 chunks_a.iter().map(|c| *c.hash.as_bytes()).collect();
517 let hashes_b: std::collections::HashSet<[u8; 32]> =
518 chunks_b.iter().map(|c| *c.hash.as_bytes()).collect();
519 let common = hashes_a.intersection(&hashes_b).count();
520
521 let max_chunks = chunks_a.len().max(chunks_b.len());
523 assert!(common * 2 >= max_chunks,
524 "too few common chunks: {}/{}", common, max_chunks);
525 }
526
527 #[test]
528 #[allow(deprecated)]
529 fn test_chunk_reader_matches_slice() {
530 let chunker = GearChunker::default();
531 let data = vec![0x55u8; 150_000];
532 let from_slice = chunker.chunk_slice(&data);
533 let from_reader = chunker.chunk_reader(std::io::Cursor::new(&data)).unwrap();
534
535 assert_eq!(from_slice.len(), from_reader.len());
536 for (a, b) in from_slice.iter().zip(from_reader.iter()) {
537 assert_eq!(a.hash, b.hash);
538 }
539 }
540
541 #[test]
542 fn test_blake3_hashes_correct() {
543 let chunker = GearChunker::default();
544 let data = b"hello world, this is a test of the chunking system";
545 let chunks = chunker.chunk_slice(data);
546 assert_eq!(chunks.len(), 1); assert_eq!(chunks[0].hash, blake3::hash(data));
548 }
549
550 #[cfg(feature = "cloud")]
551 #[test]
552 fn test_s3_wan_preset_values() {
553 assert_eq!(super::S3_WAN.min, 5 * 1024 * 1024);
554 assert_eq!(super::S3_WAN.avg, 8 * 1024 * 1024);
555 assert_eq!(super::S3_WAN.max, 64 * 1024 * 1024);
556 }
557
558 #[cfg(feature = "cloud")]
559 #[test]
560 fn test_s3_lan_preset_values() {
561 assert_eq!(super::S3_LAN.min, 5 * 1024 * 1024);
562 assert_eq!(super::S3_LAN.avg, 16 * 1024 * 1024);
563 assert_eq!(super::S3_LAN.max, 128 * 1024 * 1024);
564 }
565
566 #[cfg(feature = "cloud")]
567 #[test]
568 fn test_s3_archive_preset_values() {
569 assert_eq!(super::S3_ARCHIVE.min, 5 * 1024 * 1024);
570 assert_eq!(super::S3_ARCHIVE.avg, 32 * 1024 * 1024);
571 assert_eq!(super::S3_ARCHIVE.max, 256 * 1024 * 1024);
572 }
573
574 #[cfg(feature = "cloud")]
575 #[test]
576 fn test_s3_wan_chunk_distribution() {
577 let mut data = vec![0u8; 16 * 1024 * 1024];
578 for (i, b) in data.iter_mut().enumerate() {
579 *b = (i.wrapping_mul(0x9E3779B9) >> 24) as u8;
580 }
581 let chunks = super::S3_WAN.chunk_slice(&data);
582 let total: usize = chunks.iter().map(|c| c.data.len()).sum();
583 assert_eq!(total, data.len(), "total chunk data must equal input");
584 assert!(chunks.len() >= 1 && chunks.len() <= 8,
585 "expected ~2 chunks for 16MB/8MB avg, got {}", chunks.len());
586 }
587
588 #[test]
589 fn test_streaming_chunker_matches_slice() {
590 let chunker = GearChunker::new(64, 256, 1024).unwrap();
591 let mut data = vec![0u8; 8192];
592 for (i, b) in data.iter_mut().enumerate() {
593 *b = (i.wrapping_mul(0x9E3779B9) >> 24) as u8;
594 }
595
596 let slice_chunks = chunker.chunk_slice(&data);
597
598 let mut sc = StreamingChunker::new(&chunker);
599 let mut stream_chunks = Vec::new();
600 for chunk_buf in data.chunks(512) {
601 stream_chunks.extend(sc.feed(chunk_buf));
602 }
603 let (final_chunk, _file_hash) = sc.finish();
604 if let Some(c) = final_chunk {
605 stream_chunks.push(c);
606 }
607
608 assert_eq!(slice_chunks.len(), stream_chunks.len(),
609 "chunk count mismatch: slice={}, stream={}", slice_chunks.len(), stream_chunks.len());
610 for (i, (s, st)) in slice_chunks.iter().zip(stream_chunks.iter()).enumerate() {
611 assert_eq!(s.data, st.data, "chunk {i} data mismatch");
612 assert_eq!(s.hash, st.hash, "chunk {i} hash mismatch");
613 }
614 }
615
616 #[test]
617 fn test_streaming_chunker_single_feed() {
618 let chunker = GearChunker::new(64, 256, 1024).unwrap();
619 let data = vec![42u8; 4096];
620
621 let slice_chunks = chunker.chunk_slice(&data);
622 let mut sc = StreamingChunker::new(&chunker);
623 let mut stream_chunks = sc.feed(&data);
624 let (final_chunk, _) = sc.finish();
625 if let Some(c) = final_chunk { stream_chunks.push(c); }
626
627 assert_eq!(slice_chunks.len(), stream_chunks.len());
628 }
629
630 #[test]
631 fn test_streaming_chunker_byte_by_byte() {
632 let chunker = GearChunker::new(64, 256, 1024).unwrap();
633 let data = vec![0u8; 2048];
634
635 let slice_chunks = chunker.chunk_slice(&data);
636 let mut sc = StreamingChunker::new(&chunker);
637 let mut stream_chunks = Vec::new();
638 for &b in &data {
639 stream_chunks.extend(sc.feed(&[b]));
640 }
641 let (final_chunk, _) = sc.finish();
642 if let Some(c) = final_chunk { stream_chunks.push(c); }
643
644 assert_eq!(slice_chunks.len(), stream_chunks.len());
645 }
646
647 #[test]
648 fn test_streaming_chunker_file_hash() {
649 let chunker = GearChunker::new(64, 256, 1024).unwrap();
650 let data = b"Hello world, this is streaming chunker test data!";
651
652 let mut sc = StreamingChunker::new(&chunker);
653 sc.feed(data);
654 let (_, file_hash) = sc.finish();
655
656 assert_eq!(file_hash, blake3::hash(data));
657 }
658
659 #[test]
660 fn test_streaming_chunker_empty() {
661 let chunker = GearChunker::new(64, 256, 1024).unwrap();
662 let mut sc = StreamingChunker::new(&chunker);
663 let chunks = sc.feed(&[]);
664 assert!(chunks.is_empty());
665 let (final_chunk, _) = sc.finish();
666 assert!(final_chunk.is_none());
667 }
668
669 #[test]
670 fn test_pow2_enforcement() {
671 assert!(GearChunker::new(1024, 4096, 8192).is_ok());
672 assert!(GearChunker::new(1024, 65536, 2097152).is_ok());
673 let err = GearChunker::new(1024, 5000, 8192).unwrap_err();
674 let msg = err.to_string();
675 assert!(msg.contains("power of two"), "expected pow2 hint in: {msg}");
676 assert!(msg.contains("8192"), "expected next_power_of_two suggestion in: {msg}");
677 }
678
679 #[test]
680 fn test_with_normalization_validation() {
681 assert!(GearChunker::new(1024, 4096, 8192).unwrap().with_normalization(0).is_ok());
682 assert!(GearChunker::new(1024, 4096, 8192).unwrap().with_normalization(1).is_ok());
683 assert!(GearChunker::new(1024, 4096, 8192).unwrap().with_normalization(2).is_ok());
684 let err = GearChunker::new(1024, 4096, 8192).unwrap().with_normalization(3).unwrap_err();
685 assert!(err.to_string().contains("out of range"), "expected 'out of range' in: {err}");
686 }
687
688 #[test]
689 fn test_chunker_masks_no_zero() {
690 assert_eq!(MASKS[0], u64::MAX, "MASKS[0] must be u64::MAX sentinel, not 0");
691 for i in 1..64u32 {
692 assert_ne!(MASKS[i as usize], 0, "MASKS[{i}] must not be zero");
693 assert_eq!(MASKS[i as usize], (1u64 << i) - 1, "MASKS[{i}] formula mismatch");
694 }
695
696 let result = GearChunker::new(2, 4, 16).unwrap().with_normalization(2);
699 assert!(result.is_err(), "avg=4 with level=2 should be rejected (would use MASKS[0])");
700
701 let result = GearChunker::new(1, 2, 8).unwrap().with_normalization(1);
703 assert!(result.is_err(), "avg=2 with level=1 should be rejected (would use MASKS[0])");
704
705 for &avg in &[8usize, 16, 64, 256, 4096, 65536] {
707 for level in 0..=2u8 {
708 let bits = logarithm2(avg);
709 let result = GearChunker::new(1, avg, avg * 4).unwrap().with_normalization(level);
710 if level == 0 || bits > level as u32 {
711 assert!(result.is_ok(), "avg={avg} level={level} should be valid");
712 if level > 0 {
713 let mask_l_idx = bits.saturating_sub(level as u32) as usize;
714 assert_ne!(MASKS[mask_l_idx], 0, "mask_l for avg={avg} level={level} must not be zero");
715 }
716 }
717 }
718 }
719 }
720
721 #[test]
722 fn test_normalization_default() {
723 assert_eq!(GearChunker::default().normalization_level, 0);
724 assert_eq!(GearChunker::new(2048, 65536, 2097152).unwrap().normalization_level, 0);
725 }
726
727 fn chunk_size_stats(sizes: &[usize]) -> (f64, f64, f64) {
728 if sizes.is_empty() {
729 return (0.0, 0.0, 0.0);
730 }
731 let n = sizes.len() as f64;
732 let mean = sizes.iter().map(|&s| s as f64).sum::<f64>() / n;
733 let variance = sizes.iter().map(|&s| {
734 let diff = s as f64 - mean;
735 diff * diff
736 }).sum::<f64>() / n;
737 let stddev = variance.sqrt();
738 let cv = if mean > 0.0 { stddev / mean } else { 0.0 };
739 (mean, stddev, cv)
740 }
741
742 fn legacy_find_boundaries(min: usize, avg: usize, max: usize, data: &[u8]) -> Vec<usize> {
743 let mask = (avg - 1) as u64;
744 let mut boundaries = Vec::new();
745 let mut start = 0;
746 let mut hash: u64 = 0;
747 for i in 0..data.len() {
748 hash = hash.wrapping_shl(1).wrapping_add(GEAR_TABLE[data[i] as usize]);
749 let chunk_len = i - start + 1;
750 if chunk_len >= min && (hash & mask == 0 || chunk_len >= max) {
751 boundaries.push(i + 1);
752 start = i + 1;
753 hash = 0;
754 }
755 }
756 if start < data.len() {
757 boundaries.push(data.len());
758 }
759 boundaries
760 }
761
762 #[test]
763 fn test_normalization_zero_identical_to_legacy() {
764 let chunker = GearChunker::new(64, 256, 1024).unwrap();
765 let mut data = vec![0u8; 8192];
766 for (i, b) in data.iter_mut().enumerate() {
767 *b = (i.wrapping_mul(0x9E3779B9_usize) >> 24) as u8;
768 }
769 let new_bounds = chunker.find_boundaries(&data);
770 let old_bounds = legacy_find_boundaries(64, 256, 1024, &data);
771 assert_eq!(new_bounds, old_bounds, "normalization=0 must produce identical boundaries to legacy");
772 }
773
774 #[test]
775 fn test_normalization_one_produces_valid_chunks() {
776 let chunker = GearChunker::new(64, 256, 1024).unwrap()
777 .with_normalization(1).unwrap();
778 let mut data = vec![0u8; 8192];
779 for (i, b) in data.iter_mut().enumerate() {
780 *b = (i.wrapping_mul(0x9E3779B9_usize) >> 24) as u8;
781 }
782 let boundaries = chunker.find_boundaries(&data);
783 assert!(!boundaries.is_empty(), "must find at least one boundary");
784 assert_eq!(*boundaries.last().unwrap(), data.len(), "must cover all input");
785 let mut start = 0;
786 for &end in &boundaries {
787 let chunk_len = end - start;
788 assert!(chunk_len <= 1024, "chunk exceeds max: {chunk_len}");
789 start = end;
790 }
791 }
792
793 #[test]
794 fn test_normalization_zero_two_produce_chunks() {
795 let data: Vec<u8> = (0..4096_usize).map(|i| (i.wrapping_mul(251) >> 3) as u8).collect();
796 for level in [0u8, 1, 2] {
797 let chunker = GearChunker::new(64, 256, 1024).unwrap()
798 .with_normalization(level).unwrap();
799 let boundaries = chunker.find_boundaries(&data);
800 assert!(!boundaries.is_empty(), "level={level}: must produce boundaries");
801 assert_eq!(*boundaries.last().unwrap(), data.len(), "level={level}: must cover all input");
802 }
803 }
804
805 #[test]
806 fn test_chunk_distribution_quality() {
807 let chunker = GearChunker::new(2048, 65536, 2097152).unwrap();
808 let mut data = vec![0u8; 10 * 1024 * 1024];
809 let mut state: u64 = 0xDEAD_BEEF_CAFE_1337;
810 for byte in data.iter_mut() {
811 state = state
812 .wrapping_mul(6_364_136_223_846_793_005)
813 .wrapping_add(1_442_695_040_888_963_407);
814 *byte = (state >> 56) as u8;
815 }
816
817 let chunks = chunker.chunk_slice(&data);
818 let sizes: Vec<usize> = chunks.iter().map(|c| c.data.len()).collect();
819
820 let total: usize = sizes.iter().sum();
821 assert_eq!(total, data.len(), "chunks must cover all input bytes");
822
823 for (i, &sz) in sizes.iter().enumerate() {
824 if i < sizes.len() - 1 {
825 assert!(sz >= 2048, "non-trailing chunk below min: {sz}");
826 }
827 assert!(sz <= 2097152, "chunk exceeds max: {sz}");
828 }
829
830 let (mean, stddev, cv) = chunk_size_stats(&sizes);
831 eprintln!("normalization=0 distribution: chunks={}, mean={:.0}, stddev={:.0}, cv={:.3}",
832 sizes.len(), mean, stddev, cv);
833
834 assert!(!sizes.is_empty(), "must produce at least one chunk");
835 }
836
837 #[test]
838 fn test_distribution_cv_normalization_one_tighter() {
839 let mut data = vec![0u8; 10 * 1024 * 1024];
840 let mut state: u64 = 0xDEAD_BEEF_CAFE_1337;
841 for byte in data.iter_mut() {
842 state = state
843 .wrapping_mul(6_364_136_223_846_793_005)
844 .wrapping_add(1_442_695_040_888_963_407);
845 *byte = (state >> 56) as u8;
846 }
847
848 let norm0 = GearChunker::new(2048, 65536, 2097152).unwrap();
849 let norm1 = GearChunker::new(2048, 65536, 2097152).unwrap()
850 .with_normalization(1).unwrap();
851
852 let sizes0: Vec<usize> = norm0.chunk_slice(&data).into_iter().map(|c| c.data.len()).collect();
853 let sizes1: Vec<usize> = norm1.chunk_slice(&data).into_iter().map(|c| c.data.len()).collect();
854
855 let (mean0, stddev0, cv0) = chunk_size_stats(&sizes0);
856 let (mean1, stddev1, cv1) = chunk_size_stats(&sizes1);
857
858 eprintln!("normalization=0: chunks={}, mean={:.0}, stddev={:.0}, cv={:.3}",
859 sizes0.len(), mean0, stddev0, cv0);
860 eprintln!("normalization=1: chunks={}, mean={:.0}, stddev={:.0}, cv={:.3}",
861 sizes1.len(), mean1, stddev1, cv1);
862
863 assert!(cv1 < 0.75,
864 "normalization=1 CV {cv1:.3} must be < 0.75 (tighter distribution)");
865 assert!(cv1 < cv0,
866 "normalization=1 CV {cv1:.3} must be tighter than normalization=0 CV {cv0:.3}");
867 }
868
869 fn streaming_collect(chunker: &GearChunker, data: &[u8], feed_size: usize) -> Vec<Vec<u8>> {
870 let mut sc = StreamingChunker::new(chunker);
871 let mut out = Vec::new();
872 for chunk in data.chunks(feed_size) {
873 for c in sc.feed(chunk) {
874 out.push(c.data);
875 }
876 }
877 let (final_chunk, _) = sc.finish();
878 if let Some(c) = final_chunk {
879 out.push(c.data);
880 }
881 out
882 }
883
884 #[test]
885 fn test_streaming_matches_slice_normalization_one() {
886 let chunker = GearChunker::new(64, 256, 1024).unwrap()
887 .with_normalization(1).unwrap();
888 let mut data = vec![0u8; 8192];
889 for (i, b) in data.iter_mut().enumerate() {
890 *b = (i.wrapping_mul(0x9E3779B9_usize) >> 24) as u8;
891 }
892 let slice_data: Vec<Vec<u8>> = chunker.chunk_slice(&data).into_iter().map(|c| c.data).collect();
893 let stream_data = streaming_collect(&chunker, &data, 512);
894 assert_eq!(slice_data, stream_data, "normalization=1: streaming must match slice");
895 }
896
897 #[test]
898 fn test_streaming_matches_slice_normalization_two() {
899 let chunker = GearChunker::new(64, 256, 1024).unwrap()
900 .with_normalization(2).unwrap();
901 let mut data = vec![0u8; 8192];
902 for (i, b) in data.iter_mut().enumerate() {
903 *b = (i.wrapping_mul(0x9E3779B9_usize) >> 24) as u8;
904 }
905 let slice_data: Vec<Vec<u8>> = chunker.chunk_slice(&data).into_iter().map(|c| c.data).collect();
906 let stream_data = streaming_collect(&chunker, &data, 512);
907 assert_eq!(slice_data, stream_data, "normalization=2: streaming must match slice");
908 }
909
910 #[test]
911 #[allow(deprecated)]
912 fn test_chunk_reader_large_file_guard() {
913 let chunker = GearChunker::default();
914 let data = vec![0xABu8; 17 * 1024 * 1024]; let result = chunker.chunk_reader(std::io::Cursor::new(&data));
917 let err = match result {
918 Err(e) => e,
919 Ok(_) => panic!("chunk_reader must reject inputs > 16MB"),
920 };
921 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
922 assert!(err.to_string().contains("16 MB"), "error should mention 16 MB limit: {err}");
923 }
924
925 #[test]
926 fn test_streaming_byte_by_byte_normalization_one() {
927 let chunker = GearChunker::new(64, 256, 1024).unwrap()
928 .with_normalization(1).unwrap();
929 let data = vec![0xABu8; 2048];
930 let slice_data: Vec<Vec<u8>> = chunker.chunk_slice(&data).into_iter().map(|c| c.data).collect();
931 let stream_data = streaming_collect(&chunker, &data, 1);
932 assert_eq!(slice_data, stream_data, "byte-by-byte normalization=1 must match slice");
933 }
934}