1use 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#[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#[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#[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 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 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 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 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 let salt = SaltString::generate(&mut OsRng);
394
395 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 let hash_output = password_hash.hash.context("No hash output")?;
403 let key_bytes = hash_output.as_bytes();
404
405 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 let nonce_bytes: [u8; 12] = rand::random();
413 let nonce = Nonce::from(nonce_bytes);
414 let nonce = &nonce;
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_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 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 let plaintext = cipher
479 .decrypt(nonce, ciphertext)
480 .map_err(|_| anyhow::anyhow!("Decryption failed - wrong password?"))?;
481
482 Ok(plaintext)
483}
484
485fn 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 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 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 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
536pub 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
604pub 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 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 if manifest.checksum != checksum {
626 println!("Warning: checksum mismatch (file may be corrupted)");
627 }
628
629 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 manifest.total_size = data.len() as u64;
640 return Ok(manifest);
641 }
642 }
643
644 if let Some(pwd) = password {
646 let decrypted = decrypt_data(&data, pwd)?;
647 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
694pub 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#[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 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 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); 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); }
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 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 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]; 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 assert!(compressed_zstd.len() < compressed_gzip.len());
1102 }
1103}