1use 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#[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#[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#[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 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 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 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 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 let salt = SaltString::generate(&mut OsRng);
395
396 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 let hash_output = password_hash.hash.context("No hash output")?;
404 let key_bytes = hash_output.as_bytes();
405
406 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 let nonce_bytes: [u8; 12] = rand::random();
414 let nonce = Nonce::from_slice(&nonce_bytes);
415
416 let ciphertext = cipher
418 .encrypt(nonce, data)
419 .map_err(|e| anyhow::anyhow!("Encryption error: {}", e))?;
420
421 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 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 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 let plaintext = cipher
475 .decrypt(nonce, ciphertext)
476 .map_err(|_| anyhow::anyhow!("Decryption failed - wrong password?"))?;
477
478 Ok(plaintext)
479}
480
481fn 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 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 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 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
532pub 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
600pub 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 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 if manifest.checksum != checksum {
622 println!("Warning: checksum mismatch (file may be corrupted)");
623 }
624
625 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 manifest.total_size = data.len() as u64;
636 return Ok(manifest);
637 }
638 }
639
640 if let Some(pwd) = password {
642 let decrypted = decrypt_data(&data, pwd)?;
643 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
690pub 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#[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 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 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); 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); }
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 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 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]; 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 assert!(compressed_zstd.len() < compressed_gzip.len());
1098 }
1099}