1use std::path::{Path, PathBuf};
10use std::time::Instant;
11
12use nzb_core::models::{StageResult, StageStatus};
13use tracing::{debug, error, info, warn};
14
15use crate::detect::{ArchiveType, find_archives, find_cleanup_files, find_par2_files};
16use crate::par2::par2_repair;
17use crate::unpack::{extract_7z, extract_rar, extract_zip};
18
19enum VerifyRepairOutcome {
23 AllCorrect {
24 intact_count: usize,
25 },
26 Damaged {
27 intact: usize,
28 damaged: usize,
29 missing: usize,
30 blocks_needed: u32,
31 blocks_available: u32,
32 repair_result: Result<rust_par2::RepairResult, rust_par2::RepairError>,
33 },
34}
35
36#[derive(Debug)]
38pub struct PostProcResult {
39 pub success: bool,
41 pub stages: Vec<StageResult>,
43 pub error: Option<String>,
45}
46
47#[derive(Debug, Clone)]
49pub struct PostProcConfig {
50 pub cleanup_after_extract: bool,
52 pub output_dir: Option<PathBuf>,
55 pub articles_failed: usize,
60 pub skip_extract: bool,
63 pub password: Option<String>,
65}
66
67impl Default for PostProcConfig {
68 fn default() -> Self {
69 Self {
70 cleanup_after_extract: true,
71 output_dir: None,
72 articles_failed: 0,
73 skip_extract: false,
74 password: None,
75 }
76 }
77}
78
79pub async fn run_pipeline(job_dir: &Path, config: &PostProcConfig) -> PostProcResult {
87 let mut stages: Vec<StageResult> = Vec::new();
88 let mut pipeline_ok = true;
89
90 info!(dir = %job_dir.display(), "Starting post-processing pipeline");
91
92 let par2_files = find_par2_files(job_dir);
104
105 if par2_files.is_empty() {
106 stages.push(StageResult {
107 name: "Verify".to_string(),
108 status: StageStatus::Skipped,
109 message: Some("No par2 files found".to_string()),
110 duration_secs: 0.0,
111 });
112 } else if config.articles_failed == 0 {
113 info!("Skipping PAR2 verification — zero article failures (CRC-verified)");
114 stages.push(StageResult {
115 name: "Verify".to_string(),
116 status: StageStatus::Skipped,
117 message: Some("Skipped — zero article failures".to_string()),
118 duration_secs: 0.0,
119 });
120 } else {
121 let verify_start = Instant::now();
122 let index_par2 = par2_files[0].clone();
123
124 match rust_par2::parse(&index_par2) {
125 Ok(file_set) => {
126 rename_to_par2_names(&file_set, job_dir);
132
133 let dir = job_dir.to_path_buf();
139 let verify_repair_result = tokio::task::spawn_blocking(move || {
140 let verify_result = rust_par2::verify(&file_set, &dir);
141
142 if verify_result.all_correct() {
143 VerifyRepairOutcome::AllCorrect {
144 intact_count: verify_result.intact.len(),
145 }
146 } else {
147 let intact = verify_result.intact.len();
148 let damaged = verify_result.damaged.len();
149 let missing = verify_result.missing.len();
150 let blocks_needed = verify_result.blocks_needed();
151 let blocks_available = verify_result.recovery_blocks_available;
152
153 info!(
154 intact,
155 damaged,
156 missing,
157 blocks_needed,
158 "Native PAR2 verify: damage detected, attempting native repair"
159 );
160
161 info!("Running native PAR2 repair (with pre-computed verify)");
163 let repair_result =
164 rust_par2::repair_from_verify(&file_set, &dir, &verify_result);
165
166 VerifyRepairOutcome::Damaged {
167 intact,
168 damaged,
169 missing,
170 blocks_needed,
171 blocks_available,
172 repair_result,
173 }
174 }
175 })
176 .await;
177
178 let verify_duration = verify_start.elapsed().as_secs_f64();
179
180 match verify_repair_result {
181 Ok(VerifyRepairOutcome::AllCorrect { intact_count }) => {
182 info!(
183 files = intact_count,
184 duration_secs = verify_duration,
185 "Native PAR2 verify: all files correct"
186 );
187 stages.push(StageResult {
188 name: "Verify".to_string(),
189 status: StageStatus::Success,
190 message: Some(format!(
191 "All {intact_count} files correct (native verify, {verify_duration:.3}s)",
192 )),
193 duration_secs: verify_duration,
194 });
195 }
196 Ok(VerifyRepairOutcome::Damaged {
197 intact,
198 damaged,
199 missing,
200 blocks_needed,
201 blocks_available,
202 repair_result,
203 }) => {
204 stages.push(StageResult {
206 name: "Verify".to_string(),
207 status: StageStatus::Success,
208 message: Some(format!(
209 "{intact} intact, {damaged} damaged, {missing} missing — {blocks_needed} blocks needed (native verify)",
210 )),
211 duration_secs: verify_duration,
212 });
213
214 match repair_result {
216 Ok(result) => {
217 info!(
218 blocks_repaired = result.blocks_repaired,
219 files_repaired = result.files_repaired,
220 "Native PAR2 repair complete"
221 );
222 if !result.success {
223 pipeline_ok = false;
224 }
225 stages.push(StageResult {
226 name: "Repair".to_string(),
227 status: if result.success {
228 StageStatus::Success
229 } else {
230 StageStatus::Failed
231 },
232 message: Some(result.message),
233 duration_secs: verify_duration,
234 });
235 }
236 Err(e) => {
237 error!(
238 error = %e,
239 blocks_needed,
240 blocks_available,
241 damaged,
242 missing,
243 "Native PAR2 repair failed"
244 );
245 pipeline_ok = false;
246 stages.push(StageResult {
247 name: "Repair".to_string(),
248 status: StageStatus::Failed,
249 message: Some(format!("Repair failed: {e}")),
250 duration_secs: verify_duration,
251 });
252 }
253 }
254 }
255 Err(e) => {
256 error!(error = %e, "Verify/repair task panicked");
257 pipeline_ok = false;
258 stages.push(StageResult {
259 name: "Verify".to_string(),
260 status: StageStatus::Failed,
261 message: Some(format!("Verify task panicked: {e}")),
262 duration_secs: verify_duration,
263 });
264 }
265 }
266 }
267 Err(e) => {
268 debug!(error = %e, "Native PAR2 parse failed");
270 let verify_duration = verify_start.elapsed().as_secs_f64();
271
272 if config.articles_failed == 0 {
273 stages.push(StageResult {
275 name: "Verify".to_string(),
276 status: StageStatus::Skipped,
277 message: Some(format!(
278 "PAR2 parse failed ({e}), but zero article failures"
279 )),
280 duration_secs: verify_duration,
281 });
282 } else {
283 stages.push(StageResult {
285 name: "Verify".to_string(),
286 status: StageStatus::Skipped,
287 message: Some(format!("PAR2 parse failed ({e}), attempting repair")),
288 duration_secs: verify_duration,
289 });
290
291 let repair_result = run_repair_stage(job_dir).await;
292 if repair_result.status == StageStatus::Failed {
293 pipeline_ok = false;
294 }
295 stages.push(repair_result);
296 }
297 }
298 }
299 }
300
301 let should_extract = pipeline_ok || config.articles_failed <= 5;
308 if config.skip_extract {
309 info!("Skipping extraction — completed by direct unpack");
310 stages.push(StageResult {
311 name: "Extract".to_string(),
312 status: StageStatus::Skipped,
313 message: Some("Skipped — completed by direct unpack".to_string()),
314 duration_secs: 0.0,
315 });
316 } else if should_extract {
317 let output_dir = config.output_dir.as_deref().unwrap_or(job_dir);
318 if !par2_files.is_empty() {
319 rename_from_index_par2_for_extract(job_dir, &par2_files);
320 }
321 let result = run_extract_stage(job_dir, output_dir, config.password.as_deref()).await;
322 if result.status == StageStatus::Failed {
323 pipeline_ok = false;
324 } else if result.status == StageStatus::Success {
325 pipeline_ok = true;
327 }
328 stages.push(result);
329 }
330
331 if pipeline_ok && config.cleanup_after_extract {
335 let result = run_cleanup_stage(job_dir);
336 stages.push(result);
337 }
338
339 let error = if pipeline_ok {
340 None
341 } else {
342 let msgs: Vec<String> = stages
344 .iter()
345 .filter(|s| s.status == StageStatus::Failed)
346 .filter_map(|s| s.message.clone())
347 .collect();
348 Some(msgs.join("; "))
349 };
350
351 info!(
352 success = pipeline_ok,
353 stages = stages.len(),
354 "Post-processing pipeline finished"
355 );
356
357 PostProcResult {
358 success: pipeline_ok,
359 stages,
360 error,
361 }
362}
363
364fn rename_to_par2_names(file_set: &rust_par2::Par2FileSet, dir: &Path) {
378 let mut expected: std::collections::HashMap<[u8; 16], &str> = std::collections::HashMap::new();
380 for par2_file in file_set.files.values() {
381 expected.insert(par2_file.hash_16k, &par2_file.filename);
382 }
383
384 let any_match = file_set
386 .files
387 .values()
388 .any(|f| dir.join(&f.filename).exists());
389 if any_match {
390 return;
391 }
392
393 let entries: Vec<_> = match std::fs::read_dir(dir) {
395 Ok(rd) => rd.filter_map(|e| e.ok()).collect(),
396 Err(_) => return,
397 };
398
399 let mut renamed = 0u32;
400 for entry in &entries {
401 let path = entry.path();
402 if !path.is_file() {
403 continue;
404 }
405 let current_name = match path.file_name().and_then(|n| n.to_str()) {
406 Some(n) => n.to_string(),
407 None => continue,
408 };
409
410 if current_name.to_lowercase().ends_with(".par2") {
412 continue;
413 }
414
415 let hash = match rust_par2::compute_hash_16k(&path) {
416 Ok(h) => h,
417 Err(_) => continue,
418 };
419
420 if let Some(&par2_name) = expected.get(&hash)
421 && current_name != par2_name
422 {
423 let new_path = dir.join(par2_name);
424 if !new_path.exists() {
425 if let Err(e) = std::fs::rename(&path, &new_path) {
426 warn!(
427 from = %current_name,
428 to = %par2_name,
429 "Failed to rename file to PAR2 expected name: {e}"
430 );
431 } else {
432 renamed += 1;
433 debug!(
434 from = %current_name,
435 to = %par2_name,
436 "Renamed file to match PAR2 metadata"
437 );
438 }
439 }
440 }
441 }
442
443 if renamed > 0 {
444 info!(
445 renamed,
446 "PAR2-guided deobfuscation: renamed files to match PAR2 expected names"
447 );
448 }
449}
450
451fn rename_from_index_par2_for_extract(job_dir: &Path, par2_files: &[PathBuf]) {
452 let Some(index_par2) = par2_files.first() else {
453 return;
454 };
455
456 match rust_par2::parse(index_par2) {
457 Ok(file_set) => rename_to_par2_names(&file_set, job_dir),
458 Err(e) => {
459 debug!(
460 file = %index_par2.display(),
461 error = %e,
462 "Skipping PAR2-guided rename before extract because the index PAR2 could not be parsed"
463 );
464 }
465 }
466}
467
468async fn run_repair_stage(job_dir: &Path) -> StageResult {
475 let start = Instant::now();
476 let par2_files = find_par2_files(job_dir);
477
478 if par2_files.is_empty() {
479 return StageResult {
480 name: "Repair".to_string(),
481 status: StageStatus::Skipped,
482 message: Some("No par2 files found".to_string()),
483 duration_secs: start.elapsed().as_secs_f64(),
484 };
485 }
486
487 let index_par2 = &par2_files[0];
488 info!(file = %index_par2.display(), "Running native par2 repair");
489
490 match par2_repair(index_par2).await {
491 Ok(result) => {
492 let status = if result.repaired || result.success {
493 StageStatus::Success
494 } else {
495 StageStatus::Failed
496 };
497
498 StageResult {
499 name: "Repair".to_string(),
500 status,
501 message: Some(result.message),
502 duration_secs: start.elapsed().as_secs_f64(),
503 }
504 }
505 Err(e) => {
506 error!(error = %e, "par2 repair failed with error");
507 StageResult {
508 name: "Repair".to_string(),
509 status: StageStatus::Failed,
510 message: Some(format!("par2 repair error: {e}")),
511 duration_secs: start.elapsed().as_secs_f64(),
512 }
513 }
514 }
515}
516
517async fn run_extract_stage(
518 job_dir: &Path,
519 output_dir: &Path,
520 password: Option<&str>,
521) -> StageResult {
522 let start = Instant::now();
523 let archives = find_archives(job_dir);
524
525 if archives.is_empty() {
526 info!("No archives found — skipping extraction");
527 return StageResult {
528 name: "Extract".to_string(),
529 status: StageStatus::Skipped,
530 message: Some("No archives found".to_string()),
531 duration_secs: start.elapsed().as_secs_f64(),
532 };
533 }
534
535 let mut all_ok = true;
536 let mut messages: Vec<String> = Vec::new();
537
538 for (archive_type, path) in &archives {
539 info!(kind = %archive_type, file = %path.display(), "Extracting archive");
540
541 let result = match archive_type {
542 ArchiveType::Rar => extract_rar(path, output_dir, password).await,
543 ArchiveType::SevenZip => extract_7z(path, output_dir, password).await,
544 ArchiveType::Zip => extract_zip(path, output_dir).await,
545 };
546
547 match result {
548 Ok(unpack_result) => {
549 if unpack_result.success {
550 messages.push(format!("{archive_type}: OK"));
551 } else {
552 all_ok = false;
553 warn!(kind = %archive_type, file = %path.display(), "Extraction reported failure");
554 messages.push(format!("{archive_type}: failed"));
555 }
556 }
557 Err(e) => {
558 all_ok = false;
559 error!(kind = %archive_type, file = %path.display(), error = %e, "Extraction error");
560 messages.push(format!("{archive_type}: {e}"));
561 }
562 }
563 }
564
565 StageResult {
566 name: "Extract".to_string(),
567 status: if all_ok {
568 StageStatus::Success
569 } else {
570 StageStatus::Failed
571 },
572 message: Some(messages.join("; ")),
573 duration_secs: start.elapsed().as_secs_f64(),
574 }
575}
576
577fn run_cleanup_stage(job_dir: &Path) -> StageResult {
578 let start = Instant::now();
579 let files = find_cleanup_files(job_dir);
580
581 if files.is_empty() {
582 return StageResult {
583 name: "Cleanup".to_string(),
584 status: StageStatus::Skipped,
585 message: Some("No files to clean up".to_string()),
586 duration_secs: start.elapsed().as_secs_f64(),
587 };
588 }
589
590 let mut removed = 0u32;
591 let mut errors = 0u32;
592
593 for path in &files {
594 match std::fs::remove_file(path) {
595 Ok(()) => {
596 removed += 1;
597 }
598 Err(e) => {
599 warn!(file = %path.display(), error = %e, "Failed to remove cleanup file");
600 errors += 1;
601 }
602 }
603 }
604
605 let status = if errors == 0 {
606 StageStatus::Success
607 } else {
608 StageStatus::Failed
609 };
610
611 StageResult {
612 name: "Cleanup".to_string(),
613 status,
614 message: Some(format!("Removed {removed} files, {errors} errors")),
615 duration_secs: start.elapsed().as_secs_f64(),
616 }
617}
618
619#[cfg(test)]
624mod tests {
625 use super::*;
626 use std::fs;
627
628 fn make_test_dir(files: &[&str]) -> tempfile::TempDir {
629 let dir = tempfile::tempdir().unwrap();
630 for name in files {
631 let path = dir.path().join(name);
632 if let Some(parent) = path.parent() {
633 fs::create_dir_all(parent).unwrap();
634 }
635 fs::write(&path, b"").unwrap();
636 }
637 dir
638 }
639
640 fn copy_fixture_dir(name: &str) -> tempfile::TempDir {
641 let src = Path::new(env!("CARGO_MANIFEST_DIR"))
642 .join("tests")
643 .join("fixtures")
644 .join(name);
645 let dst = tempfile::tempdir().unwrap();
646
647 for entry in fs::read_dir(&src).unwrap() {
648 let entry = entry.unwrap();
649 let from = entry.path();
650 let to = dst.path().join(entry.file_name());
651 fs::copy(&from, &to).unwrap();
652 }
653
654 dst
655 }
656
657 #[test]
658 fn test_post_proc_result_default() {
659 let result = PostProcResult {
660 success: true,
661 stages: vec![],
662 error: None,
663 };
664 assert!(result.success);
665 assert!(result.stages.is_empty());
666 assert!(result.error.is_none());
667 }
668
669 #[test]
670 fn test_config_default() {
671 let config = PostProcConfig::default();
672 assert!(config.cleanup_after_extract);
673 assert!(config.output_dir.is_none());
674 assert_eq!(config.articles_failed, 0);
675 }
676
677 #[tokio::test]
678 async fn test_pipeline_no_files() {
679 let dir = make_test_dir(&[]);
681 let config = PostProcConfig::default();
682 let result = run_pipeline(dir.path(), &config).await;
683
684 assert!(result.success, "Pipeline should succeed for empty dir");
685
686 let verify_stage = result.stages.iter().find(|s| s.name == "Verify");
688 assert!(verify_stage.is_some(), "Verify stage should be present");
689 assert_eq!(verify_stage.unwrap().status, StageStatus::Skipped);
690
691 let extract_stage = result.stages.iter().find(|s| s.name == "Extract");
692 assert!(extract_stage.is_some(), "Extract stage should be present");
693 assert_eq!(extract_stage.unwrap().status, StageStatus::Skipped);
694 }
695
696 #[tokio::test]
697 async fn test_pipeline_only_text_files() {
698 let dir = make_test_dir(&["readme.txt", "info.nfo"]);
699 let config = PostProcConfig::default();
700 let result = run_pipeline(dir.path(), &config).await;
701
702 assert!(result.success);
703 for stage in &result.stages {
705 assert_eq!(
706 stage.status,
707 StageStatus::Skipped,
708 "Stage '{}' should be skipped",
709 stage.name
710 );
711 }
712 }
713
714 #[test]
715 fn test_cleanup_removes_files() {
716 let dir = make_test_dir(&[
717 "movie.par2",
718 "movie.vol00+01.par2",
719 "movie.rar",
720 "movie.r00",
721 "movie.mkv", ]);
723
724 let result = run_cleanup_stage(dir.path());
725 assert_eq!(result.status, StageStatus::Success);
726
727 assert!(dir.path().join("movie.mkv").exists());
729 assert!(!dir.path().join("movie.par2").exists());
731 assert!(!dir.path().join("movie.vol00+01.par2").exists());
732 assert!(!dir.path().join("movie.rar").exists());
733 assert!(!dir.path().join("movie.r00").exists());
734 }
735
736 #[tokio::test]
737 async fn test_pipeline_stage_order() {
738 let dir = make_test_dir(&[]);
741 let config = PostProcConfig {
742 cleanup_after_extract: false,
743 ..Default::default()
744 };
745 let result = run_pipeline(dir.path(), &config).await;
746
747 let stage_names: Vec<&str> = result.stages.iter().map(|s| s.name.as_str()).collect();
749 assert!(stage_names.contains(&"Verify"), "Should have Verify stage");
750 assert!(
751 stage_names.contains(&"Extract"),
752 "Should have Extract stage"
753 );
754
755 let verify_idx = stage_names.iter().position(|&n| n == "Verify").unwrap();
757 let extract_idx = stage_names.iter().position(|&n| n == "Extract").unwrap();
758 assert!(
759 verify_idx < extract_idx,
760 "Verify ({verify_idx}) should come before Extract ({extract_idx})"
761 );
762 }
763
764 #[tokio::test]
765 async fn test_pipeline_skips_verify_with_zero_failures() {
766 let dir = make_test_dir(&["movie.par2", "movie.vol00+01.par2", "movie.mkv"]);
769 let config = PostProcConfig {
770 cleanup_after_extract: false,
771 ..Default::default()
772 };
773 let result = run_pipeline(dir.path(), &config).await;
774 assert!(result.success);
775
776 let verify_stage = result.stages.iter().find(|s| s.name == "Verify").unwrap();
777 assert_eq!(
778 verify_stage.status,
779 StageStatus::Skipped,
780 "Verify should be skipped when articles_failed == 0"
781 );
782 assert!(
783 verify_stage
784 .message
785 .as_deref()
786 .unwrap_or("")
787 .contains("zero article failures"),
788 "Skip message should indicate zero failures"
789 );
790 }
791
792 #[tokio::test]
793 async fn test_pipeline_no_par2_files_skips_regardless() {
794 let dir = make_test_dir(&["movie.mkv"]);
796 let config = PostProcConfig {
797 cleanup_after_extract: false,
798 articles_failed: 5,
799 ..Default::default()
800 };
801 let result = run_pipeline(dir.path(), &config).await;
802 assert!(result.success);
803
804 let verify_stage = result.stages.iter().find(|s| s.name == "Verify").unwrap();
805 assert_eq!(
806 verify_stage.status,
807 StageStatus::Skipped,
808 "Verify should be skipped when no par2 files exist"
809 );
810 assert!(
811 verify_stage
812 .message
813 .as_deref()
814 .unwrap_or("")
815 .contains("No par2 files"),
816 "Skip message should indicate no par2 files"
817 );
818 }
819
820 #[tokio::test]
821 async fn test_pipeline_runs_verify_then_repair_when_failures() {
822 let dir = make_test_dir(&["movie.par2", "movie.vol00+01.par2", "movie.mkv"]);
826 let config = PostProcConfig {
827 cleanup_after_extract: false,
828 articles_failed: 3,
829 ..Default::default()
830 };
831 let result = run_pipeline(dir.path(), &config).await;
832
833 let stage_names: Vec<&str> = result.stages.iter().map(|s| s.name.as_str()).collect();
835 assert!(
836 stage_names.contains(&"Verify"),
837 "Should have Verify stage (native par2), got: {stage_names:?}"
838 );
839 assert!(
842 stage_names.contains(&"Repair"),
843 "Should have Repair stage when articles_failed > 0, got: {stage_names:?}"
844 );
845 }
846
847 fn make_par2_file_set(tmp: &Path, files: &[(&str, &[u8])]) -> rust_par2::Par2FileSet {
854 use rust_par2::{Par2File, Par2FileSet};
855 let mut map = std::collections::HashMap::new();
856 for (i, (name, content)) in files.iter().enumerate() {
857 let tmp_path = tmp.join(format!("_par2_tmp_{i}"));
859 fs::write(&tmp_path, content).unwrap();
860 let hash_16k = rust_par2::compute_hash_16k(&tmp_path).unwrap();
861 let _ = fs::remove_file(&tmp_path);
862
863 let file_id = [i as u8; 16];
864 map.insert(
865 file_id,
866 Par2File {
867 file_id,
868 hash: [0u8; 16],
869 hash_16k,
870 size: content.len() as u64,
871 filename: name.to_string(),
872 slices: vec![],
873 },
874 );
875 }
876 Par2FileSet {
877 recovery_set_id: [0u8; 16],
878 slice_size: 16384,
879 file_order: map.keys().copied().collect(),
880 files: map,
881 recovery_block_count: 0,
882 creator: None,
883 }
884 }
885
886 #[test]
887 fn test_rename_to_par2_names_renames_mismatched() {
888 let dir = tempfile::tempdir().unwrap();
889
890 let content_a = b"AAAA test data for part01";
892 let content_b = b"BBBB test data for part02";
893 fs::write(dir.path().join("Movie.Name.part01.rar"), content_a).unwrap();
894 fs::write(dir.path().join("Movie.Name.part02.rar"), content_b).unwrap();
895
896 let file_set = make_par2_file_set(
898 dir.path(),
899 &[
900 ("xY7kQ3.part01.rar", content_a),
901 ("xY7kQ3.part02.rar", content_b),
902 ],
903 );
904
905 rename_to_par2_names(&file_set, dir.path());
906
907 assert!(
909 dir.path().join("xY7kQ3.part01.rar").exists(),
910 "part01 should be renamed to obfuscated name"
911 );
912 assert!(
913 dir.path().join("xY7kQ3.part02.rar").exists(),
914 "part02 should be renamed to obfuscated name"
915 );
916 assert!(
917 !dir.path().join("Movie.Name.part01.rar").exists(),
918 "old readable name should no longer exist"
919 );
920 assert!(
921 !dir.path().join("Movie.Name.part02.rar").exists(),
922 "old readable name should no longer exist"
923 );
924 }
925
926 #[test]
927 fn test_rename_to_par2_names_skips_when_already_correct() {
928 let dir = tempfile::tempdir().unwrap();
929
930 let content = b"test data already correct";
932 fs::write(dir.path().join("xY7kQ3.part01.rar"), content).unwrap();
933
934 let file_set = make_par2_file_set(dir.path(), &[("xY7kQ3.part01.rar", content)]);
935
936 rename_to_par2_names(&file_set, dir.path());
937
938 assert!(dir.path().join("xY7kQ3.part01.rar").exists());
940 }
941
942 #[test]
943 fn test_rename_to_par2_names_skips_par2_files() {
944 let dir = tempfile::tempdir().unwrap();
945
946 let content = b"par2 file content";
948 fs::write(dir.path().join("Movie.Name.par2"), content).unwrap();
949 fs::write(dir.path().join("Movie.Name.part01.rar"), b"rar data").unwrap();
950
951 let file_set = make_par2_file_set(dir.path(), &[("obfuscated.par2", content)]);
952
953 rename_to_par2_names(&file_set, dir.path());
954
955 assert!(
957 dir.path().join("Movie.Name.par2").exists(),
958 "PAR2 files should be skipped"
959 );
960 }
961
962 #[test]
963 fn test_rename_to_par2_names_no_match() {
964 let dir = tempfile::tempdir().unwrap();
965
966 fs::write(dir.path().join("Movie.Name.part01.rar"), b"unrelated data").unwrap();
968
969 let file_set = make_par2_file_set(
970 dir.path(),
971 &[("xY7kQ3.part01.rar", b"different data" as &[u8])],
972 );
973
974 rename_to_par2_names(&file_set, dir.path());
975
976 assert!(dir.path().join("Movie.Name.part01.rar").exists());
978 assert!(!dir.path().join("xY7kQ3.part01.rar").exists());
979 }
980
981 #[tokio::test]
982 async fn test_pipeline_renames_obfuscated_zip_using_real_par2_fixture() {
983 let dir = copy_fixture_dir("obfuscated_zip_par2");
984 fs::rename(
985 dir.path().join("release.zip"),
986 dir.path().join("obfuscated.29"),
987 )
988 .unwrap();
989
990 let output = tempfile::tempdir().unwrap();
991 let config = PostProcConfig {
992 cleanup_after_extract: false,
993 output_dir: Some(output.path().to_path_buf()),
994 articles_failed: 0,
995 ..Default::default()
996 };
997
998 let result = run_pipeline(dir.path(), &config).await;
999 assert!(result.success, "pipeline should succeed: {result:?}");
1000
1001 let verify_stage = result.stages.iter().find(|s| s.name == "Verify").unwrap();
1002 assert_eq!(verify_stage.status, StageStatus::Skipped);
1003
1004 let extract_stage = result.stages.iter().find(|s| s.name == "Extract").unwrap();
1005 assert_eq!(extract_stage.status, StageStatus::Success);
1006 assert!(
1007 output.path().join("payload.txt").exists(),
1008 "obfuscated archive should be renamed and extracted via PAR2 metadata"
1009 );
1010 assert!(dir.path().join("release.zip").exists());
1011 assert!(!dir.path().join("obfuscated.29").exists());
1012 }
1013}