Skip to main content

batuta/experiment/
sovereign.rs

1//! Sovereign distribution manifest for air-gapped deployments
2//!
3//! This module contains sovereign distribution types extracted from experiment/mod.rs.
4
5use super::ExperimentError;
6use serde::{Deserialize, Serialize};
7
8/// Sovereign distribution manifest for air-gapped deployments
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct SovereignDistribution {
11    /// Distribution name
12    pub name: String,
13    /// Version
14    pub version: String,
15    /// Target platforms
16    pub platforms: Vec<String>,
17    /// Required artifacts
18    pub artifacts: Vec<SovereignArtifact>,
19    /// Cryptographic signatures
20    pub signatures: Vec<ArtifactSignature>,
21    /// Offline registry configuration
22    pub offline_registry: Option<OfflineRegistryConfig>,
23    /// Nix flake hash for reproducibility
24    pub nix_flake_hash: Option<String>,
25}
26
27/// Artifact in a sovereign distribution
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct SovereignArtifact {
30    /// Artifact name
31    pub name: String,
32    /// Artifact type
33    pub artifact_type: ArtifactType,
34    /// SHA-256 hash
35    pub sha256: String,
36    /// Size in bytes
37    pub size_bytes: u64,
38    /// Download URL (for pre-staging)
39    pub source_url: Option<String>,
40}
41
42/// Types of distributable artifacts
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44pub enum ArtifactType {
45    Binary,
46    Model,
47    Dataset,
48    Config,
49    Documentation,
50    Container,
51    NixDerivation,
52}
53
54/// Cryptographic signature for artifacts
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct ArtifactSignature {
57    /// Artifact name this signature is for
58    pub artifact_name: String,
59    /// Signature algorithm
60    pub algorithm: SignatureAlgorithm,
61    /// Base64-encoded signature
62    pub signature: String,
63    /// Public key identifier
64    pub key_id: String,
65}
66
67/// Supported signature algorithms
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69pub enum SignatureAlgorithm {
70    Ed25519,
71    RSA4096,
72    EcdsaP256,
73}
74
75/// Offline model registry configuration
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct OfflineRegistryConfig {
78    /// Registry path
79    pub path: String,
80    /// Index file location
81    pub index_path: String,
82    /// Supported platforms
83    pub platforms: Vec<String>,
84    /// Last sync timestamp
85    pub last_sync: Option<String>,
86}
87
88impl SovereignDistribution {
89    /// Create a new sovereign distribution
90    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
91        Self {
92            name: name.into(),
93            version: version.into(),
94            platforms: Vec::new(),
95            artifacts: Vec::new(),
96            signatures: Vec::new(),
97            offline_registry: None,
98            nix_flake_hash: None,
99        }
100    }
101
102    /// Add a platform target
103    pub fn add_platform(&mut self, platform: impl Into<String>) {
104        self.platforms.push(platform.into());
105    }
106
107    /// Add an artifact
108    pub fn add_artifact(&mut self, artifact: SovereignArtifact) {
109        self.artifacts.push(artifact);
110    }
111
112    /// Validate all artifacts have signatures
113    pub fn validate_signatures(&self) -> Result<(), ExperimentError> {
114        for artifact in &self.artifacts {
115            let has_sig = self.signatures.iter().any(|s| s.artifact_name == artifact.name);
116            if !has_sig {
117                return Err(ExperimentError::SovereignValidationFailed(format!(
118                    "Missing signature for artifact: {}",
119                    artifact.name
120                )));
121            }
122        }
123        Ok(())
124    }
125
126    /// Calculate total distribution size
127    pub fn total_size_bytes(&self) -> u64 {
128        self.artifacts.iter().map(|a| a.size_bytes).sum()
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn test_sovereign_distribution_new() {
138        let dist = SovereignDistribution::new("my-dist", "1.0.0");
139        assert_eq!(dist.name, "my-dist");
140        assert_eq!(dist.version, "1.0.0");
141        assert!(dist.platforms.is_empty());
142        assert!(dist.artifacts.is_empty());
143        assert!(dist.signatures.is_empty());
144        assert!(dist.offline_registry.is_none());
145        assert!(dist.nix_flake_hash.is_none());
146    }
147
148    #[test]
149    fn test_add_platform() {
150        let mut dist = SovereignDistribution::new("test", "1.0");
151        dist.add_platform("linux-x86_64");
152        dist.add_platform("darwin-aarch64");
153        assert_eq!(dist.platforms.len(), 2);
154        assert!(dist.platforms.contains(&"linux-x86_64".to_string()));
155        assert!(dist.platforms.contains(&"darwin-aarch64".to_string()));
156    }
157
158    #[test]
159    fn test_add_artifact() {
160        let mut dist = SovereignDistribution::new("test", "1.0");
161        let artifact = SovereignArtifact {
162            name: "model.apr".to_string(),
163            artifact_type: ArtifactType::Model,
164            sha256: "abc123".to_string(),
165            size_bytes: 1024,
166            source_url: None,
167        };
168        dist.add_artifact(artifact);
169        assert_eq!(dist.artifacts.len(), 1);
170        assert_eq!(dist.artifacts[0].name, "model.apr");
171    }
172
173    #[test]
174    fn test_total_size_bytes() {
175        let mut dist = SovereignDistribution::new("test", "1.0");
176        dist.add_artifact(SovereignArtifact {
177            name: "a.bin".to_string(),
178            artifact_type: ArtifactType::Binary,
179            sha256: "hash1".to_string(),
180            size_bytes: 1000,
181            source_url: None,
182        });
183        dist.add_artifact(SovereignArtifact {
184            name: "b.model".to_string(),
185            artifact_type: ArtifactType::Model,
186            sha256: "hash2".to_string(),
187            size_bytes: 2000,
188            source_url: None,
189        });
190        assert_eq!(dist.total_size_bytes(), 3000);
191    }
192
193    #[test]
194    fn test_total_size_bytes_empty() {
195        let dist = SovereignDistribution::new("test", "1.0");
196        assert_eq!(dist.total_size_bytes(), 0);
197    }
198
199    #[test]
200    fn test_validate_signatures_missing() {
201        let mut dist = SovereignDistribution::new("test", "1.0");
202        dist.add_artifact(SovereignArtifact {
203            name: "unsigned.bin".to_string(),
204            artifact_type: ArtifactType::Binary,
205            sha256: "hash".to_string(),
206            size_bytes: 100,
207            source_url: None,
208        });
209        let result = dist.validate_signatures();
210        assert!(result.is_err());
211    }
212
213    #[test]
214    fn test_validate_signatures_valid() {
215        let mut dist = SovereignDistribution::new("test", "1.0");
216        dist.add_artifact(SovereignArtifact {
217            name: "signed.bin".to_string(),
218            artifact_type: ArtifactType::Binary,
219            sha256: "hash".to_string(),
220            size_bytes: 100,
221            source_url: None,
222        });
223        dist.signatures.push(ArtifactSignature {
224            artifact_name: "signed.bin".to_string(),
225            algorithm: SignatureAlgorithm::Ed25519,
226            signature: "base64sig".to_string(),
227            key_id: "key123".to_string(),
228        });
229        assert!(dist.validate_signatures().is_ok());
230    }
231
232    #[test]
233    fn test_validate_signatures_empty_dist() {
234        let dist = SovereignDistribution::new("test", "1.0");
235        assert!(dist.validate_signatures().is_ok());
236    }
237
238    #[test]
239    fn test_artifact_type_serialize() {
240        assert_eq!(
241            serde_json::to_string(&ArtifactType::Binary).expect("json serialize failed"),
242            "\"Binary\""
243        );
244        assert_eq!(
245            serde_json::to_string(&ArtifactType::Model).expect("json serialize failed"),
246            "\"Model\""
247        );
248        assert_eq!(
249            serde_json::to_string(&ArtifactType::NixDerivation).expect("json serialize failed"),
250            "\"NixDerivation\""
251        );
252    }
253
254    #[test]
255    fn test_signature_algorithm_equality() {
256        assert_eq!(SignatureAlgorithm::Ed25519, SignatureAlgorithm::Ed25519);
257        assert_ne!(SignatureAlgorithm::Ed25519, SignatureAlgorithm::RSA4096);
258    }
259
260    #[test]
261    fn test_sovereign_artifact_source_url() {
262        let artifact = SovereignArtifact {
263            name: "model.apr".to_string(),
264            artifact_type: ArtifactType::Model,
265            sha256: "abc123".to_string(),
266            size_bytes: 1024,
267            source_url: Some("https://example.com/model.apr".to_string()),
268        };
269        assert!(artifact.source_url.is_some());
270        assert_eq!(
271            artifact.source_url.expect("unexpected failure"),
272            "https://example.com/model.apr"
273        );
274    }
275
276    #[test]
277    fn test_offline_registry_config() {
278        let config = OfflineRegistryConfig {
279            path: "/opt/registry".to_string(),
280            index_path: "/opt/registry/index.json".to_string(),
281            platforms: vec!["linux-x86_64".to_string()],
282            last_sync: Some("2025-01-01T00:00:00Z".to_string()),
283        };
284        assert_eq!(config.path, "/opt/registry");
285        assert_eq!(config.platforms.len(), 1);
286    }
287
288    #[test]
289    fn test_distribution_with_registry() {
290        let mut dist = SovereignDistribution::new("air-gapped", "2.0");
291        dist.offline_registry = Some(OfflineRegistryConfig {
292            path: "/mnt/data".to_string(),
293            index_path: "/mnt/data/index.json".to_string(),
294            platforms: vec!["linux-x86_64".to_string(), "darwin-aarch64".to_string()],
295            last_sync: None,
296        });
297        assert!(dist.offline_registry.is_some());
298    }
299
300    #[test]
301    fn test_distribution_with_nix_hash() {
302        let mut dist = SovereignDistribution::new("reproducible", "1.0");
303        dist.nix_flake_hash = Some("sha256-abc123".to_string());
304        assert_eq!(dist.nix_flake_hash, Some("sha256-abc123".to_string()));
305    }
306}