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(nonce_bytes);
414    let nonce = &nonce;
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_arr: [u8; 12] = nonce_bytes
457        .try_into()
458        .map_err(|_| anyhow::anyhow!("Invalid nonce length"))?;
459    let nonce = Nonce::from(nonce_arr);
460    let nonce = &nonce;
461
462    let ciphertext = &data[nonce_start + 12..];
463
464    // Derive key
465    let argon2 = Argon2::default();
466    let password_hash = argon2
467        .hash_password(password.as_bytes(), &salt)
468        .map_err(|e| anyhow::anyhow!("Argon2 error: {}", e))?;
469
470    let hash_output = password_hash.hash.context("No hash output")?;
471    let key_bytes = hash_output.as_bytes();
472    let key = &key_bytes[..32];
473
474    let cipher =
475        Aes256Gcm::new_from_slice(key).map_err(|e| anyhow::anyhow!("Cipher error: {}", e))?;
476
477    // Decrypt
478    let plaintext = cipher
479        .decrypt(nonce, ciphertext)
480        .map_err(|_| anyhow::anyhow!("Decryption failed - wrong password?"))?;
481
482    Ok(plaintext)
483}
484
485/// Count messages, mailboxes, and users in the backup source directory
486fn count_backup_items(source_dir: &Path) -> Result<(u64, u32, u32)> {
487    let mut message_count = 0u64;
488    let mut mailbox_count = 0u32;
489    let mut user_count = 0u32;
490
491    // Count mailboxes and messages
492    let mailboxes_dir = source_dir.join("mailboxes");
493    if mailboxes_dir.exists() && mailboxes_dir.is_dir() {
494        for entry in fs::read_dir(&mailboxes_dir)? {
495            let entry = entry?;
496            if entry.file_type()?.is_dir() {
497                mailbox_count += 1;
498
499                // Count messages in new/ and cur/ subdirectories
500                let mailbox_path = entry.path();
501                for subdir in &["new", "cur"] {
502                    let msg_dir = mailbox_path.join(subdir);
503                    if msg_dir.exists() && msg_dir.is_dir() {
504                        for msg_entry in fs::read_dir(&msg_dir)? {
505                            let msg_entry = msg_entry?;
506                            if msg_entry.file_type()?.is_file() {
507                                message_count += 1;
508                            }
509                        }
510                    }
511                }
512            }
513        }
514    }
515
516    // Count users
517    let users_dir = source_dir.join("users");
518    if users_dir.exists() && users_dir.is_dir() {
519        for entry in fs::read_dir(&users_dir)? {
520            let entry = entry?;
521            if entry.file_type()?.is_dir() {
522                user_count += 1;
523            }
524        }
525    }
526
527    Ok((message_count, mailbox_count, user_count))
528}
529
530fn compute_checksum(data: &[u8]) -> String {
531    let mut hasher = Sha256::new();
532    hasher.update(data);
533    format!("{:x}", hasher.finalize())
534}
535
536/// Verify backup integrity
537pub async fn verify(
538    client: &Client,
539    backup_path: &str,
540    encryption_key: Option<&str>,
541    json: bool,
542) -> Result<()> {
543    #[derive(Serialize)]
544    struct VerifyRequest {
545        backup_path: String,
546        encryption_key: Option<String>,
547    }
548
549    #[derive(Deserialize, Serialize)]
550    struct VerifyResponse {
551        valid: bool,
552        checksum_match: bool,
553        errors: Vec<String>,
554        warnings: Vec<String>,
555        messages_verified: u64,
556        mailboxes_verified: u32,
557    }
558
559    let request = VerifyRequest {
560        backup_path: backup_path.to_string(),
561        encryption_key: encryption_key.map(|s| s.to_string()),
562    };
563
564    let response: VerifyResponse = client.post("/api/backup/verify", &request).await?;
565
566    if json {
567        println!("{}", serde_json::to_string_pretty(&response)?);
568    } else {
569        if response.valid && response.checksum_match {
570            println!("{}", "✓ Backup is valid".green().bold());
571        } else {
572            println!("{}", "✗ Backup validation failed".red().bold());
573        }
574
575        println!(
576            "  Checksum: {}",
577            if response.checksum_match {
578                "Match".green()
579            } else {
580                "Mismatch".red()
581            }
582        );
583        println!("  Messages verified: {}", response.messages_verified);
584        println!("  Mailboxes verified: {}", response.mailboxes_verified);
585
586        if !response.errors.is_empty() {
587            println!("\n{}", "Errors:".red().bold());
588            for error in &response.errors {
589                println!("  - {}", error);
590            }
591        }
592
593        if !response.warnings.is_empty() {
594            println!("\n{}", "Warnings:".yellow().bold());
595            for warning in &response.warnings {
596                println!("  - {}", warning);
597            }
598        }
599    }
600
601    Ok(())
602}
603
604/// Verify local backup file
605pub fn verify_local_backup(backup_path: &Path, password: Option<&str>) -> Result<BackupManifest> {
606    let data = fs::read(backup_path)?;
607    let checksum = compute_checksum(&data);
608
609    println!("Backup file: {}", backup_path.display());
610    println!("Size: {} bytes", data.len());
611    println!("SHA256: {}", checksum);
612
613    // Try to read companion manifest file
614    let manifest_path = backup_path.with_extension(
615        backup_path
616            .extension()
617            .and_then(|e| e.to_str())
618            .map(|e| format!("{}.manifest.json", e))
619            .unwrap_or_else(|| "manifest.json".to_string()),
620    );
621
622    if let Ok(manifest_data) = fs::read(&manifest_path) {
623        if let Ok(mut manifest) = serde_json::from_slice::<BackupManifest>(&manifest_data) {
624            // Verify checksum matches manifest
625            if manifest.checksum != checksum {
626                println!("Warning: checksum mismatch (file may be corrupted)");
627            }
628
629            // Try to decrypt and decompress to verify integrity
630            if let Some(pwd) = password {
631                let decrypted = decrypt_data(&data, pwd)?;
632                let _decompressed = decompress_data(&decrypted, manifest.compression)?;
633                println!("Decryption: OK");
634                println!("Decompression: OK");
635            }
636
637            println!("Verification: OK");
638            // Update total_size with actual file size
639            manifest.total_size = data.len() as u64;
640            return Ok(manifest);
641        }
642    }
643
644    // No manifest found - try to decrypt/decompress and return partial manifest
645    if let Some(pwd) = password {
646        let decrypted = decrypt_data(&data, pwd)?;
647        // Try zstd first, then gzip
648        let compression = if decompress_data(&decrypted, CompressionType::Zstd).is_ok() {
649            println!("Decryption: OK");
650            println!("Decompression: OK (zstd)");
651            CompressionType::Zstd
652        } else if decompress_data(&decrypted, CompressionType::Gzip).is_ok() {
653            println!("Decryption: OK");
654            println!("Decompression: OK (gzip)");
655            CompressionType::Gzip
656        } else {
657            println!("Decryption: OK");
658            CompressionType::None
659        };
660        println!("Verification: OK");
661        return Ok(BackupManifest {
662            version: "1.0".to_string(),
663            created_at: chrono::Utc::now().to_rfc3339(),
664            backup_type: "full".to_string(),
665            compression,
666            encrypted: true,
667            message_count: 0,
668            mailbox_count: 0,
669            user_count: 0,
670            total_size: data.len() as u64,
671            checksum,
672            base_backup: None,
673            modseq: None,
674        });
675    }
676
677    println!("Verification: OK");
678    Ok(BackupManifest {
679        version: "1.0".to_string(),
680        created_at: chrono::Utc::now().to_rfc3339(),
681        backup_type: "full".to_string(),
682        compression: CompressionType::None,
683        encrypted: false,
684        message_count: 0,
685        mailbox_count: 0,
686        user_count: 0,
687        total_size: data.len() as u64,
688        checksum,
689        base_backup: None,
690        modseq: None,
691    })
692}
693
694/// List available backups
695pub async fn list_backups(client: &Client, json: bool) -> Result<()> {
696    #[derive(Deserialize, Serialize, Tabled)]
697    struct BackupInfo {
698        backup_id: String,
699        created_at: String,
700        backup_type: String,
701        size_mb: u64,
702        messages: u64,
703        encrypted: bool,
704    }
705
706    let backups: Vec<BackupInfo> = client.get("/api/backup/list").await?;
707
708    if json {
709        println!("{}", serde_json::to_string_pretty(&backups)?);
710    } else {
711        if backups.is_empty() {
712            println!("{}", "No backups found".yellow());
713            return Ok(());
714        }
715
716        use tabled::Table;
717        let table = Table::new(&backups).to_string();
718        println!("{}", table);
719        println!("\n{} backups", backups.len().to_string().bold());
720    }
721
722    Ok(())
723}
724
725/// Upload backup to S3-compatible storage
726#[allow(clippy::too_many_arguments)]
727pub async fn upload_s3(
728    backup_path: &str,
729    bucket: &str,
730    region: &str,
731    endpoint: Option<&str>,
732    _access_key: &str,
733    _secret_key: &str,
734    prefix: Option<&str>,
735    json: bool,
736) -> Result<()> {
737    use aws_config::BehaviorVersion;
738    use aws_sdk_s3::primitives::ByteStream;
739    use aws_sdk_s3::Client as S3Client;
740
741    if !json {
742        println!("{}", "Uploading backup to S3...".blue().bold());
743    }
744
745    let config = if let Some(ep) = endpoint {
746        aws_config::defaults(BehaviorVersion::latest())
747            .region(aws_config::Region::new(region.to_string()))
748            .endpoint_url(ep)
749            .load()
750            .await
751    } else {
752        aws_config::defaults(BehaviorVersion::latest())
753            .region(aws_config::Region::new(region.to_string()))
754            .load()
755            .await
756    };
757
758    let s3_client = S3Client::new(&config);
759
760    let path = Path::new(backup_path);
761    let file_name = path
762        .file_name()
763        .context("Invalid backup path")?
764        .to_str()
765        .context("Invalid filename")?;
766
767    let key = if let Some(p) = prefix {
768        format!("{}/{}", p, file_name)
769    } else {
770        file_name.to_string()
771    };
772
773    let body = ByteStream::from_path(path).await?;
774
775    let pb = ProgressBar::new(fs::metadata(path)?.len());
776    pb.set_style(
777        ProgressStyle::default_bar()
778            .template(
779                "[{elapsed_precise}] {bar:40.cyan/blue} {bytes}/{total_bytes} {bytes_per_sec}",
780            )
781            .expect("invalid template")
782            .progress_chars("##-"),
783    );
784
785    s3_client
786        .put_object()
787        .bucket(bucket)
788        .key(&key)
789        .body(body)
790        .send()
791        .await?;
792
793    pb.finish_with_message("Upload completed!");
794
795    if !json {
796        println!("{}", "✓ Backup uploaded successfully".green().bold());
797        println!("  Bucket: {}", bucket);
798        println!("  Key: {}", key);
799        println!("  Region: {}", region);
800    }
801
802    Ok(())
803}
804
805fn read_password_file(path: &str) -> Result<String> {
806    let content = fs::read_to_string(path)
807        .with_context(|| format!("Failed to read password file: {}", path))?;
808    Ok(content.trim().to_string())
809}
810
811fn generate_encryption_key() -> String {
812    use uuid::Uuid;
813    format!("{}", Uuid::new_v4())
814}
815
816#[cfg(test)]
817mod tests {
818    use super::*;
819    use tempfile::TempDir;
820
821    #[test]
822    fn test_backup_options_serialization() {
823        let options = BackupOptions {
824            output_path: "/tmp/backup.tar.gz".to_string(),
825            format: BackupFormat::TarGz,
826            compression: CompressionType::Gzip,
827            encryption: false,
828            encryption_key: None,
829            password_file: None,
830            incremental: false,
831            base_backup: None,
832            include_messages: true,
833            include_mailboxes: true,
834            include_config: true,
835            include_metadata: true,
836            include_users: None,
837            verify: false,
838            s3_upload: None,
839        };
840
841        let json = serde_json::to_string(&options).unwrap();
842        assert!(json.contains("backup.tar.gz"));
843    }
844
845    #[test]
846    fn test_encryption_key_generation() {
847        let key1 = generate_encryption_key();
848        let key2 = generate_encryption_key();
849        assert_ne!(key1, key2);
850        assert!(!key1.is_empty());
851    }
852
853    #[test]
854    fn test_backup_format_serialization() {
855        let format = BackupFormat::TarGz;
856        let json = serde_json::to_string(&format).unwrap();
857        assert_eq!(json, "\"targz\"");
858
859        let format2 = BackupFormat::TarZst;
860        let json2 = serde_json::to_string(&format2).unwrap();
861        assert_eq!(json2, "\"tarzst\"");
862    }
863
864    #[test]
865    fn test_compression_none() {
866        let data = b"Hello, World!";
867        let compressed = compress_data(data, CompressionType::None).unwrap();
868        assert_eq!(compressed, data);
869
870        let decompressed = decompress_data(&compressed, CompressionType::None).unwrap();
871        assert_eq!(decompressed, data);
872    }
873
874    #[test]
875    fn test_compression_gzip() {
876        // Use larger repetitive data to ensure compression
877        let data = b"Hello, World! This is a test message for compression. ".repeat(100);
878        let compressed = compress_data(&data, CompressionType::Gzip).unwrap();
879        assert!(compressed.len() < data.len());
880
881        let decompressed = decompress_data(&compressed, CompressionType::Gzip).unwrap();
882        assert_eq!(decompressed, data);
883    }
884
885    #[test]
886    fn test_compression_zstd() {
887        // Use larger repetitive data to ensure compression
888        let data = b"Hello, World! This is a test message for zstd compression. ".repeat(100);
889        let compressed = compress_data(&data, CompressionType::Zstd).unwrap();
890        assert!(compressed.len() < data.len());
891
892        let decompressed = decompress_data(&compressed, CompressionType::Zstd).unwrap();
893        assert_eq!(decompressed, data);
894    }
895
896    #[test]
897    fn test_encryption_decryption() {
898        let data = b"Secret message that needs encryption!";
899        let password = "SuperSecretPassword123";
900
901        let encrypted = encrypt_data(data, password).unwrap();
902        assert_ne!(encrypted.as_slice(), data);
903        assert!(encrypted.len() > data.len());
904
905        let decrypted = decrypt_data(&encrypted, password).unwrap();
906        assert_eq!(decrypted.as_slice(), data);
907    }
908
909    #[test]
910    fn test_encryption_wrong_password() {
911        let data = b"Secret message";
912        let password = "CorrectPassword";
913        let wrong_password = "WrongPassword";
914
915        let encrypted = encrypt_data(data, password).unwrap();
916        let result = decrypt_data(&encrypted, wrong_password);
917
918        assert!(result.is_err());
919    }
920
921    #[test]
922    fn test_checksum_computation() {
923        let data = b"Test data for checksum";
924        let checksum1 = compute_checksum(data);
925        let checksum2 = compute_checksum(data);
926
927        assert_eq!(checksum1, checksum2);
928        assert_eq!(checksum1.len(), 64); // SHA256 hex = 64 chars
929
930        let different_data = b"Different data";
931        let checksum3 = compute_checksum(different_data);
932        assert_ne!(checksum1, checksum3);
933    }
934
935    #[test]
936    fn test_manifest_serialization() {
937        let manifest = BackupManifest {
938            version: "1.0".to_string(),
939            created_at: "2024-02-15T10:00:00Z".to_string(),
940            backup_type: "full".to_string(),
941            compression: CompressionType::Zstd,
942            encrypted: true,
943            message_count: 1000,
944            mailbox_count: 50,
945            user_count: 10,
946            total_size: 1024 * 1024 * 100,
947            checksum: "abc123".to_string(),
948            base_backup: None,
949            modseq: Some(12345),
950        };
951
952        let json = serde_json::to_string(&manifest).unwrap();
953        assert!(json.contains("1.0"));
954        assert!(json.contains("full"));
955
956        let deserialized: BackupManifest = serde_json::from_str(&json).unwrap();
957        assert_eq!(deserialized.version, "1.0");
958        assert_eq!(deserialized.message_count, 1000);
959    }
960
961    #[test]
962    fn test_create_tar_archive() {
963        let temp_dir = TempDir::new().unwrap();
964        let test_file = temp_dir.path().join("test.txt");
965        fs::write(&test_file, b"Test content").unwrap();
966
967        let pb = ProgressBar::hidden();
968        let tar_data = create_tar_archive(temp_dir.path(), &pb).unwrap();
969
970        assert!(!tar_data.is_empty());
971        assert!(tar_data.len() > 512); // Tar headers + content
972    }
973
974    #[test]
975    fn test_s3_config_serialization() {
976        let config = S3Config {
977            bucket: "my-bucket".to_string(),
978            region: "us-east-1".to_string(),
979            endpoint: Some("https://s3.example.com".to_string()),
980            access_key: "AKIAIOSFODNN7EXAMPLE".to_string(),
981            secret_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
982            prefix: Some("backups/".to_string()),
983        };
984
985        let json = serde_json::to_string(&config).unwrap();
986        assert!(json.contains("my-bucket"));
987        assert!(json.contains("us-east-1"));
988    }
989
990    #[test]
991    fn test_full_backup_cycle() {
992        let temp_dir = TempDir::new().unwrap();
993        let source_dir = temp_dir.path().join("source");
994        fs::create_dir(&source_dir).unwrap();
995
996        // Create test files
997        fs::write(source_dir.join("file1.txt"), b"Content 1").unwrap();
998        fs::write(source_dir.join("file2.txt"), b"Content 2").unwrap();
999
1000        let backup_path = temp_dir.path().join("backup.tar.zst");
1001
1002        let manifest = create_local_backup(
1003            &source_dir,
1004            &backup_path,
1005            CompressionType::Zstd,
1006            false,
1007            None,
1008            false,
1009            None,
1010        )
1011        .unwrap();
1012
1013        assert!(backup_path.exists());
1014        assert!(manifest.total_size > 0);
1015        assert_eq!(manifest.compression, CompressionType::Zstd);
1016        assert!(!manifest.encrypted);
1017    }
1018
1019    #[test]
1020    fn test_encrypted_backup_cycle() {
1021        let temp_dir = TempDir::new().unwrap();
1022        let source_dir = temp_dir.path().join("source");
1023        fs::create_dir(&source_dir).unwrap();
1024
1025        fs::write(source_dir.join("secret.txt"), b"Secret data").unwrap();
1026
1027        let backup_path = temp_dir.path().join("backup.tar.gz.enc");
1028        let password = "TestPassword123";
1029
1030        let manifest = create_local_backup(
1031            &source_dir,
1032            &backup_path,
1033            CompressionType::Gzip,
1034            true,
1035            Some(password),
1036            false,
1037            None,
1038        )
1039        .unwrap();
1040
1041        assert!(backup_path.exists());
1042        assert!(manifest.encrypted);
1043
1044        // Verify
1045        let verified = verify_local_backup(&backup_path, Some(password)).unwrap();
1046        assert!(verified.encrypted);
1047    }
1048
1049    #[test]
1050    fn test_incremental_backup_options() {
1051        let options = BackupOptions {
1052            output_path: "/tmp/inc-backup.tar.zst".to_string(),
1053            format: BackupFormat::TarZst,
1054            compression: CompressionType::Zstd,
1055            encryption: false,
1056            encryption_key: None,
1057            password_file: None,
1058            incremental: true,
1059            base_backup: Some("/tmp/base-backup.tar.zst".to_string()),
1060            include_messages: true,
1061            include_mailboxes: true,
1062            include_config: false,
1063            include_metadata: true,
1064            include_users: Some(vec!["user1@example.com".to_string()]),
1065            verify: true,
1066            s3_upload: None,
1067        };
1068
1069        assert!(options.incremental);
1070        assert!(options.base_backup.is_some());
1071        assert!(options.verify);
1072        assert_eq!(options.include_users.as_ref().unwrap().len(), 1);
1073    }
1074
1075    #[test]
1076    fn test_compression_type_equality() {
1077        assert_eq!(CompressionType::None, CompressionType::None);
1078        assert_eq!(CompressionType::Gzip, CompressionType::Gzip);
1079        assert_eq!(CompressionType::Zstd, CompressionType::Zstd);
1080        assert_ne!(CompressionType::None, CompressionType::Gzip);
1081    }
1082
1083    #[test]
1084    fn test_backup_format_equality() {
1085        assert_eq!(BackupFormat::TarGz, BackupFormat::TarGz);
1086        assert_eq!(BackupFormat::TarZst, BackupFormat::TarZst);
1087        assert_ne!(BackupFormat::TarGz, BackupFormat::TarZst);
1088    }
1089
1090    #[test]
1091    fn test_large_data_compression() {
1092        let large_data = vec![b'A'; 1_000_000]; // 1MB of 'A's
1093
1094        let compressed_gzip = compress_data(&large_data, CompressionType::Gzip).unwrap();
1095        let compressed_zstd = compress_data(&large_data, CompressionType::Zstd).unwrap();
1096
1097        assert!(compressed_gzip.len() < large_data.len());
1098        assert!(compressed_zstd.len() < large_data.len());
1099
1100        // Zstd typically better for repetitive data
1101        assert!(compressed_zstd.len() < compressed_gzip.len());
1102    }
1103}