Skip to main content

azoth/
backup.rs

1//! Backup and restore with optional encryption and compression
2//!
3//! # Example
4//!
5//! ```no_run
6//! use azoth::prelude::*;
7//! use azoth::backup::{BackupOptions, EncryptionKey};
8//!
9//! # fn main() -> Result<()> {
10//! let db = AzothDb::open("./data")?;
11//!
12//! // Backup with encryption and compression
13//! let key = EncryptionKey::generate();
14//! let options = BackupOptions::new()
15//!     .with_encryption(key)
16//!     .with_compression(true);
17//!
18//! db.backup_with_options("./backup", &options)?;
19//!
20//! // Restore
21//! let restored = AzothDb::restore_with_options("./backup", "./restored", &options)?;
22//! # Ok(())
23//! # }
24//! ```
25
26use 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/// Encryption key for backup encryption using age
34///
35/// Uses the age encryption format for simplicity and security
36#[derive(Clone)]
37pub struct EncryptionKey {
38    identity: age::x25519::Identity,
39}
40
41impl EncryptionKey {
42    /// Generate a new random encryption key
43    pub fn generate() -> Self {
44        let identity = age::x25519::Identity::generate();
45        Self { identity }
46    }
47
48    /// Get the age identity string (secret key)
49    ///
50    /// Warning: This exposes the secret key. Store it securely.
51    pub fn to_identity_string(&self) -> String {
52        use age::secrecy::ExposeSecret;
53        self.identity.to_string().expose_secret().to_string()
54    }
55
56    /// Get the public recipient key
57    pub fn to_recipient(&self) -> age::x25519::Recipient {
58        self.identity.to_public()
59    }
60
61    /// Get the public recipient key as string
62    pub fn to_recipient_string(&self) -> String {
63        self.identity.to_public().to_string()
64    }
65
66    /// Create from recipient string (for encryption only)
67    pub fn from_recipient_str(s: &str) -> Result<Self> {
68        // This creates a dummy identity that can only be used for the recipient
69        // We'll store the recipient in the identity field as a workaround
70        let _recipient = s
71            .parse::<age::x25519::Recipient>()
72            .map_err(|e| AzothError::Config(format!("Invalid age recipient: {}", e)))?;
73
74        // For encryption-only use, we generate a dummy identity and only use the recipient
75        // This is a limitation - ideally we'd have separate types for encrypt vs decrypt keys
76        // For now, just return error - users should use from_str for full keys
77        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/// Backup options
95#[derive(Clone, Default)]
96pub struct BackupOptions {
97    /// Optional encryption key
98    pub encryption: Option<EncryptionKey>,
99
100    /// Enable compression (gzip)
101    pub compression: bool,
102
103    /// Compression level (0-9, default 6)
104    pub compression_level: u32,
105}
106
107impl BackupOptions {
108    /// Create new backup options with defaults
109    pub fn new() -> Self {
110        Self {
111            encryption: None,
112            compression: false,
113            compression_level: 6,
114        }
115    }
116
117    /// Enable encryption with the given key
118    pub fn with_encryption(mut self, key: EncryptionKey) -> Self {
119        self.encryption = Some(key);
120        self
121    }
122
123    /// Enable or disable compression
124    pub fn with_compression(mut self, enabled: bool) -> Self {
125        self.compression = enabled;
126        self
127    }
128
129    /// Set compression level (0-9)
130    pub fn with_compression_level(mut self, level: u32) -> Self {
131        self.compression_level = level.min(9);
132        self
133    }
134
135    /// Check if encryption is enabled
136    pub fn is_encrypted(&self) -> bool {
137        self.encryption.is_some()
138    }
139}
140
141impl AzothDb {
142    /// Backup with custom options (encryption and compression)
143    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        // Pause ingestion
169        canonical.pause_ingestion()?;
170        // Ensure we always resume (even on errors).
171        let _guard = IngestionGuard { canonical };
172
173        // Seal
174        let sealed_id = self.canonical().seal()?;
175        tracing::info!("Sealed canonical at event {}", sealed_id);
176
177        // Catch up projector
178        while self.projector().get_lag()? > 0 {
179            self.projector().run_once()?;
180        }
181        tracing::info!("Projector caught up");
182
183        // Backup canonical
184        let canonical_dir = backup_dir.join("canonical");
185        self.canonical().backup_to(&canonical_dir)?;
186
187        // Backup projection
188        let projection_path = backup_dir.join("projection.db");
189        self.projection().backup_to(&projection_path)?;
190
191        // Compression must run before encryption for the combined case.
192        // Otherwise we delete/mutate source paths and the next stage can't find its inputs.
193        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        // Apply encryption if enabled.
200        //
201        // - no compression: encrypt files in canonical/ in-place + projection.db -> projection.db.age
202        // - compression: encrypt compressed artifacts:
203        //   - canonical.tar.zst -> canonical.tar.zst.age
204        //   - projection.db.zst -> projection.db.zst.age
205        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        // Write manifest
219        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    /// Restore from backup with custom options
239    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        // Read manifest
248        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        // Restore must mirror backup ordering.
257        //
258        // - encryption + compression: decrypt *.age -> *.zst, then decompress
259        // - compression only: decompress
260        // - encryption only: decrypt canonical/*.age and projection.db.age
261        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        // Restore using standard method
281        Self::restore_from(backup_dir, target_path)
282    }
283}
284
285// Encryption functions using age
286
287fn encrypt_backup(dir: &Path, key: &EncryptionKey) -> Result<()> {
288    // Encrypt all files in the directory
289    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    // Read original file
302    let plaintext = std::fs::read(path)?;
303
304    // Encrypt with age
305    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    // Write encrypted file with .age extension
321    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    // Remove original file
328    std::fs::remove_file(path)?;
329
330    Ok(())
331}
332
333fn decrypt_backup(dir: &Path, key: &EncryptionKey) -> Result<()> {
334    // Decrypt all .age files in the directory
335    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    // Read encrypted file
348    let encrypted = std::fs::read(path)?;
349
350    // Decrypt with age
351    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    // Determine original filename (remove .age extension)
371    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    // Write decrypted file
378    std::fs::write(&original_path, decrypted)?;
379
380    // Remove encrypted file
381    std::fs::remove_file(path)?;
382
383    Ok(())
384}
385
386// Compression functions using zstd
387
388fn compress_directory(dir: &Path, level: u32) -> Result<()> {
389    // Create tar.zst archive of directory
390    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    // Add the directory (as a directory) so decompression recreates the original layout.
396    // If we archive with "." as the prefix, extraction would spill files into the parent
397    // directory rather than recreating `dir/`, which breaks restore layout expectations.
398    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    // Finish and flush
406    let encoder = tar_builder.into_inner().map_err(AzothError::Io)?;
407    encoder.finish()?;
408
409    // Remove original directory
410    std::fs::remove_dir_all(dir)?;
411
412    Ok(())
413}
414
415fn compress_file(path: &Path, level: u32) -> Result<()> {
416    // Read original file
417    let data = std::fs::read(path)?;
418
419    // Compress with zstd
420    let compressed = zstd::encode_all(&data[..], level as i32)?;
421
422    // Write compressed file with .zst extension
423    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    // Remove original file
430    std::fs::remove_file(path)?;
431
432    Ok(())
433}
434
435fn decompress_directory(dir_path: &Path) -> Result<()> {
436    // Look for tar.zst archive
437    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    // Decompress and extract tar archive
446    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    // Extract to parent directory
451    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    // Remove archive file
457    std::fs::remove_file(&archive_path)?;
458
459    Ok(())
460}
461
462fn decompress_file(path: &Path) -> Result<()> {
463    // Find the .zst file
464    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    // Read and decompress
481    let compressed = std::fs::read(&compressed_path)?;
482    let decompressed = zstd::decode_all(&compressed[..])?;
483
484    // Determine original filename (remove .zst extension)
485    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    // Write decompressed file
492    std::fs::write(&original_path, decompressed)?;
493
494    // Remove compressed file
495    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        // Should be able to parse it back
511        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}