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