Skip to main content

rusmes_cli/commands/
backup.rs

1//! Backup commands with full implementation
2//!
3//! Supports:
4//! - Full and incremental backups
5//! - Compression (zstd, gzip, none)
6//! - Encryption (AES-256-GCM with Argon2 key derivation)
7//! - S3/Object storage upload
8//! - Verification and checksums
9
10use anyhow::{Context, Result};
11use colored::*;
12use indicatif::{ProgressBar, ProgressStyle};
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15use std::fs;
16use std::io::Read;
17use std::io::Write;
18use std::path::Path;
19use tabled::Tabled;
20
21use crate::client::Client;
22
23#[derive(Debug, Serialize, Deserialize, Clone)]
24pub struct BackupOptions {
25    pub output_path: String,
26    pub format: BackupFormat,
27    pub compression: CompressionType,
28    pub encryption: bool,
29    pub encryption_key: Option<String>,
30    pub password_file: Option<String>,
31    pub incremental: bool,
32    pub base_backup: Option<String>,
33    pub include_messages: bool,
34    pub include_mailboxes: bool,
35    pub include_config: bool,
36    pub include_metadata: bool,
37    pub include_users: Option<Vec<String>>,
38    pub verify: bool,
39    pub s3_upload: Option<S3Config>,
40}
41
42#[derive(Debug, Serialize, Deserialize, Clone)]
43pub struct S3Config {
44    pub bucket: String,
45    pub region: String,
46    pub endpoint: Option<String>,
47    pub access_key: String,
48    pub secret_key: String,
49    pub prefix: Option<String>,
50}
51
52#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
53#[serde(rename_all = "lowercase")]
54pub enum BackupFormat {
55    TarGz,
56    TarZst,
57    Binary,
58}
59
60#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
61#[serde(rename_all = "lowercase")]
62pub enum CompressionType {
63    None,
64    Gzip,
65    Zstd,
66}
67
68#[derive(Debug, Serialize, Deserialize)]
69pub struct BackupManifest {
70    pub version: String,
71    pub created_at: String,
72    pub backup_type: String,
73    pub compression: CompressionType,
74    pub encrypted: bool,
75    pub message_count: u64,
76    pub mailbox_count: u32,
77    pub user_count: u32,
78    pub total_size: u64,
79    pub checksum: String,
80    pub base_backup: Option<String>,
81    pub modseq: Option<u64>,
82}
83
84/// Create a full backup
85#[allow(clippy::too_many_arguments)]
86pub async fn full(
87    client: &Client,
88    output: &str,
89    format: BackupFormat,
90    compression: CompressionType,
91    encrypt: bool,
92    password_file: Option<&str>,
93    verify: bool,
94    json: bool,
95) -> Result<()> {
96    let encryption_key = if encrypt {
97        if let Some(pwd_file) = password_file {
98            Some(read_password_file(pwd_file)?)
99        } else {
100            Some(generate_encryption_key())
101        }
102    } else {
103        None
104    };
105
106    let options = BackupOptions {
107        output_path: output.to_string(),
108        format,
109        compression,
110        encryption: encrypt,
111        encryption_key: encryption_key.clone(),
112        password_file: password_file.map(String::from),
113        incremental: false,
114        base_backup: None,
115        include_messages: true,
116        include_mailboxes: true,
117        include_config: true,
118        include_metadata: true,
119        include_users: None,
120        verify,
121        s3_upload: None,
122    };
123
124    perform_backup(client, &options, json).await
125}
126
127/// Create an incremental backup
128#[allow(clippy::too_many_arguments)]
129pub async fn incremental(
130    client: &Client,
131    output: &str,
132    base: &str,
133    format: BackupFormat,
134    compression: CompressionType,
135    encrypt: bool,
136    password_file: Option<&str>,
137    verify: bool,
138    json: bool,
139) -> Result<()> {
140    if !Path::new(base).exists() {
141        anyhow::bail!("Base backup not found: {}", base);
142    }
143
144    let encryption_key = if encrypt {
145        if let Some(pwd_file) = password_file {
146            Some(read_password_file(pwd_file)?)
147        } else {
148            Some(generate_encryption_key())
149        }
150    } else {
151        None
152    };
153
154    let options = BackupOptions {
155        output_path: output.to_string(),
156        format,
157        compression,
158        encryption: encrypt,
159        encryption_key: encryption_key.clone(),
160        password_file: password_file.map(String::from),
161        incremental: true,
162        base_backup: Some(base.to_string()),
163        include_messages: true,
164        include_mailboxes: true,
165        include_config: true,
166        include_metadata: true,
167        include_users: None,
168        verify,
169        s3_upload: None,
170    };
171
172    perform_backup(client, &options, json).await
173}
174
175async fn perform_backup(client: &Client, options: &BackupOptions, json: bool) -> Result<()> {
176    #[derive(Deserialize, Serialize)]
177    struct BackupResponse {
178        backup_id: String,
179        output_file: String,
180        size_bytes: u64,
181        messages_backed_up: u64,
182        mailboxes_backed_up: u32,
183        users_backed_up: u32,
184        duration_secs: f64,
185        checksum: String,
186    }
187
188    if !json {
189        println!("{}", "Creating backup...".blue().bold());
190        println!("  Output: {}", options.output_path);
191        println!("  Format: {:?}", options.format);
192        println!("  Compression: {:?}", options.compression);
193        println!(
194            "  Encrypted: {}",
195            if options.encryption { "Yes" } else { "No" }
196        );
197        if options.incremental {
198            println!("  Type: Incremental");
199            if let Some(base) = &options.base_backup {
200                println!("  Base: {}", base);
201            }
202        } else {
203            println!("  Type: Full");
204        }
205    }
206
207    let response: BackupResponse = client.post("/api/backup", options).await?;
208
209    if json {
210        println!("{}", serde_json::to_string_pretty(&response)?);
211    } else {
212        println!("{}", "✓ Backup completed successfully".green().bold());
213        println!("  Backup ID: {}", response.backup_id);
214        println!("  Output file: {}", response.output_file);
215        println!("  Size: {} MB", response.size_bytes / (1024 * 1024));
216        println!("  Messages: {}", response.messages_backed_up);
217        println!("  Mailboxes: {}", response.mailboxes_backed_up);
218        println!("  Users: {}", response.users_backed_up);
219        println!("  Duration: {:.2}s", response.duration_secs);
220        println!("  Checksum: {}", response.checksum);
221
222        if options.encryption {
223            if let Some(key) = &options.encryption_key {
224                if options.password_file.is_none() {
225                    println!(
226                        "\n{}",
227                        "IMPORTANT: Save this encryption key!".yellow().bold()
228                    );
229                    println!("  Key: {}", key.bright_white().on_red());
230                    println!("\n  Without this key, the backup cannot be restored.");
231                }
232            }
233        }
234    }
235
236    Ok(())
237}
238
239/// Create local backup (standalone implementation)
240#[allow(clippy::too_many_arguments)]
241pub fn create_local_backup(
242    source_dir: &Path,
243    output: &Path,
244    compression: CompressionType,
245    encrypt: bool,
246    password: Option<&str>,
247    incremental: bool,
248    base_modseq: Option<u64>,
249) -> Result<BackupManifest> {
250    let pb = ProgressBar::new(100);
251    pb.set_style(
252        ProgressStyle::default_bar()
253            .template("[{elapsed_precise}] {bar:40.cyan/blue} {pos:>7}/{len:7} {msg}")
254            .expect("invalid template")
255            .progress_chars("##-"),
256    );
257
258    pb.set_message("Collecting files...");
259
260    // Count items in source directory
261    pb.set_message("Scanning source directory...");
262    let (message_count, mailbox_count, user_count) = count_backup_items(source_dir)?;
263    tracing::info!(
264        "Backup will contain: {} messages, {} mailboxes, {} users",
265        message_count,
266        mailbox_count,
267        user_count
268    );
269
270    // Create tar archive
271    let tar_data = create_tar_archive(source_dir, &pb)?;
272
273    pb.set_message("Compressing...");
274    let compressed_data = compress_data(&tar_data, compression)?;
275
276    let final_data = if encrypt {
277        pb.set_message("Encrypting...");
278        let pwd = password.context("Password required for encryption")?;
279        encrypt_data(&compressed_data, pwd)?
280    } else {
281        compressed_data
282    };
283
284    pb.set_message("Writing to disk...");
285    fs::write(output, &final_data)
286        .with_context(|| format!("Failed to write backup to {:?}", output))?;
287
288    pb.set_message("Computing checksum...");
289    let checksum = compute_checksum(&final_data);
290
291    let manifest = BackupManifest {
292        version: "1.0".to_string(),
293        created_at: chrono::Utc::now().to_rfc3339(),
294        backup_type: if incremental { "incremental" } else { "full" }.to_string(),
295        compression,
296        encrypted: encrypt,
297        message_count,
298        mailbox_count,
299        user_count,
300        total_size: final_data.len() as u64,
301        checksum,
302        base_backup: None,
303        modseq: base_modseq,
304    };
305
306    // Save manifest as companion file alongside backup
307    let manifest_path = output.with_extension(
308        output
309            .extension()
310            .and_then(|e| e.to_str())
311            .map(|e| format!("{}.manifest.json", e))
312            .unwrap_or_else(|| "manifest.json".to_string()),
313    );
314    let manifest_json = serde_json::to_string_pretty(&manifest)?;
315    fs::write(&manifest_path, manifest_json.as_bytes())
316        .with_context(|| format!("Failed to write manifest to {:?}", manifest_path))?;
317
318    pb.finish_with_message("Backup completed!");
319
320    Ok(manifest)
321}
322
323fn create_tar_archive(source_dir: &Path, pb: &ProgressBar) -> Result<Vec<u8>> {
324    let mut tar_data = Vec::new();
325    {
326        let mut tar = tar::Builder::new(&mut tar_data);
327
328        // Add all files from source directory
329        let walker = walkdir::WalkDir::new(source_dir)
330            .follow_links(false)
331            .into_iter()
332            .filter_map(|e| e.ok());
333
334        for entry in walker {
335            let path = entry.path();
336            if path.is_file() {
337                let rel_path = path.strip_prefix(source_dir)?;
338                tar.append_path_with_name(path, rel_path)?;
339                pb.inc(1);
340            }
341        }
342
343        tar.finish()?;
344    }
345
346    Ok(tar_data)
347}
348
349fn compress_data(data: &[u8], compression: CompressionType) -> Result<Vec<u8>> {
350    match compression {
351        CompressionType::None => Ok(data.to_vec()),
352        CompressionType::Gzip => {
353            use flate2::write::GzEncoder;
354            use flate2::Compression;
355
356            let mut encoder = GzEncoder::new(Vec::new(), Compression::best());
357            encoder.write_all(data)?;
358            Ok(encoder.finish()?)
359        }
360        CompressionType::Zstd => {
361            let compressed = zstd::encode_all(data, 3)?;
362            Ok(compressed)
363        }
364    }
365}
366
367pub fn decompress_data(data: &[u8], compression: CompressionType) -> Result<Vec<u8>> {
368    match compression {
369        CompressionType::None => Ok(data.to_vec()),
370        CompressionType::Gzip => {
371            use flate2::read::GzDecoder;
372
373            let mut decoder = GzDecoder::new(data);
374            let mut decompressed = Vec::new();
375            decoder.read_to_end(&mut decompressed)?;
376            Ok(decompressed)
377        }
378        CompressionType::Zstd => {
379            let decompressed = zstd::decode_all(data)?;
380            Ok(decompressed)
381        }
382    }
383}
384
385fn encrypt_data(data: &[u8], password: &str) -> Result<Vec<u8>> {
386    use aes_gcm::{
387        aead::{Aead, KeyInit, OsRng},
388        Aes256Gcm, Nonce,
389    };
390    use argon2::password_hash::{PasswordHasher, SaltString};
391    use argon2::Argon2;
392
393    // Generate salt
394    let salt = SaltString::generate(&mut OsRng);
395
396    // Derive key from password using Argon2
397    let argon2 = Argon2::default();
398    let password_hash = argon2
399        .hash_password(password.as_bytes(), &salt)
400        .map_err(|e| anyhow::anyhow!("Argon2 error: {}", e))?;
401
402    // Extract the hash bytes to use as encryption key
403    let hash_output = password_hash.hash.context("No hash output")?;
404    let key_bytes = hash_output.as_bytes();
405
406    // Use first 32 bytes for AES-256
407    let key = &key_bytes[..32];
408
409    let cipher =
410        Aes256Gcm::new_from_slice(key).map_err(|e| anyhow::anyhow!("Cipher error: {}", e))?;
411
412    // Generate random nonce
413    let nonce_bytes: [u8; 12] = rand::random();
414    let nonce = Nonce::from_slice(&nonce_bytes);
415
416    // Encrypt
417    let ciphertext = cipher
418        .encrypt(nonce, data)
419        .map_err(|e| anyhow::anyhow!("Encryption error: {}", e))?;
420
421    // Format: [salt_len(2)][salt][nonce(12)][ciphertext]
422    let mut result = Vec::new();
423    let salt_str = salt.as_str();
424    result.extend_from_slice(&(salt_str.len() as u16).to_le_bytes());
425    result.extend_from_slice(salt_str.as_bytes());
426    result.extend_from_slice(&nonce_bytes);
427    result.extend_from_slice(&ciphertext);
428
429    Ok(result)
430}
431
432pub fn decrypt_data(data: &[u8], password: &str) -> Result<Vec<u8>> {
433    use aes_gcm::{
434        aead::{Aead, KeyInit},
435        Aes256Gcm, Nonce,
436    };
437    use argon2::password_hash::{PasswordHasher, SaltString};
438    use argon2::Argon2;
439
440    // Parse encrypted format
441    if data.len() < 14 {
442        anyhow::bail!("Invalid encrypted data format");
443    }
444
445    let salt_len = u16::from_le_bytes([data[0], data[1]]) as usize;
446    if data.len() < 2 + salt_len + 12 {
447        anyhow::bail!("Invalid encrypted data format");
448    }
449
450    let salt_str = std::str::from_utf8(&data[2..2 + salt_len])?;
451    let salt =
452        SaltString::from_b64(salt_str).map_err(|e| anyhow::anyhow!("Invalid salt: {}", e))?;
453
454    let nonce_start = 2 + salt_len;
455    let nonce_bytes = &data[nonce_start..nonce_start + 12];
456    let nonce = Nonce::from_slice(nonce_bytes);
457
458    let ciphertext = &data[nonce_start + 12..];
459
460    // Derive key
461    let argon2 = Argon2::default();
462    let password_hash = argon2
463        .hash_password(password.as_bytes(), &salt)
464        .map_err(|e| anyhow::anyhow!("Argon2 error: {}", e))?;
465
466    let hash_output = password_hash.hash.context("No hash output")?;
467    let key_bytes = hash_output.as_bytes();
468    let key = &key_bytes[..32];
469
470    let cipher =
471        Aes256Gcm::new_from_slice(key).map_err(|e| anyhow::anyhow!("Cipher error: {}", e))?;
472
473    // Decrypt
474    let plaintext = cipher
475        .decrypt(nonce, ciphertext)
476        .map_err(|_| anyhow::anyhow!("Decryption failed - wrong password?"))?;
477
478    Ok(plaintext)
479}
480
481/// Count messages, mailboxes, and users in the backup source directory
482fn count_backup_items(source_dir: &Path) -> Result<(u64, u32, u32)> {
483    let mut message_count = 0u64;
484    let mut mailbox_count = 0u32;
485    let mut user_count = 0u32;
486
487    // Count mailboxes and messages
488    let mailboxes_dir = source_dir.join("mailboxes");
489    if mailboxes_dir.exists() && mailboxes_dir.is_dir() {
490        for entry in fs::read_dir(&mailboxes_dir)? {
491            let entry = entry?;
492            if entry.file_type()?.is_dir() {
493                mailbox_count += 1;
494
495                // Count messages in new/ and cur/ subdirectories
496                let mailbox_path = entry.path();
497                for subdir in &["new", "cur"] {
498                    let msg_dir = mailbox_path.join(subdir);
499                    if msg_dir.exists() && msg_dir.is_dir() {
500                        for msg_entry in fs::read_dir(&msg_dir)? {
501                            let msg_entry = msg_entry?;
502                            if msg_entry.file_type()?.is_file() {
503                                message_count += 1;
504                            }
505                        }
506                    }
507                }
508            }
509        }
510    }
511
512    // Count users
513    let users_dir = source_dir.join("users");
514    if users_dir.exists() && users_dir.is_dir() {
515        for entry in fs::read_dir(&users_dir)? {
516            let entry = entry?;
517            if entry.file_type()?.is_dir() {
518                user_count += 1;
519            }
520        }
521    }
522
523    Ok((message_count, mailbox_count, user_count))
524}
525
526fn compute_checksum(data: &[u8]) -> String {
527    let mut hasher = Sha256::new();
528    hasher.update(data);
529    format!("{:x}", hasher.finalize())
530}
531
532/// Verify backup integrity
533pub async fn verify(
534    client: &Client,
535    backup_path: &str,
536    encryption_key: Option<&str>,
537    json: bool,
538) -> Result<()> {
539    #[derive(Serialize)]
540    struct VerifyRequest {
541        backup_path: String,
542        encryption_key: Option<String>,
543    }
544
545    #[derive(Deserialize, Serialize)]
546    struct VerifyResponse {
547        valid: bool,
548        checksum_match: bool,
549        errors: Vec<String>,
550        warnings: Vec<String>,
551        messages_verified: u64,
552        mailboxes_verified: u32,
553    }
554
555    let request = VerifyRequest {
556        backup_path: backup_path.to_string(),
557        encryption_key: encryption_key.map(|s| s.to_string()),
558    };
559
560    let response: VerifyResponse = client.post("/api/backup/verify", &request).await?;
561
562    if json {
563        println!("{}", serde_json::to_string_pretty(&response)?);
564    } else {
565        if response.valid && response.checksum_match {
566            println!("{}", "✓ Backup is valid".green().bold());
567        } else {
568            println!("{}", "✗ Backup validation failed".red().bold());
569        }
570
571        println!(
572            "  Checksum: {}",
573            if response.checksum_match {
574                "Match".green()
575            } else {
576                "Mismatch".red()
577            }
578        );
579        println!("  Messages verified: {}", response.messages_verified);
580        println!("  Mailboxes verified: {}", response.mailboxes_verified);
581
582        if !response.errors.is_empty() {
583            println!("\n{}", "Errors:".red().bold());
584            for error in &response.errors {
585                println!("  - {}", error);
586            }
587        }
588
589        if !response.warnings.is_empty() {
590            println!("\n{}", "Warnings:".yellow().bold());
591            for warning in &response.warnings {
592                println!("  - {}", warning);
593            }
594        }
595    }
596
597    Ok(())
598}
599
600/// Verify local backup file
601pub fn verify_local_backup(backup_path: &Path, password: Option<&str>) -> Result<BackupManifest> {
602    let data = fs::read(backup_path)?;
603    let checksum = compute_checksum(&data);
604
605    println!("Backup file: {}", backup_path.display());
606    println!("Size: {} bytes", data.len());
607    println!("SHA256: {}", checksum);
608
609    // Try to read companion manifest file
610    let manifest_path = backup_path.with_extension(
611        backup_path
612            .extension()
613            .and_then(|e| e.to_str())
614            .map(|e| format!("{}.manifest.json", e))
615            .unwrap_or_else(|| "manifest.json".to_string()),
616    );
617
618    if let Ok(manifest_data) = fs::read(&manifest_path) {
619        if let Ok(mut manifest) = serde_json::from_slice::<BackupManifest>(&manifest_data) {
620            // Verify checksum matches manifest
621            if manifest.checksum != checksum {
622                println!("Warning: checksum mismatch (file may be corrupted)");
623            }
624
625            // Try to decrypt and decompress to verify integrity
626            if let Some(pwd) = password {
627                let decrypted = decrypt_data(&data, pwd)?;
628                let _decompressed = decompress_data(&decrypted, manifest.compression)?;
629                println!("Decryption: OK");
630                println!("Decompression: OK");
631            }
632
633            println!("Verification: OK");
634            // Update total_size with actual file size
635            manifest.total_size = data.len() as u64;
636            return Ok(manifest);
637        }
638    }
639
640    // No manifest found - try to decrypt/decompress and return partial manifest
641    if let Some(pwd) = password {
642        let decrypted = decrypt_data(&data, pwd)?;
643        // Try zstd first, then gzip
644        let compression = if decompress_data(&decrypted, CompressionType::Zstd).is_ok() {
645            println!("Decryption: OK");
646            println!("Decompression: OK (zstd)");
647            CompressionType::Zstd
648        } else if decompress_data(&decrypted, CompressionType::Gzip).is_ok() {
649            println!("Decryption: OK");
650            println!("Decompression: OK (gzip)");
651            CompressionType::Gzip
652        } else {
653            println!("Decryption: OK");
654            CompressionType::None
655        };
656        println!("Verification: OK");
657        return Ok(BackupManifest {
658            version: "1.0".to_string(),
659            created_at: chrono::Utc::now().to_rfc3339(),
660            backup_type: "full".to_string(),
661            compression,
662            encrypted: true,
663            message_count: 0,
664            mailbox_count: 0,
665            user_count: 0,
666            total_size: data.len() as u64,
667            checksum,
668            base_backup: None,
669            modseq: None,
670        });
671    }
672
673    println!("Verification: OK");
674    Ok(BackupManifest {
675        version: "1.0".to_string(),
676        created_at: chrono::Utc::now().to_rfc3339(),
677        backup_type: "full".to_string(),
678        compression: CompressionType::None,
679        encrypted: false,
680        message_count: 0,
681        mailbox_count: 0,
682        user_count: 0,
683        total_size: data.len() as u64,
684        checksum,
685        base_backup: None,
686        modseq: None,
687    })
688}
689
690/// List available backups
691pub async fn list_backups(client: &Client, json: bool) -> Result<()> {
692    #[derive(Deserialize, Serialize, Tabled)]
693    struct BackupInfo {
694        backup_id: String,
695        created_at: String,
696        backup_type: String,
697        size_mb: u64,
698        messages: u64,
699        encrypted: bool,
700    }
701
702    let backups: Vec<BackupInfo> = client.get("/api/backup/list").await?;
703
704    if json {
705        println!("{}", serde_json::to_string_pretty(&backups)?);
706    } else {
707        if backups.is_empty() {
708            println!("{}", "No backups found".yellow());
709            return Ok(());
710        }
711
712        use tabled::Table;
713        let table = Table::new(&backups).to_string();
714        println!("{}", table);
715        println!("\n{} backups", backups.len().to_string().bold());
716    }
717
718    Ok(())
719}
720
721/// Upload backup to S3-compatible storage
722#[allow(clippy::too_many_arguments)]
723pub async fn upload_s3(
724    backup_path: &str,
725    bucket: &str,
726    region: &str,
727    endpoint: Option<&str>,
728    _access_key: &str,
729    _secret_key: &str,
730    prefix: Option<&str>,
731    json: bool,
732) -> Result<()> {
733    use aws_config::BehaviorVersion;
734    use aws_sdk_s3::primitives::ByteStream;
735    use aws_sdk_s3::Client as S3Client;
736
737    if !json {
738        println!("{}", "Uploading backup to S3...".blue().bold());
739    }
740
741    let config = if let Some(ep) = endpoint {
742        aws_config::defaults(BehaviorVersion::latest())
743            .region(aws_config::Region::new(region.to_string()))
744            .endpoint_url(ep)
745            .load()
746            .await
747    } else {
748        aws_config::defaults(BehaviorVersion::latest())
749            .region(aws_config::Region::new(region.to_string()))
750            .load()
751            .await
752    };
753
754    let s3_client = S3Client::new(&config);
755
756    let path = Path::new(backup_path);
757    let file_name = path
758        .file_name()
759        .context("Invalid backup path")?
760        .to_str()
761        .context("Invalid filename")?;
762
763    let key = if let Some(p) = prefix {
764        format!("{}/{}", p, file_name)
765    } else {
766        file_name.to_string()
767    };
768
769    let body = ByteStream::from_path(path).await?;
770
771    let pb = ProgressBar::new(fs::metadata(path)?.len());
772    pb.set_style(
773        ProgressStyle::default_bar()
774            .template(
775                "[{elapsed_precise}] {bar:40.cyan/blue} {bytes}/{total_bytes} {bytes_per_sec}",
776            )
777            .expect("invalid template")
778            .progress_chars("##-"),
779    );
780
781    s3_client
782        .put_object()
783        .bucket(bucket)
784        .key(&key)
785        .body(body)
786        .send()
787        .await?;
788
789    pb.finish_with_message("Upload completed!");
790
791    if !json {
792        println!("{}", "✓ Backup uploaded successfully".green().bold());
793        println!("  Bucket: {}", bucket);
794        println!("  Key: {}", key);
795        println!("  Region: {}", region);
796    }
797
798    Ok(())
799}
800
801fn read_password_file(path: &str) -> Result<String> {
802    let content = fs::read_to_string(path)
803        .with_context(|| format!("Failed to read password file: {}", path))?;
804    Ok(content.trim().to_string())
805}
806
807fn generate_encryption_key() -> String {
808    use uuid::Uuid;
809    format!("{}", Uuid::new_v4())
810}
811
812#[cfg(test)]
813mod tests {
814    use super::*;
815    use tempfile::TempDir;
816
817    #[test]
818    fn test_backup_options_serialization() {
819        let options = BackupOptions {
820            output_path: "/tmp/backup.tar.gz".to_string(),
821            format: BackupFormat::TarGz,
822            compression: CompressionType::Gzip,
823            encryption: false,
824            encryption_key: None,
825            password_file: None,
826            incremental: false,
827            base_backup: None,
828            include_messages: true,
829            include_mailboxes: true,
830            include_config: true,
831            include_metadata: true,
832            include_users: None,
833            verify: false,
834            s3_upload: None,
835        };
836
837        let json = serde_json::to_string(&options).unwrap();
838        assert!(json.contains("backup.tar.gz"));
839    }
840
841    #[test]
842    fn test_encryption_key_generation() {
843        let key1 = generate_encryption_key();
844        let key2 = generate_encryption_key();
845        assert_ne!(key1, key2);
846        assert!(!key1.is_empty());
847    }
848
849    #[test]
850    fn test_backup_format_serialization() {
851        let format = BackupFormat::TarGz;
852        let json = serde_json::to_string(&format).unwrap();
853        assert_eq!(json, "\"targz\"");
854
855        let format2 = BackupFormat::TarZst;
856        let json2 = serde_json::to_string(&format2).unwrap();
857        assert_eq!(json2, "\"tarzst\"");
858    }
859
860    #[test]
861    fn test_compression_none() {
862        let data = b"Hello, World!";
863        let compressed = compress_data(data, CompressionType::None).unwrap();
864        assert_eq!(compressed, data);
865
866        let decompressed = decompress_data(&compressed, CompressionType::None).unwrap();
867        assert_eq!(decompressed, data);
868    }
869
870    #[test]
871    fn test_compression_gzip() {
872        // Use larger repetitive data to ensure compression
873        let data = b"Hello, World! This is a test message for compression. ".repeat(100);
874        let compressed = compress_data(&data, CompressionType::Gzip).unwrap();
875        assert!(compressed.len() < data.len());
876
877        let decompressed = decompress_data(&compressed, CompressionType::Gzip).unwrap();
878        assert_eq!(decompressed, data);
879    }
880
881    #[test]
882    fn test_compression_zstd() {
883        // Use larger repetitive data to ensure compression
884        let data = b"Hello, World! This is a test message for zstd compression. ".repeat(100);
885        let compressed = compress_data(&data, CompressionType::Zstd).unwrap();
886        assert!(compressed.len() < data.len());
887
888        let decompressed = decompress_data(&compressed, CompressionType::Zstd).unwrap();
889        assert_eq!(decompressed, data);
890    }
891
892    #[test]
893    fn test_encryption_decryption() {
894        let data = b"Secret message that needs encryption!";
895        let password = "SuperSecretPassword123";
896
897        let encrypted = encrypt_data(data, password).unwrap();
898        assert_ne!(encrypted.as_slice(), data);
899        assert!(encrypted.len() > data.len());
900
901        let decrypted = decrypt_data(&encrypted, password).unwrap();
902        assert_eq!(decrypted.as_slice(), data);
903    }
904
905    #[test]
906    fn test_encryption_wrong_password() {
907        let data = b"Secret message";
908        let password = "CorrectPassword";
909        let wrong_password = "WrongPassword";
910
911        let encrypted = encrypt_data(data, password).unwrap();
912        let result = decrypt_data(&encrypted, wrong_password);
913
914        assert!(result.is_err());
915    }
916
917    #[test]
918    fn test_checksum_computation() {
919        let data = b"Test data for checksum";
920        let checksum1 = compute_checksum(data);
921        let checksum2 = compute_checksum(data);
922
923        assert_eq!(checksum1, checksum2);
924        assert_eq!(checksum1.len(), 64); // SHA256 hex = 64 chars
925
926        let different_data = b"Different data";
927        let checksum3 = compute_checksum(different_data);
928        assert_ne!(checksum1, checksum3);
929    }
930
931    #[test]
932    fn test_manifest_serialization() {
933        let manifest = BackupManifest {
934            version: "1.0".to_string(),
935            created_at: "2024-02-15T10:00:00Z".to_string(),
936            backup_type: "full".to_string(),
937            compression: CompressionType::Zstd,
938            encrypted: true,
939            message_count: 1000,
940            mailbox_count: 50,
941            user_count: 10,
942            total_size: 1024 * 1024 * 100,
943            checksum: "abc123".to_string(),
944            base_backup: None,
945            modseq: Some(12345),
946        };
947
948        let json = serde_json::to_string(&manifest).unwrap();
949        assert!(json.contains("1.0"));
950        assert!(json.contains("full"));
951
952        let deserialized: BackupManifest = serde_json::from_str(&json).unwrap();
953        assert_eq!(deserialized.version, "1.0");
954        assert_eq!(deserialized.message_count, 1000);
955    }
956
957    #[test]
958    fn test_create_tar_archive() {
959        let temp_dir = TempDir::new().unwrap();
960        let test_file = temp_dir.path().join("test.txt");
961        fs::write(&test_file, b"Test content").unwrap();
962
963        let pb = ProgressBar::hidden();
964        let tar_data = create_tar_archive(temp_dir.path(), &pb).unwrap();
965
966        assert!(!tar_data.is_empty());
967        assert!(tar_data.len() > 512); // Tar headers + content
968    }
969
970    #[test]
971    fn test_s3_config_serialization() {
972        let config = S3Config {
973            bucket: "my-bucket".to_string(),
974            region: "us-east-1".to_string(),
975            endpoint: Some("https://s3.example.com".to_string()),
976            access_key: "AKIAIOSFODNN7EXAMPLE".to_string(),
977            secret_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
978            prefix: Some("backups/".to_string()),
979        };
980
981        let json = serde_json::to_string(&config).unwrap();
982        assert!(json.contains("my-bucket"));
983        assert!(json.contains("us-east-1"));
984    }
985
986    #[test]
987    fn test_full_backup_cycle() {
988        let temp_dir = TempDir::new().unwrap();
989        let source_dir = temp_dir.path().join("source");
990        fs::create_dir(&source_dir).unwrap();
991
992        // Create test files
993        fs::write(source_dir.join("file1.txt"), b"Content 1").unwrap();
994        fs::write(source_dir.join("file2.txt"), b"Content 2").unwrap();
995
996        let backup_path = temp_dir.path().join("backup.tar.zst");
997
998        let manifest = create_local_backup(
999            &source_dir,
1000            &backup_path,
1001            CompressionType::Zstd,
1002            false,
1003            None,
1004            false,
1005            None,
1006        )
1007        .unwrap();
1008
1009        assert!(backup_path.exists());
1010        assert!(manifest.total_size > 0);
1011        assert_eq!(manifest.compression, CompressionType::Zstd);
1012        assert!(!manifest.encrypted);
1013    }
1014
1015    #[test]
1016    fn test_encrypted_backup_cycle() {
1017        let temp_dir = TempDir::new().unwrap();
1018        let source_dir = temp_dir.path().join("source");
1019        fs::create_dir(&source_dir).unwrap();
1020
1021        fs::write(source_dir.join("secret.txt"), b"Secret data").unwrap();
1022
1023        let backup_path = temp_dir.path().join("backup.tar.gz.enc");
1024        let password = "TestPassword123";
1025
1026        let manifest = create_local_backup(
1027            &source_dir,
1028            &backup_path,
1029            CompressionType::Gzip,
1030            true,
1031            Some(password),
1032            false,
1033            None,
1034        )
1035        .unwrap();
1036
1037        assert!(backup_path.exists());
1038        assert!(manifest.encrypted);
1039
1040        // Verify
1041        let verified = verify_local_backup(&backup_path, Some(password)).unwrap();
1042        assert!(verified.encrypted);
1043    }
1044
1045    #[test]
1046    fn test_incremental_backup_options() {
1047        let options = BackupOptions {
1048            output_path: "/tmp/inc-backup.tar.zst".to_string(),
1049            format: BackupFormat::TarZst,
1050            compression: CompressionType::Zstd,
1051            encryption: false,
1052            encryption_key: None,
1053            password_file: None,
1054            incremental: true,
1055            base_backup: Some("/tmp/base-backup.tar.zst".to_string()),
1056            include_messages: true,
1057            include_mailboxes: true,
1058            include_config: false,
1059            include_metadata: true,
1060            include_users: Some(vec!["user1@example.com".to_string()]),
1061            verify: true,
1062            s3_upload: None,
1063        };
1064
1065        assert!(options.incremental);
1066        assert!(options.base_backup.is_some());
1067        assert!(options.verify);
1068        assert_eq!(options.include_users.as_ref().unwrap().len(), 1);
1069    }
1070
1071    #[test]
1072    fn test_compression_type_equality() {
1073        assert_eq!(CompressionType::None, CompressionType::None);
1074        assert_eq!(CompressionType::Gzip, CompressionType::Gzip);
1075        assert_eq!(CompressionType::Zstd, CompressionType::Zstd);
1076        assert_ne!(CompressionType::None, CompressionType::Gzip);
1077    }
1078
1079    #[test]
1080    fn test_backup_format_equality() {
1081        assert_eq!(BackupFormat::TarGz, BackupFormat::TarGz);
1082        assert_eq!(BackupFormat::TarZst, BackupFormat::TarZst);
1083        assert_ne!(BackupFormat::TarGz, BackupFormat::TarZst);
1084    }
1085
1086    #[test]
1087    fn test_large_data_compression() {
1088        let large_data = vec![b'A'; 1_000_000]; // 1MB of 'A's
1089
1090        let compressed_gzip = compress_data(&large_data, CompressionType::Gzip).unwrap();
1091        let compressed_zstd = compress_data(&large_data, CompressionType::Zstd).unwrap();
1092
1093        assert!(compressed_gzip.len() < large_data.len());
1094        assert!(compressed_zstd.len() < large_data.len());
1095
1096        // Zstd typically better for repetitive data
1097        assert!(compressed_zstd.len() < compressed_gzip.len());
1098    }
1099}