1use crate::{AzothDb, AzothError, BackupManifest, ProjectionStore, Result};
27use std::fs::File;
28use std::io::{Read, Write};
29use std::path::Path;
30use std::str::FromStr;
31use std::sync::Arc;
32
33#[derive(Clone)]
37pub struct EncryptionKey {
38 identity: age::x25519::Identity,
39}
40
41impl EncryptionKey {
42 pub fn generate() -> Self {
44 let identity = age::x25519::Identity::generate();
45 Self { identity }
46 }
47
48 pub fn to_identity_string(&self) -> String {
52 use age::secrecy::ExposeSecret;
53 self.identity.to_string().expose_secret().to_string()
54 }
55
56 pub fn to_recipient(&self) -> age::x25519::Recipient {
58 self.identity.to_public()
59 }
60
61 pub fn to_recipient_string(&self) -> String {
63 self.identity.to_public().to_string()
64 }
65
66 pub fn from_recipient_str(s: &str) -> Result<Self> {
68 let _recipient = s
71 .parse::<age::x25519::Recipient>()
72 .map_err(|e| AzothError::Config(format!("Invalid age recipient: {}", e)))?;
73
74 Err(AzothError::Config(
78 "Use from_str() with full identity for encryption/decryption".into(),
79 ))
80 }
81}
82
83impl FromStr for EncryptionKey {
84 type Err = AzothError;
85
86 fn from_str(s: &str) -> Result<Self> {
87 let identity = s
88 .parse::<age::x25519::Identity>()
89 .map_err(|e| AzothError::Config(format!("Invalid age identity: {}", e)))?;
90 Ok(Self { identity })
91 }
92}
93
94#[derive(Clone, Default)]
96pub struct BackupOptions {
97 pub encryption: Option<EncryptionKey>,
99
100 pub compression: bool,
102
103 pub compression_level: u32,
105}
106
107impl BackupOptions {
108 pub fn new() -> Self {
110 Self {
111 encryption: None,
112 compression: false,
113 compression_level: 6,
114 }
115 }
116
117 pub fn with_encryption(mut self, key: EncryptionKey) -> Self {
119 self.encryption = Some(key);
120 self
121 }
122
123 pub fn with_compression(mut self, enabled: bool) -> Self {
125 self.compression = enabled;
126 self
127 }
128
129 pub fn with_compression_level(mut self, level: u32) -> Self {
131 self.compression_level = level.min(9);
132 self
133 }
134
135 pub fn is_encrypted(&self) -> bool {
137 self.encryption.is_some()
138 }
139}
140
141impl AzothDb {
142 pub fn backup_with_options<P: AsRef<Path>>(
144 &self,
145 dir: P,
146 options: &BackupOptions,
147 ) -> Result<()> {
148 use crate::CanonicalStore;
149
150 let backup_dir = dir.as_ref();
151 std::fs::create_dir_all(backup_dir)?;
152
153 let canonical = self.canonical().clone();
154 struct IngestionGuard {
155 canonical: Arc<crate::LmdbCanonicalStore>,
156 }
157 impl Drop for IngestionGuard {
158 fn drop(&mut self) {
159 if let Err(e) = self.canonical.clear_seal() {
160 tracing::error!("Failed to clear seal after backup: {}", e);
161 }
162 if let Err(e) = self.canonical.resume_ingestion() {
163 tracing::error!("Failed to resume ingestion after backup: {}", e);
164 }
165 }
166 }
167
168 canonical.pause_ingestion()?;
170 let _guard = IngestionGuard { canonical };
172
173 let sealed_id = self.canonical().seal()?;
175 tracing::info!("Sealed canonical at event {}", sealed_id);
176
177 while self.projector().get_lag()? > 0 {
179 self.projector().run_once()?;
180 }
181 tracing::info!("Projector caught up");
182
183 let canonical_dir = backup_dir.join("canonical");
185 self.canonical().backup_to(&canonical_dir)?;
186
187 let projection_path = backup_dir.join("projection.db");
189 self.projection().backup_to(&projection_path)?;
190
191 if options.compression {
194 tracing::info!("Compressing backup...");
195 compress_directory(&canonical_dir, options.compression_level)?;
196 compress_file(&projection_path, options.compression_level)?;
197 }
198
199 if let Some(ref key) = options.encryption {
206 tracing::info!("Encrypting backup...");
207 if options.compression {
208 let canonical_archive = canonical_dir.with_extension("tar.zst");
209 let projection_archive = projection_path.with_extension("db.zst");
210 encrypt_file(&canonical_archive, key)?;
211 encrypt_file(&projection_archive, key)?;
212 } else {
213 encrypt_backup(&canonical_dir, key)?;
214 encrypt_file(&projection_path, key)?;
215 }
216 }
217
218 let cursor = self.projection().get_cursor()?;
220 let manifest = BackupManifest::new(
221 sealed_id,
222 "lmdb".to_string(),
223 "sqlite".to_string(),
224 cursor,
225 1,
226 self.projection().schema_version()?,
227 );
228
229 let manifest_path = backup_dir.join("manifest.json");
230 let manifest_json = serde_json::to_string_pretty(&manifest)
231 .map_err(|e| AzothError::Serialization(e.to_string()))?;
232 std::fs::write(&manifest_path, manifest_json)?;
233
234 tracing::info!("Backup complete at {}", backup_dir.display());
235 Ok(())
236 }
237
238 pub fn restore_with_options<P: AsRef<Path>, Q: AsRef<Path>>(
240 backup_dir: P,
241 target_path: Q,
242 options: &BackupOptions,
243 ) -> Result<Self> {
244 let backup_dir = backup_dir.as_ref();
245 let target_path = target_path.as_ref();
246
247 let manifest_path = backup_dir.join("manifest.json");
249 let manifest_json = std::fs::read_to_string(&manifest_path)?;
250 let _manifest: BackupManifest = serde_json::from_str(&manifest_json)
251 .map_err(|e| AzothError::Serialization(e.to_string()))?;
252
253 let canonical_dir = backup_dir.join("canonical");
254 let projection_file = backup_dir.join("projection.db");
255
256 if options.compression {
262 if let Some(ref key) = options.encryption {
263 tracing::info!("Decrypting backup artifacts...");
264 let canonical_archive_age = canonical_dir.with_extension("tar.zst.age");
265 let projection_file_age = projection_file.with_extension("db.zst.age");
266 decrypt_file(&canonical_archive_age, key)?;
267 decrypt_file(&projection_file_age, key)?;
268 }
269
270 tracing::info!("Decompressing backup artifacts...");
271 decompress_directory(&canonical_dir)?;
272 decompress_file(&projection_file)?;
273 } else if let Some(ref key) = options.encryption {
274 tracing::info!("Decrypting backup...");
275 decrypt_backup(&canonical_dir, key)?;
276 let projection_file_age = projection_file.with_extension("db.age");
277 decrypt_file(&projection_file_age, key)?;
278 }
279
280 Self::restore_from(backup_dir, target_path)
282 }
283}
284
285fn encrypt_backup(dir: &Path, key: &EncryptionKey) -> Result<()> {
288 let entries = std::fs::read_dir(dir)?;
290 for entry in entries {
291 let entry = entry?;
292 let path = entry.path();
293 if path.is_file() {
294 encrypt_file(&path, key)?;
295 }
296 }
297 Ok(())
298}
299
300fn encrypt_file(path: &Path, key: &EncryptionKey) -> Result<()> {
301 let plaintext = std::fs::read(path)?;
303
304 let recipient = key.to_recipient();
306 let encryptor = age::Encryptor::with_recipients(vec![Box::new(recipient)])
307 .expect("We provided a recipient");
308
309 let mut encrypted = vec![];
310 let mut writer = encryptor
311 .wrap_output(&mut encrypted)
312 .map_err(|e| AzothError::Encryption(format!("Failed to wrap output: {}", e)))?;
313 writer
314 .write_all(&plaintext)
315 .map_err(|e| AzothError::Encryption(format!("Failed to write: {}", e)))?;
316 writer
317 .finish()
318 .map_err(|e| AzothError::Encryption(format!("Failed to finish: {}", e)))?;
319
320 let encrypted_path = path.with_extension(format!(
322 "{}.age",
323 path.extension().and_then(|s| s.to_str()).unwrap_or("dat")
324 ));
325 std::fs::write(&encrypted_path, encrypted)?;
326
327 std::fs::remove_file(path)?;
329
330 Ok(())
331}
332
333fn decrypt_backup(dir: &Path, key: &EncryptionKey) -> Result<()> {
334 let entries = std::fs::read_dir(dir)?;
336 for entry in entries {
337 let entry = entry?;
338 let path = entry.path();
339 if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("age") {
340 decrypt_file(&path, key)?;
341 }
342 }
343 Ok(())
344}
345
346fn decrypt_file(path: &Path, key: &EncryptionKey) -> Result<()> {
347 let encrypted = std::fs::read(path)?;
349
350 let decryptor = match age::Decryptor::new(&encrypted[..])
352 .map_err(|e| AzothError::Encryption(format!("Failed to create decryptor: {}", e)))?
353 {
354 age::Decryptor::Recipients(d) => d,
355 _ => {
356 return Err(AzothError::Encryption(
357 "Unexpected decryptor type".to_string(),
358 ))
359 }
360 };
361
362 let mut decrypted = vec![];
363 let mut reader = decryptor
364 .decrypt(std::iter::once(&key.identity as &dyn age::Identity))
365 .map_err(|e| AzothError::Encryption(format!("Failed to decrypt: {}", e)))?;
366 reader
367 .read_to_end(&mut decrypted)
368 .map_err(|e| AzothError::Encryption(format!("Failed to read: {}", e)))?;
369
370 let original_path = if let Some(stem) = path.file_stem() {
372 path.with_file_name(stem)
373 } else {
374 path.with_extension("")
375 };
376
377 std::fs::write(&original_path, decrypted)?;
379
380 std::fs::remove_file(path)?;
382
383 Ok(())
384}
385
386fn compress_directory(dir: &Path, level: u32) -> Result<()> {
389 let archive_path = dir.with_extension("tar.zst");
391 let archive_file = File::create(&archive_path)?;
392 let encoder = zstd::Encoder::new(archive_file, level as i32)?;
393 let mut tar_builder = tar::Builder::new(encoder);
394
395 let dir_name = dir
399 .file_name()
400 .ok_or_else(|| AzothError::Config("Cannot get directory name".to_string()))?;
401 tar_builder
402 .append_dir_all(dir_name, dir)
403 .map_err(AzothError::Io)?;
404
405 let encoder = tar_builder.into_inner().map_err(AzothError::Io)?;
407 encoder.finish()?;
408
409 std::fs::remove_dir_all(dir)?;
411
412 Ok(())
413}
414
415fn compress_file(path: &Path, level: u32) -> Result<()> {
416 let data = std::fs::read(path)?;
418
419 let compressed = zstd::encode_all(&data[..], level as i32)?;
421
422 let compressed_path = path.with_extension(format!(
424 "{}.zst",
425 path.extension().and_then(|s| s.to_str()).unwrap_or("dat")
426 ));
427 std::fs::write(&compressed_path, compressed)?;
428
429 std::fs::remove_file(path)?;
431
432 Ok(())
433}
434
435fn decompress_directory(dir_path: &Path) -> Result<()> {
436 let archive_path = dir_path.with_extension("tar.zst");
438 if !archive_path.exists() {
439 return Err(AzothError::Io(std::io::Error::new(
440 std::io::ErrorKind::NotFound,
441 format!("Archive not found: {}", archive_path.display()),
442 )));
443 }
444
445 let archive_file = File::open(&archive_path)?;
447 let decoder = zstd::Decoder::new(archive_file)?;
448 let mut tar_archive = tar::Archive::new(decoder);
449
450 let parent = dir_path
452 .parent()
453 .ok_or_else(|| AzothError::Config("Cannot get parent directory".to_string()))?;
454 tar_archive.unpack(parent).map_err(AzothError::Io)?;
455
456 std::fs::remove_file(&archive_path)?;
458
459 Ok(())
460}
461
462fn decompress_file(path: &Path) -> Result<()> {
463 let compressed_path = if path.extension().and_then(|s| s.to_str()) == Some("zst") {
465 path.to_path_buf()
466 } else {
467 path.with_extension(format!(
468 "{}.zst",
469 path.extension().and_then(|s| s.to_str()).unwrap_or("dat")
470 ))
471 };
472
473 if !compressed_path.exists() {
474 return Err(AzothError::Io(std::io::Error::new(
475 std::io::ErrorKind::NotFound,
476 format!("Compressed file not found: {}", compressed_path.display()),
477 )));
478 }
479
480 let compressed = std::fs::read(&compressed_path)?;
482 let decompressed = zstd::decode_all(&compressed[..])?;
483
484 let original_path = if let Some(stem) = compressed_path.file_stem() {
486 compressed_path.with_file_name(stem)
487 } else {
488 path.to_path_buf()
489 };
490
491 std::fs::write(&original_path, decompressed)?;
493
494 std::fs::remove_file(&compressed_path)?;
496
497 Ok(())
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503
504 #[test]
505 fn test_encryption_key_generation() {
506 let key = EncryptionKey::generate();
507 let key_str = key.to_identity_string();
508 assert!(!key_str.is_empty());
509
510 let parsed = EncryptionKey::from_str(&key_str).unwrap();
512 assert_eq!(parsed.to_identity_string(), key_str);
513 }
514
515 #[test]
516 fn test_encryption_key_recipient() {
517 let key = EncryptionKey::generate();
518 let recipient_str = key.to_recipient_string();
519 assert!(recipient_str.starts_with("age1"));
520 }
521
522 #[test]
523 fn test_backup_options() {
524 let options = BackupOptions::new()
525 .with_encryption(EncryptionKey::generate())
526 .with_compression(true)
527 .with_compression_level(9);
528
529 assert!(options.is_encrypted());
530 assert!(options.compression);
531 assert_eq!(options.compression_level, 9);
532 }
533}