Skip to main content

ai_model_vault/
version.rs

1//! Version control system for model checkpoints
2//!
3//! Maintains complete history of model versions with metadata and generations.
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::fs;
9use std::path::{Path, PathBuf};
10use uuid::Uuid;
11
12use crate::crypto::FipsCrypto;
13use crate::error::Result;
14
15/// Represents a single model version/checkpoint
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct ModelVersion {
18    /// Version number (sequential)
19    pub version: u32,
20
21    /// Unique checkpoint identifier
22    pub checkpoint_id: String,
23
24    /// Timestamp when version was created
25    pub timestamp: DateTime<Utc>,
26
27    /// Parent version for branching/lineage
28    pub parent_version: Option<u32>,
29
30    /// Model format
31    pub format: String,
32
33    /// Original size in bytes
34    pub size_bytes: u64,
35
36    /// Compressed size in bytes
37    pub compressed_size_bytes: u64,
38
39    /// SHA-256 checksum of original data
40    pub checksum_sha256: String,
41
42    /// User-provided metadata
43    pub metadata: HashMap<String, String>,
44
45    /// Relative path to encrypted file
46    pub file_path: String,
47}
48
49/// Version control system for model checkpoints
50///
51/// Features:
52/// - Complete version history
53/// - Parent-child relationships (branching)
54/// - Metadata tracking
55/// - Checksum verification
56/// - Generation/lineage tracking
57///
58/// Compliance:
59/// - CMMC AU.3.046: Alert in the event of an audit logging process failure
60/// - CMMC AU.3.049: Protect audit information from unauthorized access
61pub struct VersionControl {
62    vault_path: PathBuf,
63    version_file: PathBuf,
64    pub(crate) versions: HashMap<String, Vec<ModelVersion>>,
65}
66
67impl VersionControl {
68    const VERSION_FILE: &'static str = "versions.json";
69
70    /// Create new version control instance
71    pub fn new(vault_path: &Path) -> Result<Self> {
72        let version_file = vault_path.join(Self::VERSION_FILE);
73        let mut vc = Self {
74            vault_path: vault_path.to_path_buf(),
75            version_file,
76            versions: HashMap::new(),
77        };
78        vc.load_versions()?;
79        Ok(vc)
80    }
81
82    /// Return the vault directory path
83    pub fn vault_path(&self) -> &std::path::Path {
84        &self.vault_path
85    }
86
87    /// Load version history from file
88    fn load_versions(&mut self) -> Result<()> {
89        if self.version_file.exists() {
90            let contents = fs::read_to_string(&self.version_file)?;
91            self.versions = serde_json::from_str(&contents)?;
92        }
93        Ok(())
94    }
95
96    /// Save version history to file
97    fn save_versions(&self) -> Result<()> {
98        let contents = serde_json::to_string_pretty(&self.versions)?;
99        fs::write(&self.version_file, contents)?;
100        crate::permissions::restrict_file(&self.version_file)?;
101
102        Ok(())
103    }
104
105    /// Add new model version
106    #[allow(clippy::too_many_arguments)]
107    pub fn add_version(
108        &mut self,
109        model_name: &str,
110        file_path: &str,
111        format: &str,
112        size_bytes: u64,
113        compressed_size_bytes: u64,
114        checksum: &str,
115        metadata: Option<HashMap<String, String>>,
116        parent_version: Option<u32>,
117    ) -> Result<ModelVersion> {
118        let versions = self.versions.entry(model_name.to_string()).or_default();
119
120        // Determine next version number
121        let version = if versions.is_empty() {
122            1
123        } else {
124            versions.iter().map(|v| v.version).max().unwrap_or(0) + 1
125        };
126
127        let timestamp = Utc::now();
128        let checkpoint_id = Self::generate_checkpoint_id(model_name, version, &timestamp);
129
130        let model_version = ModelVersion {
131            version,
132            checkpoint_id,
133            timestamp,
134            parent_version,
135            format: format.to_string(),
136            size_bytes,
137            compressed_size_bytes,
138            checksum_sha256: checksum.to_string(),
139            metadata: metadata.unwrap_or_default(),
140            file_path: file_path.to_string(),
141        };
142
143        versions.push(model_version.clone());
144        self.save_versions()?;
145
146        Ok(model_version)
147    }
148
149    /// Get specific model version
150    pub fn get_version(&self, model_name: &str, version: Option<u32>) -> Option<&ModelVersion> {
151        let versions = self.versions.get(model_name)?;
152
153        if versions.is_empty() {
154            return None;
155        }
156
157        if let Some(v) = version {
158            versions.iter().find(|mv| mv.version == v)
159        } else {
160            // Return latest version
161            versions.iter().max_by_key(|mv| mv.version)
162        }
163    }
164
165    /// List all versions of a model
166    pub fn list_versions(&self, model_name: &str) -> Vec<&ModelVersion> {
167        self.versions
168            .get(model_name)
169            .map(|v| {
170                let mut sorted: Vec<&ModelVersion> = v.iter().collect();
171                sorted.sort_by_key(|mv| mv.version);
172                sorted
173            })
174            .unwrap_or_default()
175    }
176
177    /// Get complete lineage/generation history for a version
178    pub fn get_lineage(&self, model_name: &str, version: u32) -> Vec<&ModelVersion> {
179        let mut lineage = Vec::new();
180
181        if let Some(mut current) = self.get_version(model_name, Some(version)) {
182            lineage.push(current);
183
184            while let Some(parent_ver) = current.parent_version {
185                if let Some(parent) = self.get_version(model_name, Some(parent_ver)) {
186                    lineage.insert(0, parent);
187                    current = parent;
188                } else {
189                    break;
190                }
191            }
192        }
193
194        lineage
195    }
196
197    /// Delete a specific version
198    pub fn delete_version(&mut self, model_name: &str, version: u32) -> Result<bool> {
199        if let Some(versions) = self.versions.get_mut(model_name) {
200            let original_len = versions.len();
201            versions.retain(|v| v.version != version);
202
203            if versions.len() < original_len {
204                self.save_versions()?;
205                return Ok(true);
206            }
207        }
208        Ok(false)
209    }
210
211    /// Clean up old versions, keeping only the most recent
212    pub fn cleanup_old_versions(
213        &mut self,
214        model_name: &str,
215        keep_count: usize,
216    ) -> Result<Vec<u32>> {
217        let Some(versions) = self.versions.get_mut(model_name) else {
218            return Ok(Vec::new());
219        };
220
221        if versions.len() <= keep_count {
222            return Ok(Vec::new());
223        }
224
225        // Sort by version number descending
226        versions.sort_by_key(|v| std::cmp::Reverse(v.version));
227
228        // Keep the most recent
229        let to_delete: Vec<u32> = versions
230            .iter()
231            .skip(keep_count)
232            .map(|v| v.version)
233            .collect();
234
235        versions.truncate(keep_count);
236        self.save_versions()?;
237
238        Ok(to_delete)
239    }
240
241    /// Verify data integrity using stored checksum
242    pub fn verify_checksum(&self, model_name: &str, version: u32, data: &[u8]) -> bool {
243        if let Some(model_version) = self.get_version(model_name, Some(version)) {
244            let checksum = hex::encode(FipsCrypto::hash_sha256(data));
245            return checksum == model_version.checksum_sha256;
246        }
247        false
248    }
249
250    /// Update metadata for a specific model version
251    pub fn update_metadata(
252        &mut self,
253        model_name: &str,
254        version: u32,
255        key: &str,
256        value: String,
257    ) -> Result<()> {
258        if let Some(versions) = self.versions.get_mut(model_name) {
259            if let Some(model_version) = versions.iter_mut().find(|v| v.version == version) {
260                model_version.metadata.insert(key.to_string(), value);
261                self.save_versions()?;
262                return Ok(());
263            }
264        }
265        Err(crate::error::VaultError::VersionNotFound(
266            version,
267            model_name.to_string(),
268        ))
269    }
270
271    /// Get metadata for a specific model version
272    pub fn get_metadata(&self, model_name: &str, version: u32, key: &str) -> Option<String> {
273        self.get_version(model_name, Some(version))
274            .and_then(|v| v.metadata.get(key).cloned())
275    }
276
277    /// Return an owned list of model names (useful when the caller needs
278    /// ownership, e.g. for export/import operations).
279    pub fn list_models_owned(&self) -> Vec<String> {
280        self.versions.keys().cloned().collect()
281    }
282
283    /// Import a pre-built `ModelVersion` for a model — used during vault
284    /// bundle import to transplant versions from another vault.
285    pub fn import_version(&mut self, model_name: &str, version: ModelVersion) -> Result<()> {
286        self.versions
287            .entry(model_name.to_string())
288            .or_default()
289            .push(version);
290        self.save_versions()
291    }
292
293    /// Generate unique checkpoint identifier
294    fn generate_checkpoint_id(
295        model_name: &str,
296        version: u32,
297        _timestamp: &DateTime<Utc>,
298    ) -> String {
299        let uuid = Uuid::new_v4();
300        format!("{}-v{}-{}", model_name, version, uuid)
301    }
302}
303
304// ── Trait implementation ─────────────────────────────────────
305
306impl crate::traits::VersionRepo for VersionControl {
307    fn add_version(
308        &mut self,
309        model: &str,
310        file_path: &str,
311        format: &str,
312        size_bytes: u64,
313        compressed_size_bytes: u64,
314        checksum: &str,
315        metadata: Option<HashMap<String, String>>,
316        parent_version: Option<u32>,
317    ) -> Result<ModelVersion> {
318        VersionControl::add_version(
319            self,
320            model,
321            file_path,
322            format,
323            size_bytes,
324            compressed_size_bytes,
325            checksum,
326            metadata,
327            parent_version,
328        )
329    }
330
331    fn get_version(&self, model: &str, version: Option<u32>) -> Option<&ModelVersion> {
332        VersionControl::get_version(self, model, version)
333    }
334
335    fn list_versions(&self, model: &str) -> Vec<&ModelVersion> {
336        VersionControl::list_versions(self, model)
337    }
338
339    fn get_lineage(&self, model: &str, version: u32) -> Vec<&ModelVersion> {
340        VersionControl::get_lineage(self, model, version)
341    }
342
343    fn delete_version(&mut self, model: &str, version: u32) -> Result<bool> {
344        VersionControl::delete_version(self, model, version)
345    }
346
347    fn cleanup_old_versions(&mut self, model: &str, keep_count: usize) -> Result<Vec<u32>> {
348        VersionControl::cleanup_old_versions(self, model, keep_count)
349    }
350
351    fn verify_checksum(&self, model: &str, version: u32, data: &[u8]) -> bool {
352        VersionControl::verify_checksum(self, model, version, data)
353    }
354
355    fn update_metadata(
356        &mut self,
357        model: &str,
358        version: u32,
359        key: &str,
360        value: String,
361    ) -> Result<()> {
362        VersionControl::update_metadata(self, model, version, key, value)
363    }
364
365    fn get_metadata(&self, model: &str, version: u32, key: &str) -> Option<String> {
366        VersionControl::get_metadata(self, model, version, key)
367    }
368
369    fn list_models(&self) -> Vec<String> {
370        self.versions.keys().cloned().collect()
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use tempfile::tempdir;
378
379    #[test]
380    fn test_version_control() {
381        let temp_dir = tempdir().unwrap();
382        let mut vc = VersionControl::new(temp_dir.path()).unwrap();
383
384        let v1 = vc
385            .add_version(
386                "test_model",
387                "test_file.enc",
388                "pytorch",
389                1000,
390                500,
391                "abc123",
392                None,
393                None,
394            )
395            .unwrap();
396
397        assert_eq!(v1.version, 1);
398        assert_eq!(v1.format, "pytorch");
399
400        let v2 = vc
401            .add_version(
402                "test_model",
403                "test_file2.enc",
404                "pytorch",
405                2000,
406                1000,
407                "def456",
408                None,
409                Some(1),
410            )
411            .unwrap();
412
413        assert_eq!(v2.version, 2);
414        assert_eq!(v2.parent_version, Some(1));
415
416        let lineage = vc.get_lineage("test_model", 2);
417        assert_eq!(lineage.len(), 2);
418    }
419
420    #[test]
421    fn test_version_control_list_versions() {
422        // Covers line 175 — list_versions sorted path
423        let temp_dir = tempdir().unwrap();
424        let mut vc = VersionControl::new(temp_dir.path()).unwrap();
425
426        vc.add_version("m", "f1.enc", "pt", 100, 50, "c1", None, None)
427            .unwrap();
428        vc.add_version("m", "f2.enc", "pt", 200, 100, "c2", None, Some(1))
429            .unwrap();
430        vc.add_version("m", "f3.enc", "pt", 300, 150, "c3", None, Some(2))
431            .unwrap();
432
433        let versions = vc.list_versions("m");
434        assert_eq!(versions.len(), 3);
435        assert_eq!(versions[0].version, 1);
436        assert_eq!(versions[2].version, 3);
437
438        // Nonexistent model
439        let empty = vc.list_versions("nonexistent");
440        assert!(empty.is_empty());
441    }
442
443    #[test]
444    fn test_version_control_reopen() {
445        // Covers line 72 — re-load existing versions.json
446        let temp_dir = tempdir().unwrap();
447        {
448            let mut vc = VersionControl::new(temp_dir.path()).unwrap();
449            vc.add_version("m", "f1.enc", "pt", 100, 50, "c1", None, None)
450                .unwrap();
451        }
452        let vc2 = VersionControl::new(temp_dir.path()).unwrap();
453        assert_eq!(vc2.list_versions("m").len(), 1);
454    }
455
456    #[test]
457    fn test_delete_version() {
458        let temp_dir = tempdir().unwrap();
459        let mut vc = VersionControl::new(temp_dir.path()).unwrap();
460        vc.add_version("m", "f1.enc", "pt", 100, 50, "c1", None, None)
461            .unwrap();
462        vc.add_version("m", "f2.enc", "pt", 200, 100, "c2", None, Some(1))
463            .unwrap();
464
465        let deleted = vc.delete_version("m", 1).unwrap();
466        assert!(deleted);
467        assert_eq!(vc.list_versions("m").len(), 1);
468
469        // Delete nonexistent version
470        let not_deleted = vc.delete_version("m", 99).unwrap();
471        assert!(!not_deleted);
472
473        // Delete from nonexistent model
474        let not_deleted2 = vc.delete_version("nonexistent", 1).unwrap();
475        assert!(!not_deleted2);
476    }
477
478    #[test]
479    fn test_cleanup_old_versions() {
480        let temp_dir = tempdir().unwrap();
481        let mut vc = VersionControl::new(temp_dir.path()).unwrap();
482        for i in 0..5 {
483            vc.add_version(
484                "m",
485                &format!("f{}.enc", i),
486                "pt",
487                100,
488                50,
489                &format!("c{}", i),
490                None,
491                if i == 0 { None } else { Some(i as u32) },
492            )
493            .unwrap();
494        }
495        assert_eq!(vc.list_versions("m").len(), 5);
496
497        // Keep only 2
498        let removed = vc.cleanup_old_versions("m", 2).unwrap();
499        assert_eq!(removed.len(), 3);
500        assert_eq!(vc.list_versions("m").len(), 2);
501
502        // Cleanup with keep_count >= current count
503        let removed2 = vc.cleanup_old_versions("m", 10).unwrap();
504        assert!(removed2.is_empty());
505
506        // Cleanup nonexistent model
507        let removed3 = vc.cleanup_old_versions("nonexistent", 1).unwrap();
508        assert!(removed3.is_empty());
509    }
510
511    #[test]
512    fn test_verify_checksum() {
513        let temp_dir = tempdir().unwrap();
514        let mut vc = VersionControl::new(temp_dir.path()).unwrap();
515        let data = b"hello world";
516        let checksum = hex::encode(crate::crypto::FipsCrypto::hash_sha256(data));
517        vc.add_version(
518            "m",
519            "f.enc",
520            "pt",
521            data.len() as u64,
522            data.len() as u64,
523            &checksum,
524            None,
525            None,
526        )
527        .unwrap();
528
529        assert!(vc.verify_checksum("m", 1, data));
530        assert!(!vc.verify_checksum("m", 1, b"wrong data"));
531        assert!(!vc.verify_checksum("m", 99, data));
532        assert!(!vc.verify_checksum("nonexistent", 1, data));
533    }
534
535    #[test]
536    fn test_update_and_get_metadata() {
537        let temp_dir = tempdir().unwrap();
538        let mut vc = VersionControl::new(temp_dir.path()).unwrap();
539        vc.add_version("m", "f.enc", "pt", 100, 50, "c1", None, None)
540            .unwrap();
541
542        vc.update_metadata("m", 1, "author", "Alice".to_string())
543            .unwrap();
544        assert_eq!(vc.get_metadata("m", 1, "author"), Some("Alice".to_string()));
545
546        // Nonexistent key
547        assert_eq!(vc.get_metadata("m", 1, "missing_key"), None);
548
549        // Nonexistent version
550        let err = vc.update_metadata("m", 99, "key", "val".to_string());
551        assert!(err.is_err());
552
553        // Nonexistent model
554        let err2 = vc.update_metadata("nonexistent", 1, "key", "val".to_string());
555        assert!(err2.is_err());
556    }
557
558    #[test]
559    fn test_get_lineage_chain() {
560        let temp_dir = tempdir().unwrap();
561        let mut vc = VersionControl::new(temp_dir.path()).unwrap();
562        vc.add_version("m", "f1.enc", "pt", 100, 50, "c1", None, None)
563            .unwrap();
564        vc.add_version("m", "f2.enc", "pt", 200, 100, "c2", None, Some(1))
565            .unwrap();
566        vc.add_version("m", "f3.enc", "pt", 300, 150, "c3", None, Some(2))
567            .unwrap();
568
569        let lineage = vc.get_lineage("m", 3);
570        assert_eq!(lineage.len(), 3);
571        assert_eq!(lineage[0].version, 1); // oldest first
572        assert_eq!(lineage[2].version, 3); // newest last
573
574        // Lineage with nonexistent version
575        let empty = vc.get_lineage("m", 99);
576        assert!(empty.is_empty());
577
578        // Lineage for nonexistent model
579        let empty2 = vc.get_lineage("nonexistent", 1);
580        assert!(empty2.is_empty());
581    }
582
583    #[test]
584    fn test_add_version_with_metadata() {
585        let temp_dir = tempdir().unwrap();
586        let mut vc = VersionControl::new(temp_dir.path()).unwrap();
587        let mut meta = std::collections::HashMap::new();
588        meta.insert("framework".to_string(), "pytorch".to_string());
589        let v = vc
590            .add_version("m", "f.enc", "pt", 100, 50, "c1", Some(meta), None)
591            .unwrap();
592        assert_eq!(v.metadata.get("framework"), Some(&"pytorch".to_string()));
593    }
594
595    #[test]
596    fn test_version_repo_trait_forwarding() {
597        // Covers L297, L321-342, L345, L352, L355-356 — VersionRepo trait impl
598        use crate::traits::VersionRepo;
599        let temp_dir = tempdir().unwrap();
600        let mut vc = VersionControl::new(temp_dir.path()).unwrap();
601
602        // add_version via trait
603        let v = VersionRepo::add_version(
604            &mut vc,
605            "m",
606            "f.enc",
607            "pt",
608            100,
609            50,
610            "checksum1",
611            None,
612            None,
613        )
614        .unwrap();
615        assert_eq!(v.version, 1);
616
617        // get_version via trait
618        let got = VersionRepo::get_version(&vc, "m", Some(1));
619        assert!(got.is_some());
620        assert_eq!(got.unwrap().version, 1);
621
622        // list_versions via trait
623        let list = VersionRepo::list_versions(&vc, "m");
624        assert_eq!(list.len(), 1);
625
626        // get_lineage via trait
627        let lineage = VersionRepo::get_lineage(&vc, "m", 1);
628        assert_eq!(lineage.len(), 1);
629
630        // verify_checksum via trait
631        let valid = VersionRepo::verify_checksum(&vc, "m", 1, b"dummy");
632        assert!(!valid); // sha256 of "dummy" != "checksum1"
633
634        // update_metadata via trait
635        VersionRepo::update_metadata(&mut vc, "m", 1, "key1", "val1".into()).unwrap();
636
637        // get_metadata via trait
638        let val = VersionRepo::get_metadata(&vc, "m", 1, "key1");
639        assert_eq!(val, Some("val1".into()));
640
641        // delete_version via trait
642        let deleted = VersionRepo::delete_version(&mut vc, "m", 1).unwrap();
643        assert!(deleted);
644
645        // cleanup_old_versions via trait — add several versions first
646        for i in 0..5 {
647            VersionRepo::add_version(
648                &mut vc,
649                "m2",
650                &format!("f{}.enc", i),
651                "pt",
652                100,
653                50,
654                &format!("c{}", i),
655                None,
656                None,
657            )
658            .unwrap();
659        }
660        let cleaned = VersionRepo::cleanup_old_versions(&mut vc, "m2", 2).unwrap();
661        assert_eq!(cleaned.len(), 3);
662
663        // list_models via trait
664        let models = VersionRepo::list_models(&vc);
665        assert!(models.contains(&"m2".to_string()));
666    }
667
668    #[test]
669    fn test_vault_path_method() {
670        // Covers L83-84 — vault_path() getter
671        let temp_dir = tempdir().unwrap();
672        let vc = VersionControl::new(temp_dir.path()).unwrap();
673        assert_eq!(vc.vault_path(), temp_dir.path());
674    }
675
676    #[test]
677    fn test_load_existing_versions() {
678        // Covers L67-68 — loading versions from existing file
679        let temp_dir = tempdir().unwrap();
680        {
681            let mut vc = VersionControl::new(temp_dir.path()).unwrap();
682            vc.add_version("m", "f.enc", "pt", 100, 50, "c1", None, None)
683                .unwrap();
684        }
685        // Re-open: should load the saved versions
686        let vc2 = VersionControl::new(temp_dir.path()).unwrap();
687        let versions = vc2.list_versions("m");
688        assert_eq!(versions.len(), 1);
689    }
690}