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