coding_agent_search/pages/
archive_config.rs1use serde::{Deserialize, Deserializer, Serialize, Serializer};
6
7use super::encrypt::EncryptionConfig;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(untagged)]
12pub enum ArchiveConfig {
13 Encrypted(EncryptionConfig),
15 Unencrypted(UnencryptedConfig),
17}
18
19impl ArchiveConfig {
20 pub fn is_encrypted(&self) -> bool {
22 matches!(self, ArchiveConfig::Encrypted(_))
23 }
24
25 pub fn as_encrypted(&self) -> Option<&EncryptionConfig> {
27 match self {
28 ArchiveConfig::Encrypted(cfg) => Some(cfg),
29 ArchiveConfig::Unencrypted(_) => None,
30 }
31 }
32
33 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#[derive(Debug, Clone, Serialize, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct UnencryptedConfig {
46 #[serde(
48 serialize_with = "serialize_unencrypted_false",
49 deserialize_with = "deserialize_unencrypted_false"
50 )]
51 pub encrypted: bool,
52 pub version: String,
54 pub payload: UnencryptedPayload,
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub warning: Option<String>,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct UnencryptedPayload {
65 pub path: String,
67 pub format: String,
69 #[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 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 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 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 #[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 #[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 #[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 #[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 #[test]
295 fn test_untagged_deserialize_encrypted_json() {
296 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 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 #[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 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 #[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}