1use std::collections::HashSet;
10use std::path::{Path, PathBuf};
11use std::time::Instant;
12
13use nzb_core::models::{StageResult, StageStatus};
14use tracing::{debug, error, info, warn};
15
16use crate::detect::{ArchiveType, find_archives, find_cleanup_files, find_par2_files};
17use crate::par2::par2_repair;
18use crate::unpack::{extract_7z, extract_rar, extract_zip};
19
20fn increment_counter(name: &'static str) {
21 opentelemetry::global::meter_provider()
22 .meter("rustnzb")
23 .u64_counter(name)
24 .build()
25 .add(1, &[]);
26}
27
28enum VerifyRepairOutcome {
32 AllCorrect {
33 intact_count: usize,
34 },
35 Damaged {
36 intact: usize,
37 damaged: usize,
38 missing: usize,
39 blocks_needed: u32,
40 blocks_available: u32,
41 repair_result: Result<rust_par2::RepairResult, rust_par2::RepairError>,
42 },
43}
44
45#[derive(Debug)]
47pub struct PostProcResult {
48 pub success: bool,
50 pub stages: Vec<StageResult>,
52 pub error: Option<String>,
54}
55
56#[derive(Debug, Clone)]
58pub struct PostProcConfig {
59 pub cleanup_after_extract: bool,
61 pub output_dir: Option<PathBuf>,
64 pub articles_failed: usize,
69 pub content_articles_failed: usize,
72 pub skip_extract: bool,
75 pub password: Option<String>,
77 pub max_nested_archive_depth: u8,
80}
81
82impl Default for PostProcConfig {
83 fn default() -> Self {
84 Self {
85 cleanup_after_extract: true,
86 output_dir: None,
87 articles_failed: 0,
88 content_articles_failed: 0,
89 skip_extract: false,
90 password: None,
91 max_nested_archive_depth: 5,
92 }
93 }
94}
95
96pub async fn run_pipeline(job_dir: &Path, config: &PostProcConfig) -> PostProcResult {
104 let mut stages: Vec<StageResult> = Vec::new();
105 let mut pipeline_ok = true;
106
107 info!(dir = %job_dir.display(), "Starting post-processing pipeline");
108
109 let par2_files = find_par2_files(job_dir);
121
122 info!(
123 par2_files = par2_files.len(),
124 "PAR2 files discovered for post-processing"
125 );
126
127 if par2_files.is_empty() {
128 if config.content_articles_failed > 0 {
129 pipeline_ok = false;
130 stages.push(StageResult {
131 name: "Verify".to_string(),
132 status: StageStatus::Failed,
133 message: Some(format!(
134 "{} content article(s) missing and no PAR2 recovery set is available",
135 config.content_articles_failed
136 )),
137 duration_secs: 0.0,
138 });
139 } else {
140 stages.push(StageResult {
141 name: "Verify".to_string(),
142 status: StageStatus::Skipped,
143 message: Some("No par2 files found".to_string()),
144 duration_secs: 0.0,
145 });
146 }
147 } else if config.articles_failed == 0 {
148 info!("Skipping PAR2 verification — zero article failures (CRC-verified)");
149 stages.push(StageResult {
150 name: "Verify".to_string(),
151 status: StageStatus::Skipped,
152 message: Some("Skipped — zero article failures".to_string()),
153 duration_secs: 0.0,
154 });
155 } else {
156 let verify_start = Instant::now();
157 let index_par2 = par2_files[0].clone();
158
159 match rust_par2::parse(&index_par2) {
160 Ok(file_set) => {
161 rename_to_par2_names(&file_set, job_dir);
167
168 let dir = job_dir.to_path_buf();
174 let verify_repair_result = tokio::task::spawn_blocking(move || {
175 let verify_result = rust_par2::verify(&file_set, &dir);
176
177 if verify_result.all_correct() {
178 VerifyRepairOutcome::AllCorrect {
179 intact_count: verify_result.intact.len(),
180 }
181 } else {
182 let intact = verify_result.intact.len();
183 let damaged = verify_result.damaged.len();
184 let missing = verify_result.missing.len();
185 let blocks_needed = verify_result.blocks_needed();
186 let blocks_available = verify_result.recovery_blocks_available;
187
188 info!(
189 intact,
190 damaged,
191 missing,
192 blocks_needed,
193 "Native PAR2 verify: damage detected, attempting native repair"
194 );
195
196 info!("Running native PAR2 repair (with pre-computed verify)");
198 let repair_result =
199 rust_par2::repair_from_verify(&file_set, &dir, &verify_result);
200
201 VerifyRepairOutcome::Damaged {
202 intact,
203 damaged,
204 missing,
205 blocks_needed,
206 blocks_available,
207 repair_result,
208 }
209 }
210 })
211 .await;
212
213 let verify_duration = verify_start.elapsed().as_secs_f64();
214
215 match verify_repair_result {
216 Ok(VerifyRepairOutcome::AllCorrect { intact_count }) => {
217 increment_counter("par2.verify_success");
218 info!(
219 files = intact_count,
220 duration_secs = verify_duration,
221 "Native PAR2 verify: all files correct"
222 );
223 stages.push(StageResult {
224 name: "Verify".to_string(),
225 status: StageStatus::Success,
226 message: Some(format!(
227 "All {intact_count} files correct (native verify, {verify_duration:.3}s)",
228 )),
229 duration_secs: verify_duration,
230 });
231 }
232 Ok(VerifyRepairOutcome::Damaged {
233 intact,
234 damaged,
235 missing,
236 blocks_needed,
237 blocks_available,
238 repair_result,
239 }) => {
240 stages.push(StageResult {
242 name: "Verify".to_string(),
243 status: StageStatus::Success,
244 message: Some(format!(
245 "{intact} intact, {damaged} damaged, {missing} missing — {blocks_needed} blocks needed (native verify)",
246 )),
247 duration_secs: verify_duration,
248 });
249
250 match repair_result {
252 Ok(result) => {
253 increment_counter(if result.success {
254 "par2.repair_success"
255 } else {
256 "par2.repair_failure"
257 });
258 info!(
259 blocks_repaired = result.blocks_repaired,
260 files_repaired = result.files_repaired,
261 "Native PAR2 repair complete"
262 );
263 if !result.success {
264 pipeline_ok = false;
265 }
266 stages.push(StageResult {
267 name: "Repair".to_string(),
268 status: if result.success {
269 StageStatus::Success
270 } else {
271 StageStatus::Failed
272 },
273 message: Some(result.message),
274 duration_secs: verify_duration,
275 });
276 }
277 Err(e) => {
278 increment_counter("par2.repair_failure");
279 error!(
280 error = %e,
281 blocks_needed,
282 blocks_available,
283 damaged,
284 missing,
285 "Native PAR2 repair failed"
286 );
287 pipeline_ok = false;
288 stages.push(StageResult {
289 name: "Repair".to_string(),
290 status: StageStatus::Failed,
291 message: Some(format!("Repair failed: {e}")),
292 duration_secs: verify_duration,
293 });
294 }
295 }
296 }
297 Err(e) => {
298 error!(error = %e, "Verify/repair task panicked");
299 pipeline_ok = false;
300 stages.push(StageResult {
301 name: "Verify".to_string(),
302 status: StageStatus::Failed,
303 message: Some(format!("Verify task panicked: {e}")),
304 duration_secs: verify_duration,
305 });
306 }
307 }
308 }
309 Err(e) => {
310 debug!(error = %e, "Native PAR2 parse failed");
312 let verify_duration = verify_start.elapsed().as_secs_f64();
313
314 if config.articles_failed == 0 {
315 stages.push(StageResult {
317 name: "Verify".to_string(),
318 status: StageStatus::Skipped,
319 message: Some(format!(
320 "PAR2 parse failed ({e}), but zero article failures"
321 )),
322 duration_secs: verify_duration,
323 });
324 } else {
325 stages.push(StageResult {
327 name: "Verify".to_string(),
328 status: StageStatus::Skipped,
329 message: Some(format!("PAR2 parse failed ({e}), attempting repair")),
330 duration_secs: verify_duration,
331 });
332
333 let repair_result = run_repair_stage(job_dir).await;
334 increment_counter(if repair_result.status == StageStatus::Failed {
335 "par2.repair_failure"
336 } else {
337 "par2.repair_success"
338 });
339 if repair_result.status == StageStatus::Failed {
340 pipeline_ok = false;
341 }
342 stages.push(repair_result);
343 }
344 }
345 }
346 }
347
348 let should_extract = pipeline_ok;
355 let mut extracted_archives = Vec::new();
356 if should_extract {
357 let output_dir = config.output_dir.as_deref().unwrap_or(job_dir);
358 let source_dir = if config.skip_extract {
362 info!("Outer extraction completed by direct unpack; checking for nested archives");
363 output_dir
364 } else {
365 job_dir
366 };
367 let (result, processed_archives) = run_extract_stage(
368 source_dir,
369 output_dir,
370 config.password.as_deref(),
371 config.max_nested_archive_depth,
372 )
373 .await;
374 extracted_archives = processed_archives;
375 if result.status == StageStatus::Failed {
376 pipeline_ok = false;
377 } else if result.status == StageStatus::Success {
378 pipeline_ok = true;
380 }
381 stages.push(result);
382 }
383
384 if pipeline_ok && config.cleanup_after_extract {
388 let result = run_cleanup_stage(job_dir, &extracted_archives);
389 stages.push(result);
390 }
391
392 let error = if pipeline_ok {
393 None
394 } else {
395 let msgs: Vec<String> = stages
397 .iter()
398 .filter(|s| s.status == StageStatus::Failed)
399 .filter_map(|s| s.message.clone())
400 .collect();
401 Some(msgs.join("; "))
402 };
403
404 info!(
405 success = pipeline_ok,
406 stages = stages.len(),
407 "Post-processing pipeline finished"
408 );
409
410 PostProcResult {
411 success: pipeline_ok,
412 stages,
413 error,
414 }
415}
416
417fn rename_to_par2_names(file_set: &rust_par2::Par2FileSet, dir: &Path) {
431 let mut expected: std::collections::HashMap<[u8; 16], &str> = std::collections::HashMap::new();
433 for par2_file in file_set.files.values() {
434 expected.insert(par2_file.hash_16k, &par2_file.filename);
435 }
436
437 let any_match = file_set
439 .files
440 .values()
441 .any(|f| dir.join(&f.filename).exists());
442 if any_match {
443 return;
444 }
445
446 let entries: Vec<_> = match std::fs::read_dir(dir) {
448 Ok(rd) => rd.filter_map(|e| e.ok()).collect(),
449 Err(_) => return,
450 };
451
452 let mut renamed = 0u32;
453 for entry in &entries {
454 let path = entry.path();
455 if !path.is_file() {
456 continue;
457 }
458 let current_name = match path.file_name().and_then(|n| n.to_str()) {
459 Some(n) => n.to_string(),
460 None => continue,
461 };
462
463 if current_name.to_lowercase().ends_with(".par2") {
465 continue;
466 }
467
468 let hash = match rust_par2::compute_hash_16k(&path) {
469 Ok(h) => h,
470 Err(_) => continue,
471 };
472
473 if let Some(&par2_name) = expected.get(&hash)
474 && current_name != par2_name
475 {
476 let new_path = dir.join(par2_name);
477 if !new_path.exists() {
478 if let Err(e) = std::fs::rename(&path, &new_path) {
479 warn!(
480 from = %current_name,
481 to = %par2_name,
482 "Failed to rename file to PAR2 expected name: {e}"
483 );
484 } else {
485 renamed += 1;
486 debug!(
487 from = %current_name,
488 to = %par2_name,
489 "Renamed file to match PAR2 metadata"
490 );
491 }
492 }
493 }
494 }
495
496 if renamed > 0 {
497 info!(
498 renamed,
499 "PAR2-guided deobfuscation: renamed files to match PAR2 expected names"
500 );
501 }
502}
503
504async fn run_repair_stage(job_dir: &Path) -> StageResult {
511 let start = Instant::now();
512 let par2_files = find_par2_files(job_dir);
513
514 if par2_files.is_empty() {
515 return StageResult {
516 name: "Repair".to_string(),
517 status: StageStatus::Skipped,
518 message: Some("No par2 files found".to_string()),
519 duration_secs: start.elapsed().as_secs_f64(),
520 };
521 }
522
523 let index_par2 = &par2_files[0];
524 info!(file = %index_par2.display(), "Running native par2 repair");
525
526 match par2_repair(index_par2).await {
527 Ok(result) => {
528 let status = if result.repaired || result.success {
529 StageStatus::Success
530 } else {
531 StageStatus::Failed
532 };
533
534 StageResult {
535 name: "Repair".to_string(),
536 status,
537 message: Some(result.message),
538 duration_secs: start.elapsed().as_secs_f64(),
539 }
540 }
541 Err(e) => {
542 error!(error = %e, "par2 repair failed with error");
543 StageResult {
544 name: "Repair".to_string(),
545 status: StageStatus::Failed,
546 message: Some(format!("par2 repair error: {e}")),
547 duration_secs: start.elapsed().as_secs_f64(),
548 }
549 }
550 }
551}
552
553async fn run_extract_stage(
554 source_dir: &Path,
555 output_dir: &Path,
556 password: Option<&str>,
557 max_nested_archive_depth: u8,
558) -> (StageResult, Vec<PathBuf>) {
559 let start = Instant::now();
560 let mut all_ok = true;
561 let mut messages: Vec<String> = Vec::new();
562 let mut processed: HashSet<PathBuf> = if source_dir == output_dir {
567 HashSet::new()
568 } else {
569 find_archives(output_dir)
570 .into_iter()
571 .map(|(_, path)| path)
572 .collect()
573 };
574 let mut extracted_archives = Vec::new();
575 let mut scan_dir = source_dir;
576 let mut extracted_any = false;
577
578 for depth in 0..=max_nested_archive_depth {
579 let archives: Vec<_> = find_archives(scan_dir)
580 .into_iter()
581 .filter(|(_, path)| processed.insert(path.clone()))
582 .collect();
583 if archives.is_empty() {
584 break;
585 }
586
587 extracted_any = true;
588 extracted_archives.extend(archives.iter().map(|(_, path)| path.clone()));
589 for (archive_type, path) in &archives {
590 info!(depth, kind = %archive_type, file = %path.display(), "Extracting archive");
591 let result = match archive_type {
592 ArchiveType::Rar => extract_rar(path, output_dir, password).await,
593 ArchiveType::SevenZip => extract_7z(path, output_dir, password).await,
594 ArchiveType::Zip => extract_zip(path, output_dir).await,
595 };
596
597 match result {
598 Ok(unpack_result) if unpack_result.success => {
599 messages.push(format!("depth {depth} {archive_type}: OK"));
600 }
601 Ok(unpack_result) => {
602 all_ok = false;
603 let detail = unpack_result
604 .error_output
605 .trim()
606 .lines()
607 .find(|line| !line.trim().is_empty());
608 messages.push(match detail {
609 Some(detail) => format!("depth {depth} {archive_type}: failed ({detail})"),
610 None => format!("depth {depth} {archive_type}: failed"),
611 });
612 }
613 Err(e) => {
614 all_ok = false;
615 error!(depth, kind = %archive_type, file = %path.display(), error = %e, "Extraction error");
616 messages.push(format!("depth {depth} {archive_type}: {e}"));
617 }
618 }
619 }
620
621 if !all_ok {
622 break;
623 }
624 scan_dir = output_dir;
625 }
626
627 if all_ok
628 && !find_archives(scan_dir)
629 .into_iter()
630 .all(|(_, path)| processed.contains(&path))
631 {
632 all_ok = false;
633 messages.push(format!(
634 "nested archive depth limit ({max_nested_archive_depth}) reached; source files retained"
635 ));
636 }
637
638 if !extracted_any {
639 info!("No archives found — skipping extraction");
640 return (
641 StageResult {
642 name: "Extract".to_string(),
643 status: StageStatus::Skipped,
644 message: Some("No archives found".to_string()),
645 duration_secs: start.elapsed().as_secs_f64(),
646 },
647 Vec::new(),
648 );
649 }
650
651 (
652 StageResult {
653 name: "Extract".to_string(),
654 status: if all_ok {
655 StageStatus::Success
656 } else {
657 StageStatus::Failed
658 },
659 message: Some(messages.join("; ")),
660 duration_secs: start.elapsed().as_secs_f64(),
661 },
662 extracted_archives,
663 )
664}
665
666fn run_cleanup_stage(job_dir: &Path, extracted_archives: &[PathBuf]) -> StageResult {
667 let start = Instant::now();
668 let mut files = find_cleanup_files(job_dir);
669 files.extend(
670 extracted_archives
671 .iter()
672 .filter(|path| path.is_file())
673 .cloned(),
674 );
675 files.sort();
676 files.dedup();
677
678 if files.is_empty() {
679 return StageResult {
680 name: "Cleanup".to_string(),
681 status: StageStatus::Skipped,
682 message: Some("No files to clean up".to_string()),
683 duration_secs: start.elapsed().as_secs_f64(),
684 };
685 }
686
687 let mut removed = 0u32;
688 let mut errors = 0u32;
689
690 for path in &files {
691 match std::fs::remove_file(path) {
692 Ok(()) => {
693 removed += 1;
694 }
695 Err(e) => {
696 warn!(file = %path.display(), error = %e, "Failed to remove cleanup file");
697 errors += 1;
698 }
699 }
700 }
701
702 let status = if errors == 0 {
703 StageStatus::Success
704 } else {
705 StageStatus::Failed
706 };
707
708 StageResult {
709 name: "Cleanup".to_string(),
710 status,
711 message: Some(format!("Removed {removed} files, {errors} errors")),
712 duration_secs: start.elapsed().as_secs_f64(),
713 }
714}
715
716#[cfg(test)]
721mod tests {
722 use super::*;
723 use std::fs;
724 use std::io::Write;
725
726 fn make_test_dir(files: &[&str]) -> tempfile::TempDir {
727 let dir = tempfile::tempdir().unwrap();
728 for name in files {
729 let path = dir.path().join(name);
730 if let Some(parent) = path.parent() {
731 fs::create_dir_all(parent).unwrap();
732 }
733 fs::write(&path, b"").unwrap();
734 }
735 dir
736 }
737
738 #[test]
739 fn test_post_proc_result_default() {
740 let result = PostProcResult {
741 success: true,
742 stages: vec![],
743 error: None,
744 };
745 assert!(result.success);
746 assert!(result.stages.is_empty());
747 assert!(result.error.is_none());
748 }
749
750 #[test]
751 fn test_config_default() {
752 let config = PostProcConfig::default();
753 assert!(config.cleanup_after_extract);
754 assert!(config.output_dir.is_none());
755 assert_eq!(config.articles_failed, 0);
756 }
757
758 #[tokio::test]
759 async fn test_pipeline_no_files() {
760 let dir = make_test_dir(&[]);
762 let config = PostProcConfig::default();
763 let result = run_pipeline(dir.path(), &config).await;
764
765 assert!(result.success, "Pipeline should succeed for empty dir");
766
767 let verify_stage = result.stages.iter().find(|s| s.name == "Verify");
769 assert!(verify_stage.is_some(), "Verify stage should be present");
770 assert_eq!(verify_stage.unwrap().status, StageStatus::Skipped);
771
772 let extract_stage = result.stages.iter().find(|s| s.name == "Extract");
773 assert!(extract_stage.is_some(), "Extract stage should be present");
774 assert_eq!(extract_stage.unwrap().status, StageStatus::Skipped);
775 }
776
777 #[tokio::test]
778 async fn test_pipeline_only_text_files() {
779 let dir = make_test_dir(&["readme.txt", "info.nfo"]);
780 let config = PostProcConfig::default();
781 let result = run_pipeline(dir.path(), &config).await;
782
783 assert!(result.success);
784 for stage in &result.stages {
786 assert_eq!(
787 stage.status,
788 StageStatus::Skipped,
789 "Stage '{}' should be skipped",
790 stage.name
791 );
792 }
793 }
794
795 #[test]
796 fn test_cleanup_removes_files() {
797 let dir = make_test_dir(&[
798 "movie.par2",
799 "movie.vol00+01.par2",
800 "movie.rar",
801 "movie.r00",
802 "movie.mkv", ]);
804
805 let result = run_cleanup_stage(dir.path(), &[]);
806 assert_eq!(result.status, StageStatus::Success);
807
808 assert!(dir.path().join("movie.mkv").exists());
810 assert!(!dir.path().join("movie.par2").exists());
812 assert!(!dir.path().join("movie.vol00+01.par2").exists());
813 assert!(!dir.path().join("movie.rar").exists());
814 assert!(!dir.path().join("movie.r00").exists());
815 }
816
817 fn write_zip(path: &Path, entries: &[(&str, &[u8])]) {
818 let file = fs::File::create(path).unwrap();
819 let mut writer = zip::ZipWriter::new(file);
820 for (name, contents) in entries {
821 writer
822 .start_file(*name, zip::write::SimpleFileOptions::default())
823 .unwrap();
824 writer.write_all(contents).unwrap();
825 }
826 writer.finish().unwrap();
827 }
828
829 #[tokio::test]
830 async fn nested_zip_archives_are_extracted_recursively() {
831 let source = tempfile::tempdir().unwrap();
832 let output = tempfile::tempdir().unwrap();
833 let inner = source.path().join("inner.zip");
834 write_zip(&inner, &[("payload.txt", b"nested payload")]);
835 let outer = source.path().join("outer.zip");
836 write_zip(&outer, &[("inner.zip", &fs::read(&inner).unwrap())]);
837 fs::remove_file(inner).unwrap();
838
839 let (result, _) = run_extract_stage(source.path(), output.path(), None, 1).await;
840
841 assert_eq!(result.status, StageStatus::Success, "{result:?}");
842 assert_eq!(
843 fs::read(output.path().join("payload.txt")).unwrap(),
844 b"nested payload"
845 );
846 }
847
848 #[tokio::test]
849 async fn nested_archive_depth_limit_fails_without_discarding_inner_archive() {
850 let source = tempfile::tempdir().unwrap();
851 let output = tempfile::tempdir().unwrap();
852 let inner = source.path().join("inner.zip");
853 write_zip(&inner, &[("payload.txt", b"nested payload")]);
854 let outer = source.path().join("outer.zip");
855 write_zip(&outer, &[("inner.zip", &fs::read(&inner).unwrap())]);
856 fs::remove_file(inner).unwrap();
857
858 let (result, _) = run_extract_stage(source.path(), output.path(), None, 0).await;
859
860 assert_eq!(result.status, StageStatus::Failed, "{result:?}");
861 assert!(output.path().join("inner.zip").exists());
862 assert!(!output.path().join("payload.txt").exists());
863 }
864
865 #[tokio::test]
866 async fn recursive_cleanup_removes_nested_archives_from_output_directory() {
867 let source = tempfile::tempdir().unwrap();
868 let output = tempfile::tempdir().unwrap();
869 let inner = source.path().join("inner.zip");
870 write_zip(&inner, &[("payload.txt", b"nested payload")]);
871 let outer = source.path().join("outer.zip");
872 write_zip(&outer, &[("inner.zip", &fs::read(&inner).unwrap())]);
873 fs::remove_file(inner).unwrap();
874
875 let config = PostProcConfig {
876 output_dir: Some(output.path().to_path_buf()),
877 ..Default::default()
878 };
879 let result = run_pipeline(source.path(), &config).await;
880
881 assert!(result.success, "{result:?}");
882 assert_eq!(
883 fs::read(output.path().join("payload.txt")).unwrap(),
884 b"nested payload"
885 );
886 assert!(!output.path().join("inner.zip").exists());
887 assert!(!source.path().join("outer.zip").exists());
888 }
889
890 #[tokio::test]
891 async fn cleanup_keeps_unrelated_archives_in_the_output_directory() {
892 let source = tempfile::tempdir().unwrap();
893 let output = tempfile::tempdir().unwrap();
894 let inner = source.path().join("inner.zip");
895 write_zip(&inner, &[("payload.txt", b"nested payload")]);
896 let outer = source.path().join("outer.zip");
897 write_zip(&outer, &[("inner.zip", &fs::read(&inner).unwrap())]);
898 fs::remove_file(inner).unwrap();
899 let unrelated = output.path().join("keep-me.zip");
900 write_zip(&unrelated, &[("unrelated.txt", b"keep")]);
901
902 let config = PostProcConfig {
903 output_dir: Some(output.path().to_path_buf()),
904 ..Default::default()
905 };
906 let result = run_pipeline(source.path(), &config).await;
907
908 assert!(result.success, "{result:?}");
909 assert!(unrelated.exists());
910 assert!(!output.path().join("inner.zip").exists());
911 }
912
913 #[tokio::test]
914 async fn direct_unpack_still_extracts_nested_archives() {
915 let job_dir = tempfile::tempdir().unwrap();
916 let output = tempfile::tempdir().unwrap();
917 let nested = output.path().join("nested.zip");
918 write_zip(&nested, &[("payload.txt", b"nested payload")]);
919
920 let config = PostProcConfig {
921 output_dir: Some(output.path().to_path_buf()),
922 skip_extract: true,
923 ..Default::default()
924 };
925 let result = run_pipeline(job_dir.path(), &config).await;
926
927 assert!(result.success, "{result:?}");
928 assert_eq!(
929 fs::read(output.path().join("payload.txt")).unwrap(),
930 b"nested payload"
931 );
932 assert!(!nested.exists());
933 }
934
935 #[tokio::test]
936 async fn test_pipeline_stage_order() {
937 let dir = make_test_dir(&[]);
940 let config = PostProcConfig {
941 cleanup_after_extract: false,
942 ..Default::default()
943 };
944 let result = run_pipeline(dir.path(), &config).await;
945
946 let stage_names: Vec<&str> = result.stages.iter().map(|s| s.name.as_str()).collect();
948 assert!(stage_names.contains(&"Verify"), "Should have Verify stage");
949 assert!(
950 stage_names.contains(&"Extract"),
951 "Should have Extract stage"
952 );
953
954 let verify_idx = stage_names.iter().position(|&n| n == "Verify").unwrap();
956 let extract_idx = stage_names.iter().position(|&n| n == "Extract").unwrap();
957 assert!(
958 verify_idx < extract_idx,
959 "Verify ({verify_idx}) should come before Extract ({extract_idx})"
960 );
961 }
962
963 #[tokio::test]
964 async fn test_pipeline_skips_verify_with_zero_failures() {
965 let dir = make_test_dir(&["movie.par2", "movie.vol00+01.par2", "movie.mkv"]);
968 let config = PostProcConfig {
969 cleanup_after_extract: false,
970 ..Default::default()
971 };
972 let result = run_pipeline(dir.path(), &config).await;
973 assert!(result.success);
974
975 let verify_stage = result.stages.iter().find(|s| s.name == "Verify").unwrap();
976 assert_eq!(
977 verify_stage.status,
978 StageStatus::Skipped,
979 "Verify should be skipped when articles_failed == 0"
980 );
981 assert!(
982 verify_stage
983 .message
984 .as_deref()
985 .unwrap_or("")
986 .contains("zero article failures"),
987 "Skip message should indicate zero failures"
988 );
989 }
990
991 #[tokio::test]
992 async fn test_pipeline_no_par2_with_content_failures_is_terminal() {
993 let dir = make_test_dir(&["movie.mkv"]);
996 let config = PostProcConfig {
997 cleanup_after_extract: false,
998 articles_failed: 5,
999 content_articles_failed: 5,
1000 ..Default::default()
1001 };
1002 let result = run_pipeline(dir.path(), &config).await;
1003 assert!(!result.success);
1004
1005 let verify_stage = result.stages.iter().find(|s| s.name == "Verify").unwrap();
1006 assert_eq!(
1007 verify_stage.status,
1008 StageStatus::Failed,
1009 "No-PAR content damage is unrecoverable"
1010 );
1011 assert!(
1012 verify_stage
1013 .message
1014 .as_deref()
1015 .unwrap_or("")
1016 .contains("no PAR2 recovery set"),
1017 "Failure should explain that recovery data is unavailable"
1018 );
1019 assert!(result.stages.iter().all(|stage| stage.name != "Extract"));
1020 }
1021
1022 #[tokio::test]
1023 async fn test_pipeline_runs_verify_then_repair_when_failures() {
1024 let dir = make_test_dir(&["movie.par2", "movie.vol00+01.par2", "movie.mkv"]);
1028 let config = PostProcConfig {
1029 cleanup_after_extract: false,
1030 articles_failed: 3,
1031 ..Default::default()
1032 };
1033 let result = run_pipeline(dir.path(), &config).await;
1034
1035 let stage_names: Vec<&str> = result.stages.iter().map(|s| s.name.as_str()).collect();
1037 assert!(
1038 stage_names.contains(&"Verify"),
1039 "Should have Verify stage (native par2), got: {stage_names:?}"
1040 );
1041 assert!(
1044 stage_names.contains(&"Repair"),
1045 "Should have Repair stage when articles_failed > 0, got: {stage_names:?}"
1046 );
1047 }
1048
1049 fn make_par2_file_set(tmp: &Path, files: &[(&str, &[u8])]) -> rust_par2::Par2FileSet {
1056 use rust_par2::{Par2File, Par2FileSet};
1057 let mut map = std::collections::HashMap::new();
1058 let mut file_order = Vec::with_capacity(files.len());
1059 for (i, (name, content)) in files.iter().enumerate() {
1060 let tmp_path = tmp.join(format!("_par2_tmp_{i}"));
1062 fs::write(&tmp_path, content).unwrap();
1063 let hash_16k = rust_par2::compute_hash_16k(&tmp_path).unwrap();
1064 let _ = fs::remove_file(&tmp_path);
1065
1066 let file_id = [i as u8; 16];
1067 file_order.push(file_id);
1068 map.insert(
1069 file_id,
1070 Par2File {
1071 file_id,
1072 hash: [0u8; 16],
1073 hash_16k,
1074 size: content.len() as u64,
1075 filename: name.to_string(),
1076 slices: vec![],
1077 },
1078 );
1079 }
1080 Par2FileSet {
1081 recovery_set_id: [0u8; 16],
1082 slice_size: 16384,
1083 file_order,
1084 files: map,
1085 recovery_block_count: 0,
1086 creator: None,
1087 }
1088 }
1089
1090 #[test]
1091 fn test_rename_to_par2_names_renames_mismatched() {
1092 let dir = tempfile::tempdir().unwrap();
1093
1094 let content_a = b"AAAA test data for part01";
1096 let content_b = b"BBBB test data for part02";
1097 fs::write(dir.path().join("Movie.Name.part01.rar"), content_a).unwrap();
1098 fs::write(dir.path().join("Movie.Name.part02.rar"), content_b).unwrap();
1099
1100 let file_set = make_par2_file_set(
1102 dir.path(),
1103 &[
1104 ("xY7kQ3.part01.rar", content_a),
1105 ("xY7kQ3.part02.rar", content_b),
1106 ],
1107 );
1108
1109 rename_to_par2_names(&file_set, dir.path());
1110
1111 assert!(
1113 dir.path().join("xY7kQ3.part01.rar").exists(),
1114 "part01 should be renamed to obfuscated name"
1115 );
1116 assert!(
1117 dir.path().join("xY7kQ3.part02.rar").exists(),
1118 "part02 should be renamed to obfuscated name"
1119 );
1120 assert!(
1121 !dir.path().join("Movie.Name.part01.rar").exists(),
1122 "old readable name should no longer exist"
1123 );
1124 assert!(
1125 !dir.path().join("Movie.Name.part02.rar").exists(),
1126 "old readable name should no longer exist"
1127 );
1128 }
1129
1130 #[test]
1131 fn test_rename_to_par2_names_skips_when_already_correct() {
1132 let dir = tempfile::tempdir().unwrap();
1133
1134 let content = b"test data already correct";
1136 fs::write(dir.path().join("xY7kQ3.part01.rar"), content).unwrap();
1137
1138 let file_set = make_par2_file_set(dir.path(), &[("xY7kQ3.part01.rar", content)]);
1139
1140 rename_to_par2_names(&file_set, dir.path());
1141
1142 assert!(dir.path().join("xY7kQ3.part01.rar").exists());
1144 }
1145
1146 #[test]
1147 fn test_rename_to_par2_names_skips_par2_files() {
1148 let dir = tempfile::tempdir().unwrap();
1149
1150 let content = b"par2 file content";
1152 fs::write(dir.path().join("Movie.Name.par2"), content).unwrap();
1153 fs::write(dir.path().join("Movie.Name.part01.rar"), b"rar data").unwrap();
1154
1155 let file_set = make_par2_file_set(dir.path(), &[("obfuscated.par2", content)]);
1156
1157 rename_to_par2_names(&file_set, dir.path());
1158
1159 assert!(
1161 dir.path().join("Movie.Name.par2").exists(),
1162 "PAR2 files should be skipped"
1163 );
1164 }
1165
1166 #[test]
1167 fn test_rename_to_par2_names_no_match() {
1168 let dir = tempfile::tempdir().unwrap();
1169
1170 fs::write(dir.path().join("Movie.Name.part01.rar"), b"unrelated data").unwrap();
1172
1173 let file_set = make_par2_file_set(
1174 dir.path(),
1175 &[("xY7kQ3.part01.rar", b"different data" as &[u8])],
1176 );
1177
1178 rename_to_par2_names(&file_set, dir.path());
1179
1180 assert!(dir.path().join("Movie.Name.part01.rar").exists());
1182 assert!(!dir.path().join("xY7kQ3.part01.rar").exists());
1183 }
1184}