1use std::collections::HashMap;
7use std::io::{Read, Write};
8use std::path::{Path, PathBuf};
9
10use crate::error::{Result, VaultError};
11use crate::formats::{ModelFormat, ModelMetadata};
12
13fn safe_archive_name(raw: &str) -> Result<String> {
25 use std::path::Component;
26
27 let mut components = Path::new(raw).components();
28 match (components.next(), components.next()) {
29 (Some(Component::Normal(name)), None) => Ok(name.to_string_lossy().into_owned()),
30 _ => Err(VaultError::InvalidInput(format!(
31 "Refusing to extract archive member {raw:?}: member names must be a \
32 single file name, with no directory separators, parent references, \
33 or drive/root prefix"
34 ))),
35 }
36}
37
38pub const MAX_ARCHIVE_MEMBER_BYTES: u64 = 8 * 1024 * 1024 * 1024;
50
51pub const MAX_ARCHIVE_TOTAL_BYTES: u64 = 16 * 1024 * 1024 * 1024;
56
57fn check_archive_budget(name: &str, declared: u64, running_total: u64) -> Result<()> {
60 if declared > MAX_ARCHIVE_MEMBER_BYTES {
61 return Err(VaultError::InvalidInput(format!(
62 "Refusing to extract archive member {name:?}: declared size {declared} bytes \
63 exceeds the {MAX_ARCHIVE_MEMBER_BYTES}-byte per-member limit"
64 )));
65 }
66 if running_total.saturating_add(declared) > MAX_ARCHIVE_TOTAL_BYTES {
67 return Err(VaultError::InvalidInput(format!(
68 "Refusing to extract archive member {name:?}: archive expands past the \
69 {MAX_ARCHIVE_TOTAL_BYTES}-byte total limit"
70 )));
71 }
72 Ok(())
73}
74
75fn read_member_bounded<R: Read>(name: &str, reader: &mut R, limit: u64) -> Result<Vec<u8>> {
87 let mut data = Vec::new();
88 let read = reader.take(limit + 1).read_to_end(&mut data)?;
89
90 if read as u64 > limit {
91 return Err(VaultError::InvalidInput(format!(
92 "Refusing to extract archive member {name:?}: actual contents exceed the \
93 {limit}-byte per-member limit (the declared size understated it)"
94 )));
95 }
96
97 Ok(data)
98}
99
100pub struct ModelArchive;
102
103impl ModelArchive {
104 pub fn create_tar(models: Vec<(String, Vec<u8>)>, output_path: &Path) -> Result<usize> {
106 let file = std::fs::File::create(output_path)?;
107 let mut ar = tar::Builder::new(file);
108
109 let mut total_size = 0;
110
111 for (name, data) in models {
112 let mut header = tar::Header::new_gnu();
113 header.set_size(data.len() as u64);
114 header.set_mode(0o644);
115 header.set_cksum();
116
117 ar.append_data(&mut header, &name, &data[..])?;
118 total_size += data.len();
119 }
120
121 ar.finish()?;
122 Ok(total_size)
123 }
124
125 pub fn extract_tar(archive_path: &Path) -> Result<Vec<(String, Vec<u8>)>> {
127 let file = std::fs::File::open(archive_path)?;
128 let mut ar = tar::Archive::new(file);
129
130 let mut models = Vec::new();
131 let mut total: u64 = 0;
132
133 for entry in ar.entries()? {
134 let mut entry = entry?;
135 let path = entry.path()?;
136 let name = safe_archive_name(&path.to_string_lossy())?;
141
142 check_archive_budget(&name, entry.size(), total)?;
143 let data = read_member_bounded(&name, &mut entry, MAX_ARCHIVE_MEMBER_BYTES)?;
144 total = total.saturating_add(data.len() as u64);
145
146 models.push((name, data));
147 }
148
149 Ok(models)
150 }
151
152 pub fn create_zip(models: Vec<(String, Vec<u8>)>, output_path: &Path) -> Result<usize> {
154 let file = std::fs::File::create(output_path)?;
155 let mut zip = zip::ZipWriter::new(file);
156
157 let mut total_size = 0;
158
159 for (name, data) in models {
160 let options = zip::write::SimpleFileOptions::default()
161 .compression_method(zip::CompressionMethod::Deflated)
162 .unix_permissions(0o644);
163
164 zip.start_file(&name, options)?;
165 zip.write_all(&data)?;
166 total_size += data.len();
167 }
168
169 zip.finish()?;
170 Ok(total_size)
171 }
172
173 pub fn extract_zip(archive_path: &Path) -> Result<Vec<(String, Vec<u8>)>> {
175 let file = std::fs::File::open(archive_path)?;
176 let mut zip = zip::ZipArchive::new(file)?;
177
178 let mut models = Vec::new();
179 let mut total: u64 = 0;
180
181 for i in 0..zip.len() {
182 let mut file = zip.by_index(i)?;
183 let name = safe_archive_name(file.name())?;
187
188 check_archive_budget(&name, file.size(), total)?;
189 let data = read_member_bounded(&name, &mut file, MAX_ARCHIVE_MEMBER_BYTES)?;
190 total = total.saturating_add(data.len() as u64);
191
192 models.push((name, data));
193 }
194
195 Ok(models)
196 }
197}
198
199pub struct CompressionAnalyzer;
201
202impl CompressionAnalyzer {
203 #[must_use]
205 pub fn compression_ratio(original_size: u64, compressed_size: u64) -> f64 {
206 if compressed_size == 0 {
207 return 0.0;
208 }
209 original_size as f64 / compressed_size as f64
210 }
211
212 #[must_use]
214 pub fn estimate_ratio(format: &ModelFormat) -> f64 {
215 match format {
216 ModelFormat::Safetensors => 1.05, ModelFormat::GGUF => 1.0, ModelFormat::PyTorch => 1.3, ModelFormat::ONNX => 1.2,
220 ModelFormat::TensorRT => 1.0, ModelFormat::TFLite => 1.1,
222 ModelFormat::HDF5 => 1.4, ModelFormat::Pickle => 1.5, _ => 1.2, }
226 }
227
228 pub fn analyze_compression(
230 original_size: u64,
231 compressed_size: u64,
232 format: &ModelFormat,
233 ) -> CompressionReport {
234 let actual_ratio = Self::compression_ratio(original_size, compressed_size);
235 let estimated_ratio = Self::estimate_ratio(format);
236 let space_saved = original_size.saturating_sub(compressed_size);
237 let space_saved_percent = (space_saved as f64 / original_size as f64) * 100.0;
238
239 CompressionReport {
240 original_size,
241 compressed_size,
242 space_saved,
243 compression_ratio: actual_ratio,
244 estimated_ratio,
245 space_saved_percent,
246 efficiency: actual_ratio / estimated_ratio,
247 }
248 }
249}
250
251#[derive(Debug, Clone)]
253pub struct CompressionReport {
254 pub original_size: u64,
255 pub compressed_size: u64,
256 pub space_saved: u64,
257 pub compression_ratio: f64,
258 pub estimated_ratio: f64,
259 pub space_saved_percent: f64,
260 pub efficiency: f64,
261}
262
263pub struct RetrievalOptimizer {
265 cache: HashMap<String, CachedModel>,
266 max_cache_size: usize,
267 current_cache_size: usize,
268 next_seq: u64,
270}
271
272#[derive(Debug, Clone)]
273struct CachedModel {
274 data: Vec<u8>,
275 access_count: usize,
276 last_access: std::time::SystemTime,
277 seq: u64,
284}
285
286impl RetrievalOptimizer {
287 pub fn new(max_cache_size: usize) -> Self {
289 Self {
290 cache: HashMap::new(),
291 max_cache_size,
292 current_cache_size: 0,
293 next_seq: 0,
294 }
295 }
296
297 fn bump_seq(&mut self) -> u64 {
299 let seq = self.next_seq;
300 self.next_seq += 1;
301 seq
302 }
303
304 pub fn cache_model(&mut self, key: String, data: Vec<u8>) -> Result<()> {
306 let data_size = data.len();
307
308 while self.current_cache_size + data_size > self.max_cache_size && !self.cache.is_empty() {
310 self.evict_lru();
311 }
312
313 if data_size > self.max_cache_size {
315 return Ok(());
316 }
317
318 let seq = self.bump_seq();
319 self.current_cache_size += data_size;
320 self.cache.insert(
321 key,
322 CachedModel {
323 data,
324 access_count: 1,
325 last_access: std::time::SystemTime::now(),
326 seq,
327 },
328 );
329
330 Ok(())
331 }
332
333 pub fn get_cached(&mut self, key: &str) -> Option<Vec<u8>> {
335 let seq = self.bump_seq();
336 if let Some(cached) = self.cache.get_mut(key) {
337 cached.access_count += 1;
338 cached.last_access = std::time::SystemTime::now();
339 cached.seq = seq;
340 Some(cached.data.clone())
341 } else {
342 None
343 }
344 }
345
346 fn evict_lru(&mut self) {
350 if let Some(key) = self
351 .cache
352 .iter()
353 .min_by_key(|(_, v)| v.seq)
354 .map(|(k, _)| k.clone())
355 {
356 if let Some(cached) = self.cache.remove(&key) {
357 self.current_cache_size -= cached.data.len();
358 }
359 }
360 }
361
362 pub fn clear_cache(&mut self) {
364 self.cache.clear();
365 self.current_cache_size = 0;
366 }
367
368 pub fn cache_stats(&self) -> CacheStats {
370 CacheStats {
371 total_entries: self.cache.len(),
372 total_size: self.current_cache_size,
373 max_size: self.max_cache_size,
374 utilization: (self.current_cache_size as f64 / self.max_cache_size as f64) * 100.0,
375 }
376 }
377}
378
379#[derive(Debug, Clone)]
381pub struct CacheStats {
382 pub total_entries: usize,
383 pub total_size: usize,
384 pub max_size: usize,
385 pub utilization: f64,
386}
387
388pub struct QuantizationInfo;
390
391impl QuantizationInfo {
392 pub fn schemes() -> Vec<&'static str> {
394 vec![
395 "FP32", "FP16", "BF16", "INT8", "INT4", "Q8_0", "Q4_0", "Q4_K_M", "Q5_K_M", "Q6_K", ]
406 }
407
408 #[must_use]
410 pub fn estimate_size(original_size: u64, from_bits: u8, to_bits: u8) -> u64 {
411 if from_bits == 0 || to_bits == 0 {
412 return original_size;
413 }
414 (original_size as f64 * (to_bits as f64 / from_bits as f64)) as u64
415 }
416
417 #[must_use]
419 pub fn memory_savings(original_size: u64, quantized_size: u64) -> QuantizationSavings {
420 let saved_bytes = original_size.saturating_sub(quantized_size);
421 let saved_percent = (saved_bytes as f64 / original_size as f64) * 100.0;
422
423 QuantizationSavings {
424 original_size,
425 quantized_size,
426 saved_bytes,
427 saved_percent,
428 size_ratio: original_size as f64 / quantized_size as f64,
429 }
430 }
431
432 pub fn is_valid_scheme(scheme: &str) -> bool {
434 Self::schemes().contains(&scheme)
435 }
436}
437
438#[derive(Debug, Clone)]
440pub struct QuantizationSavings {
441 pub original_size: u64,
442 pub quantized_size: u64,
443 pub saved_bytes: u64,
444 pub saved_percent: f64,
445 pub size_ratio: f64,
446}
447
448pub struct PruningInfo {
450 pub pruning_method: PruningMethod,
451 pub sparsity_level: f64,
452 pub original_params: u64,
453 pub remaining_params: u64,
454}
455
456#[derive(Debug, Clone, PartialEq)]
457pub enum PruningMethod {
458 Magnitude,
459 Structured,
460 Unstructured,
461 GradientBased,
462 LayerWise,
463 Custom(String),
464}
465
466impl PruningInfo {
467 pub fn new(method: PruningMethod, sparsity: f64, original: u64, remaining: u64) -> Self {
469 Self {
470 pruning_method: method,
471 sparsity_level: sparsity.clamp(0.0, 1.0),
472 original_params: original,
473 remaining_params: remaining,
474 }
475 }
476
477 #[must_use]
479 pub fn calculate_sparsity(&self) -> f64 {
480 if self.original_params == 0 {
481 return 0.0;
482 }
483 1.0 - (self.remaining_params as f64 / self.original_params as f64)
484 }
485
486 #[must_use]
488 pub fn size_reduction(&self) -> f64 {
489 self.calculate_sparsity() * 100.0
490 }
491}
492
493pub struct ModelAnalyzer;
495
496impl ModelAnalyzer {
497 pub fn analyze(data: &[u8], metadata: &ModelMetadata) -> ModelAnalysis {
499 let size_bytes = data.len() as u64;
500 let size_mb = size_bytes as f64 / (1024.0 * 1024.0);
501 let size_gb = size_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
502
503 let estimated_params = Self::estimate_parameters(size_bytes, &metadata.format);
505
506 ModelAnalysis {
507 format: metadata.format.clone(),
508 size_bytes,
509 size_mb,
510 size_gb,
511 estimated_parameters: estimated_params,
512 framework: metadata.framework.clone(),
513 task: metadata.task.clone(),
514 architecture: metadata.architecture.clone(),
515 }
516 }
517
518 fn estimate_parameters(size_bytes: u64, format: &ModelFormat) -> Option<u64> {
520 let base_estimate = size_bytes / 4;
522
523 match format {
524 ModelFormat::GGUF => Some(base_estimate * 2), ModelFormat::Safetensors | ModelFormat::PyTorch => Some(base_estimate),
526 ModelFormat::ONNX => Some((base_estimate as f64 * 0.9) as u64), ModelFormat::TFLite => Some(base_estimate * 4), _ => Some(base_estimate),
529 }
530 }
531
532 #[must_use]
534 pub fn format_size(bytes: u64) -> String {
535 const KB: u64 = 1024;
536 const MB: u64 = KB * 1024;
537 const GB: u64 = MB * 1024;
538 const TB: u64 = GB * 1024;
539
540 if bytes >= TB {
541 format!("{:.2} TB", bytes as f64 / TB as f64)
542 } else if bytes >= GB {
543 format!("{:.2} GB", bytes as f64 / GB as f64)
544 } else if bytes >= MB {
545 format!("{:.2} MB", bytes as f64 / MB as f64)
546 } else if bytes >= KB {
547 format!("{:.2} KB", bytes as f64 / KB as f64)
548 } else {
549 format!("{} B", bytes)
550 }
551 }
552
553 #[must_use]
555 pub fn format_parameters(params: u64) -> String {
556 const K: u64 = 1_000;
557 const M: u64 = K * 1_000;
558 const B: u64 = M * 1_000;
559
560 if params >= B {
561 format!("{:.2}B", params as f64 / B as f64)
562 } else if params >= M {
563 format!("{:.2}M", params as f64 / M as f64)
564 } else if params >= K {
565 format!("{:.2}K", params as f64 / K as f64)
566 } else {
567 format!("{}", params)
568 }
569 }
570}
571
572#[derive(Debug, Clone)]
574pub struct ModelAnalysis {
575 pub format: ModelFormat,
576 pub size_bytes: u64,
577 pub size_mb: f64,
578 pub size_gb: f64,
579 pub estimated_parameters: Option<u64>,
580 pub framework: Option<String>,
581 pub task: Option<String>,
582 pub architecture: Option<String>,
583}
584
585pub struct ModelExporter;
587
588impl ModelExporter {
589 pub fn export_with_metadata(
591 model_data: Vec<u8>,
592 metadata: &ModelMetadata,
593 output_dir: &Path,
594 ) -> Result<PathBuf> {
595 std::fs::create_dir_all(output_dir)?;
596
597 let model_filename = format!("{}.{}", metadata.name, metadata.format.extension());
599 let model_path = output_dir.join(&model_filename);
600 std::fs::write(&model_path, model_data)?;
601
602 let metadata_filename = format!("{}.meta.json", metadata.name);
604 let metadata_path = output_dir.join(&metadata_filename);
605 let metadata_json = serde_json::to_string_pretty(&metadata)?;
606 std::fs::write(metadata_path, metadata_json)?;
607
608 Ok(model_path)
609 }
610
611 pub fn export_to_directory(
613 models: Vec<(Vec<u8>, ModelMetadata)>,
614 output_dir: &Path,
615 ) -> Result<Vec<PathBuf>> {
616 std::fs::create_dir_all(output_dir)?;
617
618 let mut paths = Vec::new();
619
620 for (data, metadata) in models {
621 let path = Self::export_with_metadata(data, &metadata, output_dir)?;
622 paths.push(path);
623 }
624
625 Ok(paths)
626 }
627}
628
629pub struct ModelDeduplicator;
631
632impl ModelDeduplicator {
633 #[must_use]
635 pub fn calculate_hash(data: &[u8]) -> String {
636 use sha2::{Digest, Sha256};
637 let mut hasher = Sha256::new();
638 hasher.update(data);
639 hex::encode(hasher.finalize())
640 }
641
642 pub fn find_duplicates(models: Vec<(String, Vec<u8>)>) -> HashMap<String, Vec<String>> {
644 let mut hash_map: HashMap<String, Vec<String>> = HashMap::new();
645
646 for (name, data) in models {
647 let hash = Self::calculate_hash(&data);
648 hash_map.entry(hash).or_default().push(name);
649 }
650
651 hash_map
653 .into_iter()
654 .filter(|(_, names)| names.len() > 1)
655 .collect()
656 }
657
658 #[must_use]
660 pub fn similarity_score(data1: &[u8], data2: &[u8]) -> f64 {
661 if data1.len() != data2.len() {
662 return 0.0;
663 }
664
665 let matching_bytes = data1
666 .iter()
667 .zip(data2.iter())
668 .filter(|(a, b)| a == b)
669 .count();
670
671 (matching_bytes as f64 / data1.len() as f64) * 100.0
672 }
673}
674
675#[cfg(test)]
676#[allow(clippy::float_cmp)]
679mod tests {
680 use super::*;
681
682 #[test]
683 fn test_compression_ratio() {
684 let ratio = CompressionAnalyzer::compression_ratio(1000, 500);
685 assert_eq!(ratio, 2.0);
686
687 let ratio = CompressionAnalyzer::compression_ratio(1000, 250);
688 assert_eq!(ratio, 4.0);
689 }
690
691 #[test]
692 fn test_compression_analysis() {
693 let report = CompressionAnalyzer::analyze_compression(1000, 750, &ModelFormat::PyTorch);
694
695 assert_eq!(report.original_size, 1000);
696 assert_eq!(report.compressed_size, 750);
697 assert_eq!(report.space_saved, 250);
698 assert_eq!(report.space_saved_percent, 25.0);
699 }
700
701 #[test]
702 fn test_quantization_size_estimate() {
703 let size = QuantizationInfo::estimate_size(1000, 32, 16);
704 assert_eq!(size, 500);
705
706 let size = QuantizationInfo::estimate_size(1000, 32, 8);
707 assert_eq!(size, 250);
708 }
709
710 #[test]
711 fn test_quantization_savings() {
712 let savings = QuantizationInfo::memory_savings(1000, 250);
713 assert_eq!(savings.saved_bytes, 750);
714 assert_eq!(savings.saved_percent, 75.0);
715 assert_eq!(savings.size_ratio, 4.0);
716 }
717
718 #[test]
719 fn test_pruning_info() {
720 let info = PruningInfo::new(PruningMethod::Magnitude, 0.5, 1000, 500);
721 assert_eq!(info.calculate_sparsity(), 0.5);
722 assert_eq!(info.size_reduction(), 50.0);
723 }
724
725 #[test]
726 fn test_retrieval_optimizer() {
727 let mut optimizer = RetrievalOptimizer::new(1000);
728
729 optimizer
731 .cache_model("model1".to_string(), vec![0; 100])
732 .unwrap();
733 assert_eq!(optimizer.current_cache_size, 100);
734
735 let cached = optimizer.get_cached("model1");
737 assert!(cached.is_some());
738
739 optimizer
741 .cache_model("model2".to_string(), vec![0; 100])
742 .unwrap();
743 assert_eq!(optimizer.current_cache_size, 200);
744 }
745
746 #[test]
747 fn test_model_analyzer_format_size() {
748 assert_eq!(ModelAnalyzer::format_size(500), "500 B");
749 assert_eq!(ModelAnalyzer::format_size(1024), "1.00 KB");
750 assert_eq!(ModelAnalyzer::format_size(1024 * 1024), "1.00 MB");
751 assert_eq!(ModelAnalyzer::format_size(1024 * 1024 * 1024), "1.00 GB");
752 }
753
754 #[test]
755 fn test_model_analyzer_format_parameters() {
756 assert_eq!(ModelAnalyzer::format_parameters(500), "500");
757 assert_eq!(ModelAnalyzer::format_parameters(1_500), "1.50K");
758 assert_eq!(ModelAnalyzer::format_parameters(1_500_000), "1.50M");
759 assert_eq!(ModelAnalyzer::format_parameters(7_000_000_000), "7.00B");
760 }
761
762 #[test]
763 fn test_deduplicator_hash() {
764 let data1 = b"test data";
765 let hash1 = ModelDeduplicator::calculate_hash(data1);
766 let hash2 = ModelDeduplicator::calculate_hash(data1);
767
768 assert_eq!(hash1, hash2);
769 assert_eq!(hash1.len(), 64); }
771
772 #[test]
773 fn test_deduplicator_similarity() {
774 let data1 = b"test data";
775 let data2 = b"test data";
776 let data3 = b"different";
777
778 let score1 = ModelDeduplicator::similarity_score(data1, data2);
779 assert_eq!(score1, 100.0);
780
781 let score2 = ModelDeduplicator::similarity_score(data1, data3);
782 assert_eq!(score2, 0.0); }
784
785 #[test]
786 fn test_create_and_extract_tar() {
787 let temp_dir = tempfile::tempdir().unwrap();
789 let tar_path = temp_dir.path().join("test.tar");
790
791 let models = vec![
792 ("model_a.bin".to_string(), vec![1, 2, 3, 4]),
793 ("model_b.bin".to_string(), vec![5, 6, 7, 8, 9]),
794 ];
795
796 let total = ModelArchive::create_tar(models, &tar_path).unwrap();
797 assert_eq!(total, 9); let extracted = ModelArchive::extract_tar(&tar_path).unwrap();
800 assert_eq!(extracted.len(), 2);
801 assert_eq!(extracted[0].1, vec![1, 2, 3, 4]);
802 }
803
804 #[test]
805 fn test_create_and_extract_zip() {
806 let temp_dir = tempfile::tempdir().unwrap();
808 let zip_path = temp_dir.path().join("test.zip");
809
810 let models = vec![
811 ("model_x.bin".to_string(), vec![10, 20, 30]),
812 ("model_y.bin".to_string(), vec![40, 50]),
813 ];
814
815 let total = ModelArchive::create_zip(models, &zip_path).unwrap();
816 assert_eq!(total, 5);
817
818 let extracted = ModelArchive::extract_zip(&zip_path).unwrap();
819 assert_eq!(extracted.len(), 2);
820 }
821
822 #[test]
823 fn test_model_analyzer_analyze() {
824 let data = vec![0u8; 1024 * 1024]; let meta = ModelMetadata::new("test_model".to_string(), ModelFormat::PyTorch);
827 let analysis = ModelAnalyzer::analyze(&data, &meta);
828
829 assert_eq!(analysis.size_bytes, 1024 * 1024);
830 assert!((analysis.size_mb - 1.0).abs() < 0.01);
831 assert!(analysis.estimated_parameters.unwrap() > 0);
832 }
833
834 #[test]
835 fn test_compression_ratio_zero() {
836 let ratio = CompressionAnalyzer::compression_ratio(1000, 0);
837 assert_eq!(ratio, 0.0);
838 }
839
840 #[test]
841 fn test_estimate_ratio_all_formats() {
842 assert!(CompressionAnalyzer::estimate_ratio(&ModelFormat::Safetensors) > 1.0);
843 assert_eq!(CompressionAnalyzer::estimate_ratio(&ModelFormat::GGUF), 1.0);
844 assert!(CompressionAnalyzer::estimate_ratio(&ModelFormat::PyTorch) > 1.0);
845 assert!(CompressionAnalyzer::estimate_ratio(&ModelFormat::ONNX) > 1.0);
846 assert_eq!(
847 CompressionAnalyzer::estimate_ratio(&ModelFormat::TensorRT),
848 1.0
849 );
850 assert!(CompressionAnalyzer::estimate_ratio(&ModelFormat::TFLite) >= 1.0);
851 assert!(CompressionAnalyzer::estimate_ratio(&ModelFormat::HDF5) > 1.0);
852 assert!(CompressionAnalyzer::estimate_ratio(&ModelFormat::Pickle) > 1.0);
853 assert!(CompressionAnalyzer::estimate_ratio(&ModelFormat::CoreML) > 0.0);
855 }
856
857 #[test]
858 fn test_retrieval_optimizer_eviction() {
859 let mut opt = RetrievalOptimizer::new(100);
860 opt.cache_model("m1".to_string(), vec![0; 60]).unwrap();
861 opt.cache_model("m2".to_string(), vec![0; 60]).unwrap();
862 assert!(opt.get_cached("m1").is_none());
864 assert!(opt.get_cached("m2").is_some());
865 }
866
867 #[test]
868 fn test_retrieval_optimizer_oversized() {
869 let mut opt = RetrievalOptimizer::new(10);
870 opt.cache_model("big".to_string(), vec![0; 100]).unwrap();
871 assert!(opt.get_cached("big").is_none());
872 }
873
874 #[test]
875 fn test_retrieval_optimizer_clear() {
876 let mut opt = RetrievalOptimizer::new(1000);
877 opt.cache_model("m1".to_string(), vec![1]).unwrap();
878 opt.clear_cache();
879 assert!(opt.get_cached("m1").is_none());
880 let stats = opt.cache_stats();
881 assert_eq!(stats.total_entries, 0);
882 assert_eq!(stats.total_size, 0);
883 }
884
885 #[test]
886 fn test_retrieval_optimizer_stats() {
887 let mut opt = RetrievalOptimizer::new(1000);
888 opt.cache_model("m1".to_string(), vec![0; 100]).unwrap();
889 let stats = opt.cache_stats();
890 assert_eq!(stats.total_entries, 1);
891 assert_eq!(stats.total_size, 100);
892 assert_eq!(stats.max_size, 1000);
893 assert!((stats.utilization - 10.0).abs() < 0.01);
894 }
895
896 #[test]
897 fn test_quantization_schemes() {
898 let schemes = QuantizationInfo::schemes();
899 assert!(schemes.contains(&"FP32"));
900 assert!(schemes.contains(&"Q4_K_M"));
901 assert!(!schemes.is_empty());
902 }
903
904 #[test]
905 fn test_quantization_estimate_size_zero_bits() {
906 let size = QuantizationInfo::estimate_size(1000, 0, 8);
907 assert_eq!(size, 1000);
908 let size2 = QuantizationInfo::estimate_size(1000, 32, 0);
909 assert_eq!(size2, 1000);
910 }
911
912 #[test]
913 fn test_quantization_is_valid_scheme() {
914 assert!(QuantizationInfo::is_valid_scheme("FP32"));
915 assert!(QuantizationInfo::is_valid_scheme("Q4_K_M"));
916 assert!(!QuantizationInfo::is_valid_scheme("INVALID"));
917 }
918
919 #[test]
920 fn test_pruning_zero_params() {
921 let info = PruningInfo::new(PruningMethod::Magnitude, 0.5, 0, 0);
922 assert_eq!(info.calculate_sparsity(), 0.0);
923 assert_eq!(info.size_reduction(), 0.0);
924 }
925
926 #[test]
927 fn test_pruning_methods() {
928 assert_eq!(PruningMethod::Magnitude, PruningMethod::Magnitude);
929 assert_ne!(PruningMethod::Structured, PruningMethod::Unstructured);
930 assert_ne!(PruningMethod::GradientBased, PruningMethod::LayerWise);
932 assert_eq!(
933 PruningMethod::Custom("test".into()),
934 PruningMethod::Custom("test".into())
935 );
936 }
937
938 #[test]
939 fn test_model_analyzer_format_size_tb() {
940 let tb = 1024u64 * 1024 * 1024 * 1024;
941 assert!(ModelAnalyzer::format_size(tb).contains("TB"));
942 }
943
944 #[test]
945 fn test_model_analyzer_estimate_params_formats() {
946 let data = vec![0u8; 4000]; for fmt in &[
949 ModelFormat::GGUF,
950 ModelFormat::ONNX,
951 ModelFormat::TFLite,
952 ModelFormat::Safetensors,
953 ] {
954 let meta = ModelMetadata::new("m".to_string(), fmt.clone());
955 let analysis = ModelAnalyzer::analyze(&data, &meta);
956 assert!(analysis.estimated_parameters.is_some());
957 }
958 }
959
960 #[test]
961 fn test_model_exporter_export_with_metadata() {
962 let temp_dir = tempfile::tempdir().unwrap();
963 let meta = ModelMetadata::new("exported".to_string(), ModelFormat::PyTorch);
964 let data = vec![1, 2, 3, 4];
965 let path = ModelExporter::export_with_metadata(data, &meta, temp_dir.path()).unwrap();
966 assert!(path.exists());
967 assert!(temp_dir.path().join("exported.meta.json").exists());
968 }
969
970 #[test]
971 fn test_model_exporter_export_to_directory() {
972 let temp_dir = tempfile::tempdir().unwrap();
973 let models = vec![
974 (
975 vec![1, 2],
976 ModelMetadata::new("a".to_string(), ModelFormat::ONNX),
977 ),
978 (
979 vec![3, 4],
980 ModelMetadata::new("b".to_string(), ModelFormat::PyTorch),
981 ),
982 ];
983 let paths = ModelExporter::export_to_directory(models, temp_dir.path()).unwrap();
984 assert_eq!(paths.len(), 2);
985 for p in &paths {
986 assert!(p.exists());
987 }
988 }
989
990 #[test]
991 fn test_deduplicator_find_duplicates() {
992 let models = vec![
993 ("model_a".to_string(), vec![1, 2, 3]),
994 ("model_b".to_string(), vec![1, 2, 3]), ("model_c".to_string(), vec![4, 5, 6]), ];
997 let dupes = ModelDeduplicator::find_duplicates(models);
998 assert_eq!(dupes.len(), 1);
999 let names: Vec<&Vec<String>> = dupes.values().collect();
1000 let names = &names[0];
1001 assert!(names.contains(&"model_a".to_string()));
1002 assert!(names.contains(&"model_b".to_string()));
1003 }
1004
1005 #[test]
1006 fn test_deduplicator_similarity_partial() {
1007 let data1 = vec![1, 2, 3, 4];
1008 let data2 = vec![1, 2, 0, 0];
1009 let score = ModelDeduplicator::similarity_score(&data1, &data2);
1010 assert!((score - 50.0).abs() < 0.1);
1011 }
1012
1013 #[test]
1014 fn test_similarity_score_different_lengths() {
1015 let a = vec![1u8, 2, 3];
1016 let b = vec![1u8, 2, 3, 4];
1017 let score = ModelDeduplicator::similarity_score(&a, &b);
1018 assert_eq!(score, 0.0);
1019 }
1020
1021 #[test]
1022 fn test_estimate_parameters_custom_format() {
1023 let data = vec![0u8; 1024]; let meta = ModelMetadata::new(
1026 "custom_fmt".to_string(),
1027 ModelFormat::Custom("myformat".to_string()),
1028 );
1029 let analysis = ModelAnalyzer::analyze(&data, &meta);
1030 assert!(analysis.estimated_parameters.is_some());
1032 assert_eq!(analysis.estimated_parameters.unwrap(), 256);
1034 }
1035 #[test]
1038 fn test_safe_archive_name_accepts_ordinary_names() {
1039 assert_eq!(
1040 safe_archive_name("model.safetensors").unwrap(),
1041 "model.safetensors"
1042 );
1043 assert_eq!(safe_archive_name("llama-7b.bin").unwrap(), "llama-7b.bin");
1044 }
1045
1046 #[test]
1049 fn test_safe_archive_name_rejects_escapes() {
1050 for hostile in [
1051 "../evil",
1052 "../../evil",
1053 "a/b",
1054 "a/../b",
1055 "/etc/passwd",
1056 "..",
1057 ".",
1058 "",
1059 ] {
1060 assert!(
1061 safe_archive_name(hostile).is_err(),
1062 "{hostile:?} must be rejected"
1063 );
1064 }
1065 }
1066
1067 #[cfg(windows)]
1072 #[test]
1073 fn test_safe_archive_name_rejects_windows_separators_and_prefixes() {
1074 for hostile in [
1075 r"a\b",
1076 r"..\..\evil",
1077 r"C:\Windows\evil",
1078 "C:/Windows/evil",
1079 r"\\server\share\x",
1080 ] {
1081 assert!(
1082 safe_archive_name(hostile).is_err(),
1083 "{hostile:?} must be rejected on Windows"
1084 );
1085 }
1086 }
1087
1088 #[cfg(unix)]
1092 #[test]
1093 fn test_safe_archive_name_treats_backslash_as_an_ordinary_character() {
1094 assert_eq!(safe_archive_name(r"a\b").unwrap(), r"a\b");
1095 assert_eq!(
1096 safe_archive_name(r"C:\Windows\evil").unwrap(),
1097 r"C:\Windows\evil"
1098 );
1099
1100 assert!(safe_archive_name("../evil").is_err());
1102 }
1103
1104 #[test]
1107 fn test_extract_zip_refuses_a_member_that_escapes() {
1108 let dir = tempfile::tempdir().unwrap();
1109 let archive = dir.path().join("evil.zip");
1110
1111 {
1112 let file = std::fs::File::create(&archive).unwrap();
1113 let mut zip = zip::ZipWriter::new(file);
1114 let opts = zip::write::SimpleFileOptions::default();
1115 zip.start_file("legit.bin", opts).unwrap();
1116 zip.write_all(b"harmless").unwrap();
1117 zip.start_file("../../ESCAPED.txt", opts).unwrap();
1118 zip.write_all(b"pwned").unwrap();
1119 zip.finish().unwrap();
1120 }
1121
1122 let err = ModelArchive::extract_zip(&archive).unwrap_err();
1123 assert!(
1124 matches!(err, VaultError::InvalidInput(_)),
1125 "expected InvalidInput, got {err:?}"
1126 );
1127 assert!(err.to_string().contains("ESCAPED.txt"), "got: {err}");
1128 }
1129
1130 #[test]
1133 fn test_extract_tar_refuses_a_member_that_escapes() {
1134 let dir = tempfile::tempdir().unwrap();
1135 let archive = dir.path().join("evil.tar");
1136
1137 {
1138 let file = std::fs::File::create(&archive).unwrap();
1139 let mut ar = tar::Builder::new(file);
1140 let payload = b"pwned";
1141
1142 let mut header = tar::Header::new_gnu();
1147 let hostile = b"../../ESCAPED.txt";
1148 {
1149 let gnu = header.as_gnu_mut().expect("new_gnu is a GNU header");
1150 gnu.name[..hostile.len()].copy_from_slice(hostile);
1151 }
1152 header.set_size(payload.len() as u64);
1153 header.set_mode(0o644);
1154 header.set_cksum();
1155
1156 ar.append(&header, &payload[..]).unwrap();
1157 ar.finish().unwrap();
1158 }
1159
1160 {
1163 let file = std::fs::File::open(&archive).unwrap();
1164 let mut probe = tar::Archive::new(file);
1165 let names: Vec<String> = probe
1166 .entries()
1167 .unwrap()
1168 .map(|e| e.unwrap().path().unwrap().to_string_lossy().into_owned())
1169 .collect();
1170 assert!(
1171 names.iter().any(|n| n.contains("..")),
1172 "forged archive lost its traversal: {names:?}"
1173 );
1174 }
1175
1176 let err = ModelArchive::extract_tar(&archive).unwrap_err();
1177 assert!(
1178 matches!(err, VaultError::InvalidInput(_)),
1179 "expected InvalidInput, got {err:?}"
1180 );
1181 }
1182
1183 #[test]
1185 fn test_round_trip_still_works_for_both_formats() {
1186 let dir = tempfile::tempdir().unwrap();
1187 let models = vec![
1188 ("alpha.safetensors".to_string(), b"aaa".to_vec()),
1189 ("beta.gguf".to_string(), b"bbb".to_vec()),
1190 ];
1191
1192 let tar_path = dir.path().join("a.tar");
1193 ModelArchive::create_tar(models.clone(), &tar_path).unwrap();
1194 let mut out = ModelArchive::extract_tar(&tar_path).unwrap();
1195 out.sort();
1196 assert_eq!(out, models);
1197
1198 let zip_path = dir.path().join("a.zip");
1199 ModelArchive::create_zip(models.clone(), &zip_path).unwrap();
1200 let mut out = ModelArchive::extract_zip(&zip_path).unwrap();
1201 out.sort();
1202 assert_eq!(out, models);
1203 }
1204
1205 #[test]
1206 fn test_archive_budget_rejects_an_oversized_member() {
1207 let err = check_archive_budget("bomb.bin", MAX_ARCHIVE_MEMBER_BYTES + 1, 0).unwrap_err();
1208 assert!(
1209 err.to_string().contains("per-member limit"),
1210 "expected a per-member limit error, got: {err}"
1211 );
1212
1213 check_archive_budget("big.bin", MAX_ARCHIVE_MEMBER_BYTES, 0).unwrap();
1215 }
1216
1217 #[test]
1218 fn test_archive_budget_rejects_many_members_that_are_each_legal() {
1219 let member = MAX_ARCHIVE_MEMBER_BYTES;
1222 let mut total: u64 = 0;
1223 let mut rejected_at = None;
1224
1225 for i in 0..8 {
1226 match check_archive_budget("chunk.bin", member, total) {
1227 Ok(()) => total += member,
1228 Err(err) => {
1229 assert!(
1230 err.to_string().contains("total limit"),
1231 "expected a total-limit error, got: {err}"
1232 );
1233 rejected_at = Some(i);
1234 break;
1235 }
1236 }
1237 }
1238
1239 assert_eq!(
1240 rejected_at,
1241 Some(2),
1242 "16 GiB of budget should admit exactly two 8 GiB members"
1243 );
1244 }
1245
1246 #[test]
1247 fn test_archive_budget_does_not_overflow_on_a_u64_max_claim() {
1248 let err = check_archive_budget("liar.bin", u64::MAX, u64::MAX).unwrap_err();
1251 assert!(err.to_string().contains("limit"), "got: {err}");
1252 }
1253
1254 #[test]
1255 fn test_read_member_bounded_rejects_contents_that_overrun_the_declared_size() {
1256 let payload = vec![0u8; 64];
1259
1260 let mut reader = &payload[..];
1261 let err = read_member_bounded("liar.bin", &mut reader, 16).unwrap_err();
1262 assert!(
1263 err.to_string().contains("understated"),
1264 "expected an overrun error, got: {err}"
1265 );
1266
1267 let mut reader = &payload[..];
1270 assert_eq!(
1271 read_member_bounded("ok.bin", &mut reader, 64).unwrap(),
1272 payload
1273 );
1274 }
1275
1276 #[test]
1290 fn cache_evicts_strictly_least_recently_used() {
1291 for round in 0..64 {
1292 let mut opt = RetrievalOptimizer::new(200);
1293
1294 opt.cache_model("m1".into(), vec![0; 100]).unwrap();
1295 opt.cache_model("m2".into(), vec![0; 100]).unwrap();
1296
1297 assert!(opt.get_cached("m1").is_some(), "round {round}");
1301
1302 opt.cache_model("m3".into(), vec![0; 100]).unwrap();
1303
1304 assert!(
1305 opt.get_cached("m2").is_none(),
1306 "round {round}: m2 was least recently used and should have been evicted"
1307 );
1308 assert!(
1309 opt.get_cached("m1").is_some(),
1310 "round {round}: m1 was touched"
1311 );
1312 assert!(
1313 opt.get_cached("m3").is_some(),
1314 "round {round}: m3 is newest"
1315 );
1316 }
1317 }
1318
1319 #[test]
1321 fn cache_evicts_in_insertion_order_when_untouched() {
1322 for round in 0..64 {
1323 let mut opt = RetrievalOptimizer::new(200);
1324 opt.cache_model("m1".into(), vec![0; 100]).unwrap();
1325 opt.cache_model("m2".into(), vec![0; 100]).unwrap();
1326 opt.cache_model("m3".into(), vec![0; 100]).unwrap();
1327
1328 assert!(opt.get_cached("m1").is_none(), "round {round}");
1329 assert!(opt.get_cached("m3").is_some(), "round {round}");
1330 }
1331 }
1332
1333 #[test]
1345 fn cache_eviction_is_deterministic_when_timestamps_are_identical() {
1346 for round in 0..64 {
1347 let mut opt = RetrievalOptimizer::new(200);
1348 opt.cache_model("m1".into(), vec![0; 100]).unwrap();
1349 opt.cache_model("m2".into(), vec![0; 100]).unwrap();
1350
1351 let frozen = std::time::SystemTime::now();
1353 for entry in opt.cache.values_mut() {
1354 entry.last_access = frozen;
1355 }
1356
1357 opt.cache_model("m3".into(), vec![0; 100]).unwrap();
1358
1359 assert!(
1360 opt.cache.contains_key("m2"),
1361 "round {round}: m1 was inserted first and must be evicted first, \
1362 but eviction fell back to HashMap iteration order"
1363 );
1364 assert!(!opt.cache.contains_key("m1"), "round {round}");
1365 assert!(opt.cache.contains_key("m3"), "round {round}");
1366 }
1367 }
1368}