1use std::collections::HashMap;
38use std::fmt;
39use std::time::{SystemTime, UNIX_EPOCH};
40
41#[inline]
49pub fn fnv1a_64(data: &[u8]) -> u64 {
50 let mut h: u64 = 14695981039346656037;
51 for &b in data {
52 h ^= b as u64;
53 h = h.wrapping_mul(1099511628211);
54 }
55 h
56}
57
58#[inline]
61fn rolling_hash(window: &[u8]) -> u64 {
62 fnv1a_64(window)
63}
64
65fn unix_timestamp() -> u64 {
68 SystemTime::now()
69 .duration_since(UNIX_EPOCH)
70 .map(|d| d.as_secs())
71 .unwrap_or(0)
72}
73
74#[derive(Hash, Eq, PartialEq, Clone, Copy, Debug)]
80pub struct ChunkHash([u8; 8]);
81
82impl ChunkHash {
83 #[inline]
85 pub fn from_bytes(bytes: [u8; 8]) -> Self {
86 Self(bytes)
87 }
88
89 #[inline]
91 pub fn as_bytes(&self) -> &[u8; 8] {
92 &self.0
93 }
94
95 #[inline]
97 pub fn to_u64(self) -> u64 {
98 u64::from_le_bytes(self.0)
99 }
100}
101
102impl From<u64> for ChunkHash {
103 #[inline]
104 fn from(v: u64) -> Self {
105 Self(v.to_le_bytes())
106 }
107}
108
109impl fmt::Display for ChunkHash {
110 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111 for b in &self.0 {
112 write!(f, "{:02x}", b)?;
113 }
114 Ok(())
115 }
116}
117
118#[derive(Debug, Clone)]
125pub struct Chunk {
126 pub hash: ChunkHash,
128 pub offset: u64,
130 pub length: usize,
132 pub ref_count: u32,
134 pub compressed: bool,
136}
137
138#[derive(Debug, Clone)]
144pub struct DeduplicationStats {
145 pub total_chunks: u64,
147 pub unique_chunks: u64,
149 pub duplicate_chunks: u64,
151 pub bytes_before: u64,
153 pub bytes_after: u64,
155 pub dedup_ratio: f64,
158 pub compression_ratio: f64,
160}
161
162#[derive(Debug, Clone)]
169pub struct ChunkingConfig {
170 pub min_chunk_size: usize,
172 pub max_chunk_size: usize,
175 pub target_bits: u8,
178 pub window_size: usize,
180 pub enable_compression: bool,
182}
183
184impl Default for ChunkingConfig {
185 fn default() -> Self {
186 Self {
187 min_chunk_size: 2048,
188 max_chunk_size: 65536,
189 target_bits: 13,
190 window_size: 48,
191 enable_compression: false,
192 }
193 }
194}
195
196impl ChunkingConfig {
197 #[inline]
199 pub fn boundary_mask(&self) -> u64 {
200 (1u64 << self.target_bits).wrapping_sub(1)
201 }
202}
203
204#[derive(Debug, Clone)]
211pub struct ChunkRef {
212 pub hash: ChunkHash,
214 pub offset_in_object: u64,
216 pub chunk_length: usize,
218}
219
220#[derive(Debug, Clone)]
226pub struct ObjectManifest {
227 pub object_id: String,
229 pub total_size: u64,
231 pub chunks: Vec<ChunkRef>,
233 pub created_at: u64,
235}
236
237#[derive(Debug, thiserror::Error)]
241pub enum DeduplicatorError {
242 #[error("object not found: {0}")]
244 ObjectNotFound(String),
245
246 #[error("chunk not found: {0}")]
248 ChunkNotFound(ChunkHash),
249
250 #[error("compression failed: {0}")]
252 CompressionFailed(String),
253
254 #[error("invalid manifest: {0}")]
256 InvalidManifest(String),
257
258 #[error("storage full")]
260 StorageFull,
261}
262
263pub struct BlockDeduplicator {
286 config: ChunkingConfig,
287 chunk_store: HashMap<ChunkHash, (Chunk, Vec<u8>)>,
289 manifests: HashMap<String, ObjectManifest>,
291 total_bytes_stored: u64,
293 total_bytes_deduplicated: u64,
295}
296
297impl BlockDeduplicator {
298 pub fn new(config: ChunkingConfig) -> Self {
300 Self {
301 config,
302 chunk_store: HashMap::new(),
303 manifests: HashMap::new(),
304 total_bytes_stored: 0,
305 total_bytes_deduplicated: 0,
306 }
307 }
308
309 pub fn with_defaults() -> Self {
311 Self::new(ChunkingConfig::default())
312 }
313
314 pub fn chunk_data(&self, data: &[u8]) -> Vec<(ChunkHash, Vec<u8>)> {
324 if data.is_empty() {
325 return Vec::new();
326 }
327
328 let min_sz = self.config.min_chunk_size;
329 let max_sz = self.config.max_chunk_size;
330 let win = self.config.window_size;
331 let mask = self.config.boundary_mask();
332
333 let mut result = Vec::new();
334 let mut chunk_start = 0usize;
335
336 let mut pos = 0usize;
337 while pos < data.len() {
338 let chunk_len = pos - chunk_start;
339
340 if chunk_len >= min_sz {
342 let win_start = pos.saturating_sub(win);
344 let window = &data[win_start..pos];
345 let h = rolling_hash(window);
346
347 let is_hash_boundary = (h & mask) == 0;
348 let is_max_boundary = chunk_len >= max_sz;
349
350 if is_hash_boundary || is_max_boundary {
351 let chunk_bytes = data[chunk_start..pos].to_vec();
353 let hash = ChunkHash::from(fnv1a_64(&chunk_bytes));
354 result.push((hash, chunk_bytes));
355 chunk_start = pos;
356 }
357 }
358
359 pos += 1;
360 }
361
362 if chunk_start < data.len() {
364 let chunk_bytes = data[chunk_start..].to_vec();
365 let hash = ChunkHash::from(fnv1a_64(&chunk_bytes));
366 result.push((hash, chunk_bytes));
367 }
368
369 result
370 }
371
372 pub fn store_object(
383 &mut self,
384 object_id: String,
385 data: Vec<u8>,
386 ) -> Result<ObjectManifest, DeduplicatorError> {
387 let total_size = data.len() as u64;
388 self.total_bytes_stored += total_size;
389
390 let chunks = self.chunk_data(&data);
391 let mut chunk_refs = Vec::with_capacity(chunks.len());
392 let mut offset_in_object: u64 = 0;
393 let mut chunk_offset_in_store: u64 = 0;
394
395 for (hash, chunk_bytes) in chunks {
396 let chunk_len = chunk_bytes.len();
397
398 if let Some((existing, _)) = self.chunk_store.get_mut(&hash) {
399 existing.ref_count = existing.ref_count.saturating_add(1);
401 self.total_bytes_deduplicated += chunk_len as u64;
402 } else {
403 let chunk = Chunk {
405 hash,
406 offset: chunk_offset_in_store,
407 length: chunk_len,
408 ref_count: 1,
409 compressed: false,
410 };
411 self.chunk_store.insert(hash, (chunk, chunk_bytes));
412 chunk_offset_in_store += chunk_len as u64;
413 }
414
415 chunk_refs.push(ChunkRef {
416 hash,
417 offset_in_object,
418 chunk_length: chunk_len,
419 });
420
421 offset_in_object += chunk_len as u64;
422 }
423
424 let manifest = ObjectManifest {
425 object_id: object_id.clone(),
426 total_size,
427 chunks: chunk_refs,
428 created_at: unix_timestamp(),
429 };
430
431 self.manifests.insert(object_id, manifest.clone());
432 Ok(manifest)
433 }
434
435 pub fn retrieve_object(&self, object_id: &str) -> Result<Vec<u8>, DeduplicatorError> {
446 let manifest = self
447 .manifests
448 .get(object_id)
449 .ok_or_else(|| DeduplicatorError::ObjectNotFound(object_id.to_string()))?;
450
451 let mut result = Vec::with_capacity(manifest.total_size as usize);
452
453 for chunk_ref in &manifest.chunks {
454 let (_, data) = self
455 .chunk_store
456 .get(&chunk_ref.hash)
457 .ok_or(DeduplicatorError::ChunkNotFound(chunk_ref.hash))?;
458 result.extend_from_slice(data);
459 }
460
461 if result.len() as u64 != manifest.total_size {
462 return Err(DeduplicatorError::InvalidManifest(format!(
463 "object '{}': expected {} bytes, reconstructed {} bytes",
464 object_id,
465 manifest.total_size,
466 result.len()
467 )));
468 }
469
470 Ok(result)
471 }
472
473 pub fn delete_object(&mut self, object_id: &str) -> Result<Vec<ChunkHash>, DeduplicatorError> {
482 let manifest = self
483 .manifests
484 .remove(object_id)
485 .ok_or_else(|| DeduplicatorError::ObjectNotFound(object_id.to_string()))?;
486
487 let mut removed = Vec::new();
488
489 for chunk_ref in &manifest.chunks {
490 if let Some((meta, _)) = self.chunk_store.get_mut(&chunk_ref.hash) {
491 meta.ref_count = meta.ref_count.saturating_sub(1);
492 if meta.ref_count == 0 {
493 self.chunk_store.remove(&chunk_ref.hash);
494 removed.push(chunk_ref.hash);
495 }
496 }
497 }
498
499 Ok(removed)
500 }
501
502 pub fn get_chunk(&self, hash: &ChunkHash) -> Result<&Chunk, DeduplicatorError> {
508 self.chunk_store
509 .get(hash)
510 .map(|(meta, _)| meta)
511 .ok_or(DeduplicatorError::ChunkNotFound(*hash))
512 }
513
514 pub fn chunk_exists(&self, hash: &ChunkHash) -> bool {
516 self.chunk_store.contains_key(hash)
517 }
518
519 pub fn compact(&mut self) -> usize {
529 let before = self.chunk_store.len();
530 self.chunk_store.retain(|_, (meta, _)| meta.ref_count > 0);
531 before - self.chunk_store.len()
532 }
533
534 pub fn stats(&self) -> DeduplicationStats {
536 let unique_chunks = self.chunk_store.len() as u64;
537 let total_chunks: u64 = self
538 .chunk_store
539 .values()
540 .map(|(meta, _)| meta.ref_count as u64)
541 .sum();
542 let duplicate_chunks = total_chunks.saturating_sub(unique_chunks);
543
544 let bytes_after: u64 = self
545 .chunk_store
546 .values()
547 .map(|(_, data)| data.len() as u64)
548 .sum();
549
550 let bytes_before = self.total_bytes_stored;
551
552 let dedup_ratio = if bytes_before > 0 {
553 1.0 - (bytes_after as f64 / bytes_before as f64)
554 } else {
555 0.0
556 };
557
558 let compression_ratio = bytes_before as f64 / bytes_after.max(1) as f64;
559
560 DeduplicationStats {
561 total_chunks,
562 unique_chunks,
563 duplicate_chunks,
564 bytes_before,
565 bytes_after,
566 dedup_ratio,
567 compression_ratio,
568 }
569 }
570
571 pub fn list_objects(&self) -> Vec<String> {
573 let mut ids: Vec<String> = self.manifests.keys().cloned().collect();
574 ids.sort();
575 ids
576 }
577
578 pub fn chunk_count(&self) -> usize {
580 self.chunk_store.len()
581 }
582
583 pub fn object_count(&self) -> usize {
585 self.manifests.len()
586 }
587
588 pub fn get_manifest(&self, object_id: &str) -> Option<&ObjectManifest> {
590 self.manifests.get(object_id)
591 }
592
593 pub fn config(&self) -> &ChunkingConfig {
595 &self.config
596 }
597}
598
599#[cfg(test)]
602mod tests {
603 use super::*;
604
605 fn xorshift64(state: &mut u64) -> u64 {
608 let mut x = *state;
609 x ^= x << 13;
610 x ^= x >> 7;
611 x ^= x << 17;
612 *state = x;
613 x
614 }
615
616 fn gen_bytes(seed: u64, len: usize) -> Vec<u8> {
617 let mut state = seed;
618 let mut out = Vec::with_capacity(len);
619 while out.len() < len {
620 let v = xorshift64(&mut state);
621 let bytes = v.to_le_bytes();
622 let remaining = len - out.len();
623 let take = remaining.min(8);
624 out.extend_from_slice(&bytes[..take]);
625 }
626 out
627 }
628
629 fn default_dedup() -> BlockDeduplicator {
630 BlockDeduplicator::with_defaults()
631 }
632
633 #[test]
636 fn test_single_chunk_small_data() {
637 let dedup = default_dedup();
638 let data = gen_bytes(1, 100);
640 let chunks = dedup.chunk_data(&data);
641 assert_eq!(chunks.len(), 1);
642 assert_eq!(chunks[0].1, data);
643 }
644
645 #[test]
646 fn test_chunk_data_empty() {
647 let dedup = default_dedup();
648 let chunks = dedup.chunk_data(&[]);
649 assert!(chunks.is_empty(), "empty input should produce no chunks");
650 }
651
652 #[test]
653 fn test_chunk_data_exact_min() {
654 let config = ChunkingConfig {
655 min_chunk_size: 128,
656 max_chunk_size: 65536,
657 target_bits: 8, window_size: 48,
659 enable_compression: false,
660 };
661 let dedup = BlockDeduplicator::new(config);
662 let data = gen_bytes(42, 128);
665 let chunks = dedup.chunk_data(&data);
666 assert!(!chunks.is_empty());
667 let total: usize = chunks.iter().map(|(_, b)| b.len()).sum();
668 assert_eq!(total, 128);
669 }
670
671 #[test]
672 fn test_chunk_data_max_size_boundary() {
673 let config = ChunkingConfig {
674 min_chunk_size: 512,
675 max_chunk_size: 1024,
676 target_bits: 20, window_size: 32,
678 enable_compression: false,
679 };
680 let dedup = BlockDeduplicator::new(config);
681 let data = gen_bytes(7, 3 * 1024);
683 let chunks = dedup.chunk_data(&data);
684 assert!(
685 chunks.len() >= 3,
686 "expected at least 3 chunks, got {}",
687 chunks.len()
688 );
689 }
690
691 #[test]
692 fn test_chunk_data_deterministic() {
693 let dedup = default_dedup();
694 let data = gen_bytes(99, 200_000);
695 let c1 = dedup.chunk_data(&data);
696 let c2 = dedup.chunk_data(&data);
697 assert_eq!(c1.len(), c2.len());
698 for (a, b) in c1.iter().zip(c2.iter()) {
699 assert_eq!(a.0, b.0);
700 assert_eq!(a.1, b.1);
701 }
702 }
703
704 #[test]
705 fn test_chunk_data_window_smaller_than_data() {
706 let config = ChunkingConfig {
707 min_chunk_size: 4,
708 max_chunk_size: 65536,
709 target_bits: 4, window_size: 48,
711 enable_compression: false,
712 };
713 let dedup = BlockDeduplicator::new(config);
714 let data = gen_bytes(3, 20);
716 let chunks = dedup.chunk_data(&data);
717 let total: usize = chunks.iter().map(|(_, b)| b.len()).sum();
719 assert_eq!(total, 20);
720 }
721
722 #[test]
723 fn test_chunk_data_covers_all_bytes() {
724 let dedup = default_dedup();
725 let data = gen_bytes(55, 500_000);
726 let chunks = dedup.chunk_data(&data);
727 let total: usize = chunks.iter().map(|(_, b)| b.len()).sum();
728 assert_eq!(total, data.len(), "chunks must cover all bytes exactly");
729 }
730
731 #[test]
732 fn test_chunk_hash_hex_display() {
733 let hash = ChunkHash::from(0xDEADBEEF_CAFEBABE_u64);
734 let s = format!("{}", hash);
735 assert_eq!(s.len(), 16, "ChunkHash hex must be exactly 16 chars");
736 assert!(s.chars().all(|c| c.is_ascii_hexdigit()), "must be hex");
737 }
738
739 #[test]
740 fn test_chunk_hash_from_u64_roundtrip() {
741 let v: u64 = 0x0102030405060708;
742 let hash = ChunkHash::from(v);
743 assert_eq!(hash.to_u64(), v);
744 }
745
746 #[test]
747 fn test_rolling_hash_produces_boundaries() {
748 let config = ChunkingConfig {
751 min_chunk_size: 4,
752 max_chunk_size: 65536,
753 target_bits: 4, window_size: 4,
755 enable_compression: false,
756 };
757 let dedup = BlockDeduplicator::new(config);
758 let data = gen_bytes(1234, 10_000);
760 let chunks = dedup.chunk_data(&data);
761 assert!(
763 chunks.len() > 2,
764 "expected multiple hash-triggered boundaries, got {}",
765 chunks.len()
766 );
767 let total: usize = chunks.iter().map(|(_, b)| b.len()).sum();
769 assert_eq!(total, 10_000);
770 }
771
772 #[test]
773 fn test_chunk_data_large_uniform_zeros() {
774 let config = ChunkingConfig {
775 min_chunk_size: 512,
776 max_chunk_size: 1024,
777 target_bits: 20, window_size: 32,
779 enable_compression: false,
780 };
781 let dedup = BlockDeduplicator::new(config);
782 let data = vec![0u8; 5 * 1024];
784 let chunks = dedup.chunk_data(&data);
785 assert!(chunks.len() >= 5);
787 let total: usize = chunks.iter().map(|(_, b)| b.len()).sum();
788 assert_eq!(total, 5 * 1024);
789 }
790
791 #[test]
792 fn test_chunk_data_multiple_chunks() {
793 let config = ChunkingConfig {
794 min_chunk_size: 256,
795 max_chunk_size: 1024,
796 target_bits: 20, window_size: 32,
798 enable_compression: false,
799 };
800 let dedup = BlockDeduplicator::new(config);
801 let data = gen_bytes(11, 4096);
802 let chunks = dedup.chunk_data(&data);
803 assert!(
804 chunks.len() >= 4,
805 "expected ≥4 chunks from 4096 bytes with max_size=1024, got {}",
806 chunks.len()
807 );
808 }
809
810 #[test]
811 fn test_chunk_hash_from_bytes() {
812 let bytes = [0u8, 1, 2, 3, 4, 5, 6, 7];
813 let hash = ChunkHash::from_bytes(bytes);
814 assert_eq!(hash.as_bytes(), &bytes);
815 }
816
817 #[test]
818 fn test_chunk_data_each_chunk_nonempty() {
819 let dedup = default_dedup();
820 let data = gen_bytes(77, 300_000);
821 let chunks = dedup.chunk_data(&data);
822 for (i, (_, b)) in chunks.iter().enumerate() {
823 assert!(!b.is_empty(), "chunk {} must not be empty", i);
824 }
825 }
826
827 #[test]
830 fn test_store_and_retrieve_small() {
831 let mut dedup = default_dedup();
832 let data = b"hello world".to_vec();
833 dedup
834 .store_object("obj1".to_string(), data.clone())
835 .unwrap();
836 let retrieved = dedup.retrieve_object("obj1").unwrap();
837 assert_eq!(data, retrieved);
838 }
839
840 #[test]
841 fn test_store_and_retrieve_large() {
842 let mut dedup = default_dedup();
843 let data = gen_bytes(1001, 500_000);
844 dedup
845 .store_object("large".to_string(), data.clone())
846 .unwrap();
847 let retrieved = dedup.retrieve_object("large").unwrap();
848 assert_eq!(data, retrieved, "large object round-trip failed");
849 }
850
851 #[test]
852 fn test_store_duplicate_objects() {
853 let mut dedup = default_dedup();
854 let data = gen_bytes(22, 100_000);
855 dedup.store_object("a".to_string(), data.clone()).unwrap();
856 dedup.store_object("b".to_string(), data.clone()).unwrap();
857 let ra = dedup.retrieve_object("a").unwrap();
858 let rb = dedup.retrieve_object("b").unwrap();
859 assert_eq!(data, ra);
860 assert_eq!(data, rb);
861 }
862
863 #[test]
864 fn test_store_two_different_objects() {
865 let mut dedup = default_dedup();
866 let d1 = gen_bytes(101, 50_000);
867 let d2 = gen_bytes(202, 60_000);
868 dedup.store_object("x".to_string(), d1.clone()).unwrap();
869 dedup.store_object("y".to_string(), d2.clone()).unwrap();
870 assert_eq!(dedup.retrieve_object("x").unwrap(), d1);
871 assert_eq!(dedup.retrieve_object("y").unwrap(), d2);
872 }
873
874 #[test]
875 fn test_delete_object_removes_manifest() {
876 let mut dedup = default_dedup();
877 let data = gen_bytes(5, 10_000);
878 dedup.store_object("del_me".to_string(), data).unwrap();
879 dedup.delete_object("del_me").unwrap();
880 let err = dedup.retrieve_object("del_me").unwrap_err();
881 assert!(matches!(err, DeduplicatorError::ObjectNotFound(_)));
882 }
883
884 #[test]
885 fn test_delete_decrements_ref_count() {
886 let mut dedup = BlockDeduplicator::new(ChunkingConfig {
887 min_chunk_size: 256,
888 max_chunk_size: 65536,
889 target_bits: 20, window_size: 32,
891 enable_compression: false,
892 });
893 let data = gen_bytes(9, 1024); dedup
895 .store_object("obj_a".to_string(), data.clone())
896 .unwrap();
897 dedup
898 .store_object("obj_b".to_string(), data.clone())
899 .unwrap();
900
901 let manifest = dedup.get_manifest("obj_a").unwrap().clone();
904 let hash = manifest.chunks[0].hash;
905 assert_eq!(dedup.get_chunk(&hash).unwrap().ref_count, 2);
906
907 let removed = dedup.delete_object("obj_a").unwrap();
909 assert!(
910 removed.is_empty(),
911 "chunk still referenced by obj_b, should not be removed"
912 );
913 assert_eq!(dedup.get_chunk(&hash).unwrap().ref_count, 1);
914 }
915
916 #[test]
917 fn test_delete_removes_unshared_chunks() {
918 let mut dedup = default_dedup();
919 let data = gen_bytes(13, 50_000);
920 let manifest = dedup.store_object("only_obj".to_string(), data).unwrap();
921 let n_chunks = manifest.chunks.len();
922 let removed = dedup.delete_object("only_obj").unwrap();
923 assert_eq!(
924 removed.len(),
925 n_chunks,
926 "all unshared chunks should be removed"
927 );
928 }
929
930 #[test]
931 fn test_delete_nonexistent_object() {
932 let mut dedup = default_dedup();
933 let err = dedup.delete_object("ghost").unwrap_err();
934 assert!(matches!(err, DeduplicatorError::ObjectNotFound(_)));
935 }
936
937 #[test]
938 fn test_retrieve_nonexistent_object() {
939 let dedup = default_dedup();
940 let err = dedup.retrieve_object("ghost").unwrap_err();
941 assert!(matches!(err, DeduplicatorError::ObjectNotFound(_)));
942 }
943
944 #[test]
945 fn test_store_empty_data() {
946 let mut dedup = default_dedup();
947 let manifest = dedup.store_object("empty".to_string(), vec![]).unwrap();
948 assert_eq!(manifest.total_size, 0);
949 assert!(manifest.chunks.is_empty());
950 let retrieved = dedup.retrieve_object("empty").unwrap();
951 assert!(retrieved.is_empty());
952 }
953
954 #[test]
955 fn test_store_overwrite_same_id() {
956 let mut dedup = default_dedup();
957 let d1 = gen_bytes(1, 1000);
958 let d2 = gen_bytes(2, 2000);
959 dedup.store_object("obj".to_string(), d1).unwrap();
960 dedup.store_object("obj".to_string(), d2.clone()).unwrap();
961 let retrieved = dedup.retrieve_object("obj").unwrap();
963 assert_eq!(retrieved, d2);
964 }
965
966 #[test]
967 fn test_manifest_chunk_order() {
968 let mut dedup = default_dedup();
969 let data = gen_bytes(33, 200_000);
970 let manifest = dedup.store_object("ordered".to_string(), data).unwrap();
971 let mut prev_offset = 0u64;
972 for (i, cr) in manifest.chunks.iter().enumerate() {
973 assert!(
974 cr.offset_in_object >= prev_offset || i == 0,
975 "chunk {} has non-monotonic offset",
976 i
977 );
978 prev_offset = cr.offset_in_object;
979 }
980 }
981
982 #[test]
983 fn test_manifest_total_size() {
984 let mut dedup = default_dedup();
985 let data = gen_bytes(44, 150_000);
986 let expected_size = data.len() as u64;
987 let manifest = dedup.store_object("sized".to_string(), data).unwrap();
988 assert_eq!(manifest.total_size, expected_size);
989 }
990
991 #[test]
992 fn test_retrieve_exact_bytes() {
993 let mut dedup = default_dedup();
994 let data: Vec<u8> = (0..=255u8).cycle().take(10_000).collect();
995 dedup
996 .store_object("pattern".to_string(), data.clone())
997 .unwrap();
998 let retrieved = dedup.retrieve_object("pattern").unwrap();
999 assert_eq!(data, retrieved, "byte-for-byte equality required");
1000 }
1001
1002 #[test]
1003 fn test_delete_returns_removed_hashes() {
1004 let mut dedup = default_dedup();
1005 let data = gen_bytes(66, 30_000);
1006 let manifest = dedup.store_object("ret_hashes".to_string(), data).unwrap();
1007 let expected_hashes: Vec<ChunkHash> = manifest.chunks.iter().map(|cr| cr.hash).collect();
1008 let removed = dedup.delete_object("ret_hashes").unwrap();
1009 for h in &expected_hashes {
1010 assert!(removed.contains(h), "hash {} should be in removed list", h);
1011 }
1012 }
1013
1014 #[test]
1015 fn test_manifest_chunk_offsets_are_contiguous() {
1016 let mut dedup = default_dedup();
1017 let data = gen_bytes(88, 300_000);
1018 let manifest = dedup.store_object("contiguous".to_string(), data).unwrap();
1019 let mut expected_offset = 0u64;
1020 for cr in &manifest.chunks {
1021 assert_eq!(
1022 cr.offset_in_object, expected_offset,
1023 "chunk offsets must be contiguous"
1024 );
1025 expected_offset += cr.chunk_length as u64;
1026 }
1027 }
1028
1029 #[test]
1032 fn test_ref_count_increments_on_duplicate() {
1033 let mut dedup = BlockDeduplicator::new(ChunkingConfig {
1034 min_chunk_size: 256,
1035 max_chunk_size: 65536,
1036 target_bits: 20,
1037 window_size: 32,
1038 enable_compression: false,
1039 });
1040 let data = gen_bytes(111, 1024); dedup.store_object("d1".to_string(), data.clone()).unwrap();
1042 dedup.store_object("d2".to_string(), data.clone()).unwrap();
1043
1044 let manifest = dedup.get_manifest("d1").unwrap().clone();
1045 let hash = manifest.chunks[0].hash;
1046 assert_eq!(
1047 dedup.get_chunk(&hash).unwrap().ref_count,
1048 2,
1049 "ref_count should be 2 after two stores"
1050 );
1051 }
1052
1053 #[test]
1054 fn test_ref_count_one_unique() {
1055 let mut dedup = default_dedup();
1056 let data = gen_bytes(222, 50_000);
1057 let manifest = dedup.store_object("unique_obj".to_string(), data).unwrap();
1058 for cr in &manifest.chunks {
1059 let chunk = dedup.get_chunk(&cr.hash).unwrap();
1060 assert_eq!(
1061 chunk.ref_count, 1,
1062 "unique chunk should have ref_count == 1"
1063 );
1064 }
1065 }
1066
1067 #[test]
1068 fn test_dedup_ratio_identical_objects() {
1069 let mut dedup = default_dedup();
1070 let data = gen_bytes(333, 200_000);
1071 dedup
1072 .store_object("copy1".to_string(), data.clone())
1073 .unwrap();
1074 dedup
1075 .store_object("copy2".to_string(), data.clone())
1076 .unwrap();
1077
1078 let stats = dedup.stats();
1079 assert!(
1082 stats.dedup_ratio > 0.3,
1083 "expected dedup ratio > 0.3, got {}",
1084 stats.dedup_ratio
1085 );
1086 }
1087
1088 #[test]
1089 fn test_dedup_ratio_unique_objects() {
1090 let mut dedup = default_dedup();
1091 let d1 = gen_bytes(401, 100_000);
1092 let d2 = gen_bytes(402, 100_000);
1093 dedup.store_object("u1".to_string(), d1).unwrap();
1094 dedup.store_object("u2".to_string(), d2).unwrap();
1095
1096 let stats = dedup.stats();
1097 assert!(
1099 stats.dedup_ratio < 0.2,
1100 "expected low dedup ratio for unique data, got {}",
1101 stats.dedup_ratio
1102 );
1103 }
1104
1105 #[test]
1106 fn test_stats_total_chunks() {
1107 let mut dedup = BlockDeduplicator::new(ChunkingConfig {
1108 min_chunk_size: 256,
1109 max_chunk_size: 65536,
1110 target_bits: 20,
1111 window_size: 32,
1112 enable_compression: false,
1113 });
1114 let data = gen_bytes(500, 1024); dedup.store_object("s1".to_string(), data.clone()).unwrap();
1116 dedup.store_object("s2".to_string(), data.clone()).unwrap();
1117
1118 let stats = dedup.stats();
1119 assert_eq!(stats.total_chunks, 2);
1121 assert_eq!(stats.unique_chunks, 1);
1122 assert_eq!(stats.duplicate_chunks, 1);
1123 }
1124
1125 #[test]
1126 fn test_stats_unique_chunks_equals_store_size() {
1127 let mut dedup = default_dedup();
1128 let d1 = gen_bytes(601, 50_000);
1129 let d2 = gen_bytes(602, 50_000);
1130 dedup.store_object("a".to_string(), d1).unwrap();
1131 dedup.store_object("b".to_string(), d2).unwrap();
1132 let stats = dedup.stats();
1133 assert_eq!(stats.unique_chunks, dedup.chunk_count() as u64);
1134 }
1135
1136 #[test]
1137 fn test_stats_bytes_before_after() {
1138 let mut dedup = default_dedup();
1139 let data = gen_bytes(700, 100_000);
1140 dedup
1141 .store_object("ba_test".to_string(), data.clone())
1142 .unwrap();
1143 let stats = dedup.stats();
1144 assert_eq!(stats.bytes_before, 100_000);
1145 let expected_after: u64 = dedup
1147 .chunk_store
1148 .values()
1149 .map(|(_, d)| d.len() as u64)
1150 .sum();
1151 assert_eq!(stats.bytes_after, expected_after);
1152 }
1153
1154 #[test]
1155 fn test_chunk_exists_true() {
1156 let mut dedup = default_dedup();
1157 let data = b"some data for hashing".to_vec();
1158 let manifest = dedup.store_object("ce_true".to_string(), data).unwrap();
1159 let hash = manifest.chunks[0].hash;
1160 assert!(dedup.chunk_exists(&hash));
1161 }
1162
1163 #[test]
1164 fn test_chunk_exists_false() {
1165 let dedup = default_dedup();
1166 let fake_hash = ChunkHash::from(0xFFFF_FFFF_FFFF_FFFFu64);
1167 assert!(!dedup.chunk_exists(&fake_hash));
1168 }
1169
1170 #[test]
1171 fn test_get_chunk_success() {
1172 let mut dedup = default_dedup();
1173 let data = b"chunk metadata test".to_vec();
1174 let expected_len = data.len();
1175 let manifest = dedup.store_object("gc_ok".to_string(), data).unwrap();
1176 let hash = manifest.chunks[0].hash;
1177 let chunk = dedup.get_chunk(&hash).unwrap();
1178 assert_eq!(chunk.hash, hash);
1179 assert_eq!(chunk.length, expected_len);
1180 assert_eq!(chunk.ref_count, 1);
1181 }
1182
1183 #[test]
1184 fn test_get_chunk_not_found() {
1185 let dedup = default_dedup();
1186 let missing = ChunkHash::from(0xDEAD_C0DE_0000_0001u64);
1187 let err = dedup.get_chunk(&missing).unwrap_err();
1188 assert!(matches!(err, DeduplicatorError::ChunkNotFound(_)));
1189 }
1190
1191 #[test]
1192 fn test_dedup_shared_chunks_across_objects() {
1193 let config = ChunkingConfig {
1197 min_chunk_size: 512,
1198 max_chunk_size: 4096,
1199 target_bits: 20,
1200 window_size: 32,
1201 enable_compression: false,
1202 };
1203 let mut dedup = BlockDeduplicator::new(config);
1204 let data = gen_bytes(801, 4096);
1206
1207 dedup
1208 .store_object("obj1".to_string(), data.clone())
1209 .unwrap();
1210 dedup
1211 .store_object("obj2".to_string(), data.clone())
1212 .unwrap();
1213
1214 let manifest1 = dedup.get_manifest("obj1").unwrap().clone();
1216 for cr in &manifest1.chunks {
1217 let chunk = dedup.get_chunk(&cr.hash).unwrap();
1218 assert_eq!(
1219 chunk.ref_count, 2,
1220 "shared chunk {} should have ref_count == 2",
1221 cr.hash
1222 );
1223 }
1224 }
1225
1226 #[test]
1229 fn test_compact_removes_orphans() {
1230 let mut dedup = default_dedup();
1231 let data = gen_bytes(901, 50_000);
1232 dedup.store_object("to_compact".to_string(), data).unwrap();
1233 dedup.delete_object("to_compact").unwrap();
1236 let removed = dedup.compact();
1239 assert_eq!(dedup.chunk_count(), 0);
1241 let _ = removed; }
1243
1244 #[test]
1245 fn test_compact_keeps_referenced() {
1246 let mut dedup = BlockDeduplicator::new(ChunkingConfig {
1247 min_chunk_size: 256,
1248 max_chunk_size: 65536,
1249 target_bits: 20,
1250 window_size: 32,
1251 enable_compression: false,
1252 });
1253 let data = gen_bytes(1001, 1024);
1254 dedup
1255 .store_object("keep_a".to_string(), data.clone())
1256 .unwrap();
1257 dedup
1258 .store_object("keep_b".to_string(), data.clone())
1259 .unwrap();
1260 dedup.delete_object("keep_a").unwrap();
1261
1262 let removed = dedup.compact();
1264 assert_eq!(removed, 0, "no orphans should exist");
1265 assert_eq!(dedup.chunk_count(), 1, "chunk still referenced by keep_b");
1266 }
1267
1268 #[test]
1269 fn test_compact_returns_count() {
1270 let mut dedup = default_dedup();
1271 let data = gen_bytes(1100, 50_000);
1272 let manifest = dedup.store_object("count_test".to_string(), data).unwrap();
1273 let n_chunks = manifest.chunks.len();
1274
1275 for (meta, _) in dedup.chunk_store.values_mut() {
1278 meta.ref_count = 0;
1279 }
1280 dedup.manifests.remove("count_test");
1282
1283 let removed = dedup.compact();
1284 assert_eq!(
1285 removed, n_chunks,
1286 "compact should remove all orphaned chunks"
1287 );
1288 assert_eq!(dedup.chunk_count(), 0);
1289 }
1290
1291 #[test]
1292 fn test_compact_empty_store() {
1293 let mut dedup = default_dedup();
1294 let removed = dedup.compact();
1295 assert_eq!(removed, 0);
1296 }
1297
1298 #[test]
1299 fn test_compact_idempotent() {
1300 let mut dedup = default_dedup();
1301 let data = gen_bytes(1200, 50_000);
1302 dedup.store_object("idem".to_string(), data).unwrap();
1303 dedup.delete_object("idem").unwrap();
1304 let _r1 = dedup.compact();
1305 let r2 = dedup.compact();
1306 assert_eq!(r2, 0, "second compact should remove nothing");
1307 }
1308
1309 #[test]
1310 fn test_compact_after_delete_all() {
1311 let mut dedup = default_dedup();
1312 let d1 = gen_bytes(1301, 60_000);
1313 let d2 = gen_bytes(1302, 60_000);
1314 dedup.store_object("c1".to_string(), d1).unwrap();
1315 dedup.store_object("c2".to_string(), d2).unwrap();
1316 dedup.delete_object("c1").unwrap();
1317 dedup.delete_object("c2").unwrap();
1318 dedup.compact();
1319 assert_eq!(dedup.chunk_count(), 0, "all chunks should be gone");
1320 assert_eq!(dedup.object_count(), 0);
1321 }
1322
1323 #[test]
1326 fn test_error_object_not_found_display() {
1327 let err = DeduplicatorError::ObjectNotFound("my-special-object".to_string());
1328 let msg = format!("{}", err);
1329 assert!(
1330 msg.contains("my-special-object"),
1331 "error message must contain object id: {}",
1332 msg
1333 );
1334 }
1335
1336 #[test]
1337 fn test_error_chunk_not_found_display() {
1338 let hash = ChunkHash::from(0xABCD_EF01_2345_6789u64);
1339 let err = DeduplicatorError::ChunkNotFound(hash);
1340 let msg = format!("{}", err);
1341 assert!(!msg.is_empty(), "error message must not be empty");
1343 }
1344
1345 #[test]
1346 fn test_chunk_hash_equality() {
1347 let b = [1u8, 2, 3, 4, 5, 6, 7, 8];
1348 let h1 = ChunkHash::from_bytes(b);
1349 let h2 = ChunkHash::from_bytes(b);
1350 assert_eq!(h1, h2);
1351 }
1352
1353 #[test]
1354 fn test_deduplicator_list_objects_empty() {
1355 let dedup = default_dedup();
1356 let objs = dedup.list_objects();
1357 assert!(objs.is_empty());
1358 }
1359
1360 #[test]
1361 fn test_deduplicator_list_objects_sorted() {
1362 let mut dedup = default_dedup();
1363 let ids = ["zebra", "alpha", "mango", "beta"];
1364 for id in &ids {
1365 dedup
1366 .store_object((*id).to_string(), gen_bytes(42, 100))
1367 .unwrap();
1368 }
1369 let listed = dedup.list_objects();
1370 let mut expected: Vec<String> = ids.iter().map(|s| s.to_string()).collect();
1371 expected.sort();
1372 assert_eq!(listed, expected);
1373 }
1374
1375 #[test]
1378 fn test_large_object_chunked_correctly() {
1379 let mut dedup = default_dedup();
1380 let data = gen_bytes(9999, 1_000_000); dedup
1382 .store_object("mega".to_string(), data.clone())
1383 .unwrap();
1384 let retrieved = dedup.retrieve_object("mega").unwrap();
1385 assert_eq!(data, retrieved, "1 MB round-trip failed");
1386 }
1387
1388 #[test]
1389 fn test_many_small_objects() {
1390 let mut dedup = default_dedup();
1391 let mut originals: Vec<Vec<u8>> = Vec::new();
1392 let mut seed = 12345u64;
1393 for i in 0..100 {
1394 let data = gen_bytes(seed, 500 + i * 7);
1395 seed = seed.wrapping_add(1);
1396 dedup
1397 .store_object(format!("small_{}", i), data.clone())
1398 .unwrap();
1399 originals.push(data);
1400 }
1401 for (i, original) in originals.iter().enumerate() {
1402 let retrieved = dedup.retrieve_object(&format!("small_{}", i)).unwrap();
1403 assert_eq!(*original, retrieved, "small object {} failed round-trip", i);
1404 }
1405 }
1406
1407 #[test]
1408 fn test_config_default_values() {
1409 let cfg = ChunkingConfig::default();
1410 assert_eq!(cfg.min_chunk_size, 2048);
1411 assert_eq!(cfg.max_chunk_size, 65536);
1412 assert_eq!(cfg.target_bits, 13);
1413 assert_eq!(cfg.window_size, 48);
1414 assert!(!cfg.enable_compression);
1415 }
1416
1417 #[test]
1418 fn test_deduplicator_new_starts_empty() {
1419 let dedup = default_dedup();
1420 assert_eq!(dedup.chunk_count(), 0);
1421 assert_eq!(dedup.object_count(), 0);
1422 assert!(dedup.list_objects().is_empty());
1423 let stats = dedup.stats();
1424 assert_eq!(stats.total_chunks, 0);
1425 assert_eq!(stats.bytes_before, 0);
1426 }
1427
1428 #[test]
1429 fn test_full_dedup_workflow() {
1430 let mut dedup = default_dedup();
1431
1432 let shared = gen_bytes(5555, 200_000);
1434 let extra1 = gen_bytes(6001, 50_000);
1435 let extra2 = gen_bytes(6002, 50_000);
1436
1437 let mut obj_a = shared.clone();
1438 obj_a.extend_from_slice(&extra1);
1439 let mut obj_b = shared.clone();
1440 obj_b.extend_from_slice(&extra2);
1441
1442 let m_a = dedup
1443 .store_object("workflow_a".to_string(), obj_a.clone())
1444 .unwrap();
1445 let m_b = dedup
1446 .store_object("workflow_b".to_string(), obj_b.clone())
1447 .unwrap();
1448
1449 assert_eq!(dedup.retrieve_object("workflow_a").unwrap(), obj_a);
1451 assert_eq!(dedup.retrieve_object("workflow_b").unwrap(), obj_b);
1452
1453 let listed = dedup.list_objects();
1455 assert!(listed.contains(&"workflow_a".to_string()));
1456 assert!(listed.contains(&"workflow_b".to_string()));
1457
1458 let stats = dedup.stats();
1460 assert_eq!(stats.bytes_before, (obj_a.len() + obj_b.len()) as u64);
1461
1462 dedup.delete_object("workflow_a").unwrap();
1464 assert!(dedup
1465 .retrieve_object("workflow_a")
1466 .unwrap_err()
1467 .to_string()
1468 .contains("workflow_a"));
1469
1470 assert_eq!(dedup.retrieve_object("workflow_b").unwrap(), obj_b);
1472
1473 dedup.compact();
1475
1476 let n_chunks_b = m_b.chunks.len();
1478 assert!(dedup.chunk_count() > 0, "workflow_b chunks still present");
1479 assert!(dedup.chunk_count() <= m_a.chunks.len() + n_chunks_b);
1481
1482 dedup.delete_object("workflow_b").unwrap();
1484 dedup.compact();
1485 assert_eq!(dedup.chunk_count(), 0);
1486 assert_eq!(dedup.object_count(), 0);
1487 }
1488
1489 #[test]
1490 fn test_chunk_data_chunk_hashes_are_content_based() {
1491 let dedup = default_dedup();
1492 let d1 = vec![0u8; 100];
1493 let d2 = vec![1u8; 100];
1494 let c1 = dedup.chunk_data(&d1);
1495 let c2 = dedup.chunk_data(&d2);
1496 assert_ne!(
1497 c1[0].0, c2[0].0,
1498 "different content must produce different hashes"
1499 );
1500 }
1501
1502 #[test]
1503 fn test_boundary_mask_calculation() {
1504 let config = ChunkingConfig {
1505 target_bits: 13,
1506 ..ChunkingConfig::default()
1507 };
1508 let mask = config.boundary_mask();
1509 assert_eq!(mask, (1u64 << 13) - 1);
1510 assert_eq!(mask, 8191);
1511 }
1512
1513 #[test]
1514 fn test_store_single_byte() {
1515 let mut dedup = default_dedup();
1516 let data = vec![42u8];
1517 let manifest = dedup
1518 .store_object("single_byte".to_string(), data.clone())
1519 .unwrap();
1520 assert_eq!(manifest.total_size, 1);
1521 assert_eq!(manifest.chunks.len(), 1);
1522 let retrieved = dedup.retrieve_object("single_byte").unwrap();
1523 assert_eq!(retrieved, data);
1524 }
1525
1526 #[test]
1527 fn test_stats_compression_ratio_at_least_one() {
1528 let mut dedup = default_dedup();
1529 let data = gen_bytes(7777, 100_000);
1530 dedup.store_object("cr_test".to_string(), data).unwrap();
1531 let stats = dedup.stats();
1532 assert!(
1534 (stats.compression_ratio - 1.0).abs() < 1e-6,
1535 "expected ratio ~1.0, got {}",
1536 stats.compression_ratio
1537 );
1538 }
1539
1540 #[test]
1541 fn test_chunk_length_matches_metadata() {
1542 let mut dedup = default_dedup();
1543 let data = gen_bytes(8888, 200_000);
1544 let manifest = dedup.store_object("len_check".to_string(), data).unwrap();
1545 for cr in &manifest.chunks {
1546 let chunk_meta = dedup.get_chunk(&cr.hash).unwrap();
1547 assert_eq!(
1548 chunk_meta.length, cr.chunk_length,
1549 "chunk metadata length must match ChunkRef length"
1550 );
1551 }
1552 }
1553
1554 #[test]
1555 fn test_object_count_updates_correctly() {
1556 let mut dedup = default_dedup();
1557 assert_eq!(dedup.object_count(), 0);
1558 dedup
1559 .store_object("oc1".to_string(), gen_bytes(1, 1000))
1560 .unwrap();
1561 assert_eq!(dedup.object_count(), 1);
1562 dedup
1563 .store_object("oc2".to_string(), gen_bytes(2, 1000))
1564 .unwrap();
1565 assert_eq!(dedup.object_count(), 2);
1566 dedup.delete_object("oc1").unwrap();
1567 assert_eq!(dedup.object_count(), 1);
1568 dedup.delete_object("oc2").unwrap();
1569 assert_eq!(dedup.object_count(), 0);
1570 }
1571}