Skip to main content

coding_agent_search/pages/
archive_config.rs

1//! Archive configuration types for pages bundles.
2//!
3//! Supports both encrypted and unencrypted bundles via an untagged enum.
4
5use serde::{Deserialize, Deserializer, Serialize, Serializer};
6
7use super::encrypt::EncryptionConfig;
8
9/// Supported archive configuration formats.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(untagged)]
12pub enum ArchiveConfig {
13    /// Encrypted bundle configuration (default).
14    Encrypted(EncryptionConfig),
15    /// Unencrypted bundle configuration.
16    Unencrypted(UnencryptedConfig),
17}
18
19impl ArchiveConfig {
20    /// Returns true if this config represents an encrypted bundle.
21    pub fn is_encrypted(&self) -> bool {
22        matches!(self, ArchiveConfig::Encrypted(_))
23    }
24
25    /// Get the encrypted config if available.
26    pub fn as_encrypted(&self) -> Option<&EncryptionConfig> {
27        match self {
28            ArchiveConfig::Encrypted(cfg) => Some(cfg),
29            ArchiveConfig::Unencrypted(_) => None,
30        }
31    }
32
33    /// Get the unencrypted config if available.
34    pub fn as_unencrypted(&self) -> Option<&UnencryptedConfig> {
35        match self {
36            ArchiveConfig::Encrypted(_) => None,
37            ArchiveConfig::Unencrypted(cfg) => Some(cfg),
38        }
39    }
40}
41
42/// Unencrypted bundle configuration.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct UnencryptedConfig {
46    /// Whether the bundle is encrypted (must be false).
47    #[serde(
48        serialize_with = "serialize_unencrypted_false",
49        deserialize_with = "deserialize_unencrypted_false"
50    )]
51    pub encrypted: bool,
52    /// Config version.
53    pub version: String,
54    /// Payload descriptor.
55    pub payload: UnencryptedPayload,
56    /// Optional warning message for viewers.
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub warning: Option<String>,
59}
60
61/// Unencrypted payload descriptor.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct UnencryptedPayload {
65    /// Relative path to the SQLite database payload.
66    pub path: String,
67    /// Payload format (e.g., "sqlite").
68    pub format: String,
69    /// Optional byte size of the payload.
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub size_bytes: Option<u64>,
72}
73
74fn serialize_unencrypted_false<S>(encrypted: &bool, serializer: S) -> Result<S::Ok, S::Error>
75where
76    S: Serializer,
77{
78    if *encrypted {
79        return Err(serde::ser::Error::custom(
80            "unencrypted config must set encrypted=false",
81        ));
82    }
83    serializer.serialize_bool(false)
84}
85
86fn deserialize_unencrypted_false<'de, D>(deserializer: D) -> Result<bool, D::Error>
87where
88    D: Deserializer<'de>,
89{
90    let encrypted = bool::deserialize(deserializer)?;
91    if encrypted {
92        return Err(serde::de::Error::custom(
93            "unencrypted config must set encrypted=false",
94        ));
95    }
96    Ok(false)
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    // Helper to create a minimal UnencryptedPayload
104    fn make_unencrypted_payload() -> UnencryptedPayload {
105        UnencryptedPayload {
106            path: "data.sqlite".to_string(),
107            format: "sqlite".to_string(),
108            size_bytes: None,
109        }
110    }
111
112    // Helper to create a minimal UnencryptedConfig
113    fn make_unencrypted_config() -> UnencryptedConfig {
114        UnencryptedConfig {
115            encrypted: false,
116            version: "1.0".to_string(),
117            payload: make_unencrypted_payload(),
118            warning: None,
119        }
120    }
121
122    // Helper to create a minimal EncryptionConfig for testing
123    fn make_encryption_config() -> EncryptionConfig {
124        use crate::pages::encrypt::{Argon2Params, PayloadMeta};
125        use base64::prelude::{BASE64_STANDARD, Engine as _};
126
127        EncryptionConfig {
128            version: 1,
129            export_id: BASE64_STANDARD.encode([0u8; 16]),
130            base_nonce: BASE64_STANDARD.encode([0u8; 12]),
131            compression: "deflate".to_string(),
132            kdf_defaults: Argon2Params::default(),
133            payload: PayloadMeta {
134                chunk_size: 8 * 1024 * 1024,
135                chunk_count: 1,
136                total_compressed_size: 1024,
137                total_plaintext_size: 2048,
138                files: vec!["chunk_0".to_string()],
139            },
140            key_slots: vec![],
141        }
142    }
143
144    // ==================== ArchiveConfig::is_encrypted() tests ====================
145
146    #[test]
147    fn test_is_encrypted_returns_true_for_encrypted_variant() {
148        let config = ArchiveConfig::Encrypted(make_encryption_config());
149        assert!(config.is_encrypted());
150    }
151
152    #[test]
153    fn test_is_encrypted_returns_false_for_unencrypted_variant() {
154        let config = ArchiveConfig::Unencrypted(make_unencrypted_config());
155        assert!(!config.is_encrypted());
156    }
157
158    // ==================== ArchiveConfig::as_encrypted() tests ====================
159
160    #[test]
161    fn test_as_encrypted_returns_some_for_encrypted_variant() {
162        let inner = make_encryption_config();
163        let config = ArchiveConfig::Encrypted(inner.clone());
164        let result = config.as_encrypted();
165        assert!(result.is_some());
166        assert_eq!(result.unwrap().version, inner.version);
167        assert_eq!(result.unwrap().export_id, inner.export_id);
168    }
169
170    #[test]
171    fn test_as_encrypted_returns_none_for_unencrypted_variant() {
172        let config = ArchiveConfig::Unencrypted(make_unencrypted_config());
173        assert!(config.as_encrypted().is_none());
174    }
175
176    // ==================== ArchiveConfig::as_unencrypted() tests ====================
177
178    #[test]
179    fn test_as_unencrypted_returns_some_for_unencrypted_variant() {
180        let inner = make_unencrypted_config();
181        let config = ArchiveConfig::Unencrypted(inner.clone());
182        let result = config.as_unencrypted();
183        assert!(result.is_some());
184        assert_eq!(result.unwrap().version, inner.version);
185        assert!(!result.unwrap().encrypted);
186    }
187
188    #[test]
189    fn test_as_unencrypted_returns_none_for_encrypted_variant() {
190        let config = ArchiveConfig::Encrypted(make_encryption_config());
191        assert!(config.as_unencrypted().is_none());
192    }
193
194    // ==================== Serialization round-trip tests ====================
195
196    #[test]
197    fn test_unencrypted_config_serialization_roundtrip() {
198        let original = make_unencrypted_config();
199        let json = serde_json::to_string(&original).expect("serialize");
200        let deserialized: UnencryptedConfig = serde_json::from_str(&json).expect("deserialize");
201
202        assert_eq!(original.encrypted, deserialized.encrypted);
203        assert_eq!(original.version, deserialized.version);
204        assert_eq!(original.payload.path, deserialized.payload.path);
205        assert_eq!(original.payload.format, deserialized.payload.format);
206        assert_eq!(original.warning, deserialized.warning);
207    }
208
209    #[test]
210    fn test_unencrypted_config_rejects_encrypted_true() {
211        let json = r#"{
212            "encrypted": true,
213            "version": "1.0",
214            "payload": {
215                "path": "payload.sqlite",
216                "format": "sqlite"
217            }
218        }"#;
219
220        let err = serde_json::from_str::<UnencryptedConfig>(json)
221            .expect_err("unencrypted config must reject encrypted=true");
222        assert!(
223            err.to_string()
224                .contains("unencrypted config must set encrypted=false"),
225            "unexpected error: {err}"
226        );
227    }
228
229    #[test]
230    fn test_unencrypted_config_refuses_to_serialize_encrypted_true() {
231        let invalid = UnencryptedConfig {
232            encrypted: true,
233            version: "1.0".to_string(),
234            payload: make_unencrypted_payload(),
235            warning: None,
236        };
237
238        let err = serde_json::to_string(&invalid)
239            .expect_err("unencrypted config must not serialize encrypted=true");
240        assert!(
241            err.to_string()
242                .contains("unencrypted config must set encrypted=false"),
243            "unexpected error: {err}"
244        );
245    }
246
247    #[test]
248    fn test_unencrypted_config_with_optional_fields_roundtrip() {
249        let original = UnencryptedConfig {
250            encrypted: false,
251            version: "2.0".to_string(),
252            payload: UnencryptedPayload {
253                path: "archive/data.sqlite".to_string(),
254                format: "sqlite".to_string(),
255                size_bytes: Some(123456),
256            },
257            warning: Some("This bundle is unencrypted!".to_string()),
258        };
259
260        let json = serde_json::to_string(&original).expect("serialize");
261        let deserialized: UnencryptedConfig = serde_json::from_str(&json).expect("deserialize");
262
263        assert_eq!(original.payload.size_bytes, deserialized.payload.size_bytes);
264        assert_eq!(original.warning, deserialized.warning);
265    }
266
267    #[test]
268    fn test_archive_config_unencrypted_roundtrip() {
269        let original = ArchiveConfig::Unencrypted(make_unencrypted_config());
270        let json = serde_json::to_string(&original).expect("serialize");
271        let deserialized: ArchiveConfig = serde_json::from_str(&json).expect("deserialize");
272
273        assert!(!deserialized.is_encrypted());
274        let inner = deserialized
275            .as_unencrypted()
276            .expect("should be unencrypted");
277        assert_eq!(inner.version, "1.0");
278    }
279
280    #[test]
281    fn test_archive_config_encrypted_roundtrip() {
282        let original = ArchiveConfig::Encrypted(make_encryption_config());
283        let json = serde_json::to_string(&original).expect("serialize");
284        let deserialized: ArchiveConfig = serde_json::from_str(&json).expect("deserialize");
285
286        assert!(deserialized.is_encrypted());
287        let inner = deserialized.as_encrypted().expect("should be encrypted");
288        assert_eq!(inner.version, 1);
289        assert_eq!(inner.compression, "deflate");
290    }
291
292    // ==================== Serde untagged behavior tests ====================
293
294    #[test]
295    fn test_untagged_deserialize_encrypted_json() {
296        // JSON that matches EncryptionConfig structure
297        let json = r#"{
298            "version": 1,
299            "export_id": "dGVzdGV4cG9ydGlkMTIz",
300            "base_nonce": "dGVzdG5vbmNlMTI=",
301            "compression": "gzip",
302            "kdf_defaults": {
303                "memory_kb": 65536,
304                "iterations": 3,
305                "parallelism": 4
306            },
307            "payload": {
308                "chunk_size": 4194304,
309                "chunk_count": 2,
310                "total_compressed_size": 2048,
311                "total_plaintext_size": 4096,
312                "files": ["chunk_0", "chunk_1"]
313            },
314            "key_slots": []
315        }"#;
316
317        let config: ArchiveConfig = serde_json::from_str(json).expect("deserialize");
318        assert!(config.is_encrypted());
319    }
320
321    #[test]
322    fn test_untagged_deserialize_unencrypted_json() {
323        // JSON that matches UnencryptedConfig structure
324        let json = r#"{
325            "encrypted": false,
326            "version": "1.0",
327            "payload": {
328                "path": "payload.sqlite",
329                "format": "sqlite"
330            }
331        }"#;
332
333        let config: ArchiveConfig = serde_json::from_str(json).expect("deserialize");
334        assert!(!config.is_encrypted());
335        let inner = config.as_unencrypted().expect("should be unencrypted");
336        assert_eq!(inner.payload.path, "payload.sqlite");
337    }
338
339    #[test]
340    fn test_untagged_deserialize_rejects_unencrypted_shape_with_encrypted_true() {
341        let json = r#"{
342            "encrypted": true,
343            "version": "1.0",
344            "payload": {
345                "path": "payload.sqlite",
346                "format": "sqlite"
347            }
348        }"#;
349
350        serde_json::from_str::<ArchiveConfig>(json)
351            .expect_err("encrypted=true must not classify as unencrypted archive config");
352    }
353
354    #[test]
355    fn test_untagged_deserialize_rejects_unknown_top_level_field() {
356        let json = r#"{
357            "encrypted": false,
358            "version": "1.0",
359            "payload": {
360                "path": "payload.sqlite",
361                "format": "sqlite"
362            },
363            "totally_unknown_field": 123
364        }"#;
365
366        serde_json::from_str::<ArchiveConfig>(json).expect_err("should reject unknown");
367    }
368
369    #[test]
370    fn test_untagged_deserialize_rejects_unknown_nested_payload_field() {
371        let json = r#"{
372            "encrypted": false,
373            "version": "1.0",
374            "payload": {
375                "path": "payload.sqlite",
376                "format": "sqlite",
377                "extra_payload_field": true
378            }
379        }"#;
380
381        serde_json::from_str::<ArchiveConfig>(json).expect_err("should reject unknown");
382    }
383
384    // ==================== UnencryptedPayload tests ====================
385
386    #[test]
387    fn test_unencrypted_payload_minimal() {
388        let payload = UnencryptedPayload {
389            path: "db.sqlite".to_string(),
390            format: "sqlite".to_string(),
391            size_bytes: None,
392        };
393
394        let json = serde_json::to_string(&payload).expect("serialize");
395        // size_bytes should be skipped when None
396        assert!(!json.contains("size_bytes"));
397
398        let deserialized: UnencryptedPayload = serde_json::from_str(&json).expect("deserialize");
399        assert_eq!(deserialized.path, "db.sqlite");
400        assert!(deserialized.size_bytes.is_none());
401    }
402
403    #[test]
404    fn test_unencrypted_payload_with_size() {
405        let payload = UnencryptedPayload {
406            path: "large.sqlite".to_string(),
407            format: "sqlite".to_string(),
408            size_bytes: Some(1_000_000),
409        };
410
411        let json = serde_json::to_string(&payload).expect("serialize");
412        assert!(json.contains("size_bytes"));
413        assert!(json.contains("1000000"));
414
415        let deserialized: UnencryptedPayload = serde_json::from_str(&json).expect("deserialize");
416        assert_eq!(deserialized.size_bytes, Some(1_000_000));
417    }
418
419    // ==================== Edge case tests ====================
420
421    #[test]
422    fn test_unencrypted_config_warning_skipped_when_none() {
423        let config = make_unencrypted_config();
424        let json = serde_json::to_string(&config).expect("serialize");
425        assert!(!json.contains("warning"));
426    }
427
428    #[test]
429    fn test_unencrypted_config_warning_included_when_some() {
430        let mut config = make_unencrypted_config();
431        config.warning = Some("Be careful!".to_string());
432        let json = serde_json::to_string(&config).expect("serialize");
433        assert!(json.contains("warning"));
434        assert!(json.contains("Be careful!"));
435    }
436
437    #[test]
438    fn test_clone_preserves_all_fields() {
439        let original = UnencryptedConfig {
440            encrypted: false,
441            version: "3.0".to_string(),
442            payload: UnencryptedPayload {
443                path: "test.sqlite".to_string(),
444                format: "sqlite".to_string(),
445                size_bytes: Some(999),
446            },
447            warning: Some("Cloned warning".to_string()),
448        };
449
450        let cloned = original.clone();
451        assert_eq!(original.encrypted, cloned.encrypted);
452        assert_eq!(original.version, cloned.version);
453        assert_eq!(original.payload.path, cloned.payload.path);
454        assert_eq!(original.payload.size_bytes, cloned.payload.size_bytes);
455        assert_eq!(original.warning, cloned.warning);
456    }
457
458    #[test]
459    fn test_archive_config_clone() {
460        let original = ArchiveConfig::Unencrypted(make_unencrypted_config());
461        let cloned = original.clone();
462        assert!(!cloned.is_encrypted());
463    }
464
465    #[test]
466    fn test_debug_impl_exists() {
467        let config = make_unencrypted_config();
468        let debug_str = format!("{:?}", config);
469        assert!(debug_str.contains("UnencryptedConfig"));
470        assert!(debug_str.contains("version"));
471    }
472
473    #[test]
474    fn test_archive_config_debug_impl() {
475        let encrypted = ArchiveConfig::Encrypted(make_encryption_config());
476        let debug_str = format!("{:?}", encrypted);
477        assert!(debug_str.contains("Encrypted"));
478
479        let unencrypted = ArchiveConfig::Unencrypted(make_unencrypted_config());
480        let debug_str = format!("{:?}", unencrypted);
481        assert!(debug_str.contains("Unencrypted"));
482    }
483}