1use std::collections::HashSet;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12use std::time::Instant;
13
14use nzb_core::models::{StageResult, StageStatus};
15use tracing::{debug, error, info, warn};
16
17use crate::detect::{ArchiveType, find_archives, find_cleanup_files, find_par2_files};
18use crate::par2::par2_repair;
19use crate::resources::PostProcResourcePool;
20use crate::unpack::{extract_7z, extract_rar, extract_tar, extract_zip};
21
22fn increment_counter(name: &'static str) {
23 opentelemetry::global::meter_provider()
24 .meter("rustnzb")
25 .u64_counter(name)
26 .build()
27 .add(1, &[]);
28}
29
30enum VerifyRepairOutcome {
34 AllCorrect {
35 intact_count: usize,
36 },
37 Damaged {
38 intact: usize,
39 damaged: usize,
40 missing: usize,
41 blocks_needed: u32,
42 blocks_available: u32,
43 repair_result: Result<rust_par2::RepairResult, rust_par2::RepairError>,
44 },
45}
46
47#[derive(Debug)]
49pub struct PostProcResult {
50 pub success: bool,
52 pub stages: Vec<StageResult>,
54 pub error: Option<String>,
56}
57
58#[derive(Debug, Clone)]
60pub struct PostProcConfig {
61 pub cleanup_after_extract: bool,
63 pub output_dir: Option<PathBuf>,
66 pub articles_failed: usize,
71 pub content_articles_failed: usize,
74 pub skip_extract: bool,
77 pub password: Option<String>,
79 pub max_nested_archive_depth: u8,
82}
83
84impl Default for PostProcConfig {
85 fn default() -> Self {
86 Self {
87 cleanup_after_extract: true,
88 output_dir: None,
89 articles_failed: 0,
90 content_articles_failed: 0,
91 skip_extract: false,
92 password: None,
93 max_nested_archive_depth: 5,
94 }
95 }
96}
97
98pub async fn run_pipeline(job_dir: &Path, config: &PostProcConfig) -> PostProcResult {
106 run_pipeline_with_resources(job_dir, config, None).await
107}
108
109pub async fn run_pipeline_with_resources(
115 job_dir: &Path,
116 config: &PostProcConfig,
117 resources: Option<&Arc<PostProcResourcePool>>,
118) -> PostProcResult {
119 run_pipeline_with_cleanup(job_dir, config, resources, &[], &[]).await
120}
121
122pub async fn run_pipeline_with_cleanup(
124 job_dir: &Path,
125 config: &PostProcConfig,
126 resources: Option<&Arc<PostProcResourcePool>>,
127 cleanup_patterns: &[String],
128 unwanted_extensions: &[String],
129) -> PostProcResult {
130 let mut stages: Vec<StageResult> = Vec::new();
131 let mut pipeline_ok = true;
132
133 info!(dir = %job_dir.display(), "Starting post-processing pipeline");
134
135 let par2_files = find_par2_files(job_dir);
147
148 info!(
149 par2_files = par2_files.len(),
150 "PAR2 files discovered for post-processing"
151 );
152
153 if par2_files.is_empty() {
154 if config.content_articles_failed > 0 {
155 pipeline_ok = false;
156 stages.push(StageResult {
157 name: "Verify".to_string(),
158 status: StageStatus::Failed,
159 message: Some(format!(
160 "{} content article(s) missing and no PAR2 recovery set is available",
161 config.content_articles_failed
162 )),
163 duration_secs: 0.0,
164 });
165 } else {
166 stages.push(StageResult {
167 name: "Verify".to_string(),
168 status: StageStatus::Skipped,
169 message: Some("No par2 files found".to_string()),
170 duration_secs: 0.0,
171 });
172 }
173 } else if config.articles_failed == 0 {
174 info!("Skipping PAR2 verification — zero article failures (CRC-verified)");
186 let start = Instant::now();
187 let message = match rust_par2::parse(&par2_files[0]) {
188 Ok(file_set) => {
189 rename_to_par2_names(&file_set, job_dir);
190 "Skipped — zero article failures (PAR2-guided rename applied)".to_string()
191 }
192 Err(e) => {
193 debug!(error = %e, "PAR2 parse failed; skipping PAR2-guided deobfuscation");
194 format!("Skipped — zero article failures (PAR2 parse failed: {e})")
195 }
196 };
197 stages.push(StageResult {
198 name: "Verify".to_string(),
199 status: StageStatus::Skipped,
200 message: Some(message),
201 duration_secs: start.elapsed().as_secs_f64(),
202 });
203 } else {
204 let verify_start = Instant::now();
205 let _repair_permit = if let Some(resources) = resources {
206 Some(resources.acquire_repair().await)
207 } else {
208 None
209 };
210 let index_par2 = par2_files[0].clone();
211
212 match rust_par2::parse(&index_par2) {
213 Ok(file_set) => {
214 rename_to_par2_names(&file_set, job_dir);
222
223 let dir = job_dir.to_path_buf();
229 let verify_repair_result = tokio::task::spawn_blocking(move || {
230 let verify_result = rust_par2::verify(&file_set, &dir);
231
232 if verify_result.all_correct() {
233 VerifyRepairOutcome::AllCorrect {
234 intact_count: verify_result.intact.len(),
235 }
236 } else {
237 let intact = verify_result.intact.len();
238 let damaged = verify_result.damaged.len();
239 let missing = verify_result.missing.len();
240 let blocks_needed = verify_result.blocks_needed();
241 let blocks_available = verify_result.recovery_blocks_available;
242
243 info!(
244 intact,
245 damaged,
246 missing,
247 blocks_needed,
248 "Native PAR2 verify: damage detected, attempting native repair"
249 );
250
251 info!("Running native PAR2 repair (with pre-computed verify)");
253 let repair_result =
254 rust_par2::repair_from_verify(&file_set, &dir, &verify_result);
255
256 VerifyRepairOutcome::Damaged {
257 intact,
258 damaged,
259 missing,
260 blocks_needed,
261 blocks_available,
262 repair_result,
263 }
264 }
265 })
266 .await;
267
268 let verify_duration = verify_start.elapsed().as_secs_f64();
269
270 match verify_repair_result {
271 Ok(VerifyRepairOutcome::AllCorrect { intact_count }) => {
272 increment_counter("par2.verify_success");
273 info!(
274 files = intact_count,
275 duration_secs = verify_duration,
276 "Native PAR2 verify: all files correct"
277 );
278 stages.push(StageResult {
279 name: "Verify".to_string(),
280 status: StageStatus::Success,
281 message: Some(format!(
282 "All {intact_count} files correct (native verify, {verify_duration:.3}s)",
283 )),
284 duration_secs: verify_duration,
285 });
286 }
287 Ok(VerifyRepairOutcome::Damaged {
288 intact,
289 damaged,
290 missing,
291 blocks_needed,
292 blocks_available,
293 repair_result,
294 }) => {
295 stages.push(StageResult {
297 name: "Verify".to_string(),
298 status: StageStatus::Success,
299 message: Some(format!(
300 "{intact} intact, {damaged} damaged, {missing} missing — {blocks_needed} blocks needed (native verify)",
301 )),
302 duration_secs: verify_duration,
303 });
304
305 match repair_result {
307 Ok(result) => {
308 increment_counter(if result.success {
309 "par2.repair_success"
310 } else {
311 "par2.repair_failure"
312 });
313 info!(
314 blocks_repaired = result.blocks_repaired,
315 files_repaired = result.files_repaired,
316 "Native PAR2 repair complete"
317 );
318 if !result.success {
319 pipeline_ok = false;
320 }
321 stages.push(StageResult {
322 name: "Repair".to_string(),
323 status: if result.success {
324 StageStatus::Success
325 } else {
326 StageStatus::Failed
327 },
328 message: Some(result.message),
329 duration_secs: verify_duration,
330 });
331 }
332 Err(e) => {
333 increment_counter("par2.repair_failure");
334 error!(
335 error = %e,
336 blocks_needed,
337 blocks_available,
338 damaged,
339 missing,
340 "Native PAR2 repair failed"
341 );
342 pipeline_ok = false;
343 stages.push(StageResult {
344 name: "Repair".to_string(),
345 status: StageStatus::Failed,
346 message: Some(format!("Repair failed: {e}")),
347 duration_secs: verify_duration,
348 });
349 }
350 }
351 }
352 Err(e) => {
353 error!(error = %e, "Verify/repair task panicked");
354 pipeline_ok = false;
355 stages.push(StageResult {
356 name: "Verify".to_string(),
357 status: StageStatus::Failed,
358 message: Some(format!("Verify task panicked: {e}")),
359 duration_secs: verify_duration,
360 });
361 }
362 }
363 }
364 Err(e) => {
365 debug!(error = %e, "Native PAR2 parse failed");
367 let verify_duration = verify_start.elapsed().as_secs_f64();
368
369 if config.articles_failed == 0 {
370 stages.push(StageResult {
372 name: "Verify".to_string(),
373 status: StageStatus::Skipped,
374 message: Some(format!(
375 "PAR2 parse failed ({e}), but zero article failures"
376 )),
377 duration_secs: verify_duration,
378 });
379 } else {
380 stages.push(StageResult {
382 name: "Verify".to_string(),
383 status: StageStatus::Skipped,
384 message: Some(format!("PAR2 parse failed ({e}), attempting repair")),
385 duration_secs: verify_duration,
386 });
387
388 let repair_result = run_repair_stage(job_dir).await;
389 increment_counter(if repair_result.status == StageStatus::Failed {
390 "par2.repair_failure"
391 } else {
392 "par2.repair_success"
393 });
394 if repair_result.status == StageStatus::Failed {
395 pipeline_ok = false;
396 }
397 stages.push(repair_result);
398 }
399 }
400 }
401 }
402
403 let should_extract = pipeline_ok;
410 let mut extracted_archives = Vec::new();
411 if should_extract {
412 let output_dir = config.output_dir.as_deref().unwrap_or(job_dir);
413 let source_dir = if config.skip_extract {
417 info!("Outer extraction completed by direct unpack; checking for nested archives");
418 output_dir
419 } else {
420 job_dir
421 };
422 let _extract_permit = if let Some(resources) = resources {
423 Some(resources.acquire_extract().await)
424 } else {
425 None
426 };
427 let (result, processed_archives) = run_extract_stage(
428 source_dir,
429 output_dir,
430 config.password.as_deref(),
431 config.max_nested_archive_depth,
432 )
433 .await;
434 extracted_archives = processed_archives;
435 if result.status == StageStatus::Failed {
436 pipeline_ok = false;
437 } else if result.status == StageStatus::Success {
438 pipeline_ok = true;
440 }
441 stages.push(result);
442 }
443
444 if pipeline_ok && config.cleanup_after_extract {
448 let cleanup_root = config.output_dir.as_deref().unwrap_or(job_dir);
449 let result = run_cleanup_stage_with_rules(
450 job_dir,
451 cleanup_root,
452 &extracted_archives,
453 cleanup_patterns,
454 unwanted_extensions,
455 );
456 stages.push(result);
457 }
458
459 let error = if pipeline_ok {
460 None
461 } else {
462 let msgs: Vec<String> = stages
464 .iter()
465 .filter(|s| s.status == StageStatus::Failed)
466 .filter_map(|s| s.message.clone())
467 .collect();
468 Some(msgs.join("; "))
469 };
470
471 info!(
472 success = pipeline_ok,
473 stages = stages.len(),
474 "Post-processing pipeline finished"
475 );
476
477 PostProcResult {
478 success: pipeline_ok,
479 stages,
480 error,
481 }
482}
483
484fn rename_to_par2_names(file_set: &rust_par2::Par2FileSet, dir: &Path) {
498 let mut expected: std::collections::HashMap<[u8; 16], &str> = std::collections::HashMap::new();
500 for par2_file in file_set.files.values() {
501 expected.insert(par2_file.hash_16k, &par2_file.filename);
502 }
503
504 let any_match = file_set
506 .files
507 .values()
508 .any(|f| dir.join(&f.filename).exists());
509 if any_match {
510 return;
511 }
512
513 let entries: Vec<_> = match std::fs::read_dir(dir) {
515 Ok(rd) => rd.filter_map(|e| e.ok()).collect(),
516 Err(_) => return,
517 };
518
519 let mut renamed = 0u32;
520 for entry in &entries {
521 let path = entry.path();
522 if !path.is_file() {
523 continue;
524 }
525 let current_name = match path.file_name().and_then(|n| n.to_str()) {
526 Some(n) => n.to_string(),
527 None => continue,
528 };
529
530 if current_name.to_lowercase().ends_with(".par2") {
532 continue;
533 }
534
535 let hash = match rust_par2::compute_hash_16k(&path) {
536 Ok(h) => h,
537 Err(_) => continue,
538 };
539
540 if let Some(&par2_name) = expected.get(&hash)
541 && current_name != par2_name
542 {
543 let new_path = dir.join(par2_name);
544 if !new_path.exists() {
545 if let Err(e) = std::fs::rename(&path, &new_path) {
546 warn!(
547 from = %current_name,
548 to = %par2_name,
549 "Failed to rename file to PAR2 expected name: {e}"
550 );
551 } else {
552 renamed += 1;
553 debug!(
554 from = %current_name,
555 to = %par2_name,
556 "Renamed file to match PAR2 metadata"
557 );
558 }
559 }
560 }
561 }
562
563 if renamed > 0 {
564 info!(
565 renamed,
566 "PAR2-guided deobfuscation: renamed files to match PAR2 expected names"
567 );
568 }
569}
570
571async fn run_repair_stage(job_dir: &Path) -> StageResult {
578 let start = Instant::now();
579 let par2_files = find_par2_files(job_dir);
580
581 if par2_files.is_empty() {
582 return StageResult {
583 name: "Repair".to_string(),
584 status: StageStatus::Skipped,
585 message: Some("No par2 files found".to_string()),
586 duration_secs: start.elapsed().as_secs_f64(),
587 };
588 }
589
590 let index_par2 = &par2_files[0];
591 info!(file = %index_par2.display(), "Running native par2 repair");
592
593 match par2_repair(index_par2).await {
594 Ok(result) => {
595 let status = if result.repaired || result.success {
596 StageStatus::Success
597 } else {
598 StageStatus::Failed
599 };
600
601 StageResult {
602 name: "Repair".to_string(),
603 status,
604 message: Some(result.message),
605 duration_secs: start.elapsed().as_secs_f64(),
606 }
607 }
608 Err(e) => {
609 error!(error = %e, "par2 repair failed with error");
610 StageResult {
611 name: "Repair".to_string(),
612 status: StageStatus::Failed,
613 message: Some(format!("par2 repair error: {e}")),
614 duration_secs: start.elapsed().as_secs_f64(),
615 }
616 }
617 }
618}
619
620async fn run_extract_stage(
621 source_dir: &Path,
622 output_dir: &Path,
623 password: Option<&str>,
624 max_nested_archive_depth: u8,
625) -> (StageResult, Vec<PathBuf>) {
626 let start = Instant::now();
627 let mut all_ok = true;
628 let mut messages: Vec<String> = Vec::new();
629 let mut processed: HashSet<PathBuf> = if source_dir == output_dir {
634 HashSet::new()
635 } else {
636 find_archives(output_dir)
637 .into_iter()
638 .map(|(_, path)| path)
639 .collect()
640 };
641 let mut extracted_archives = Vec::new();
642 let mut scan_dir = source_dir;
643 let mut extracted_any = false;
644
645 for depth in 0..=max_nested_archive_depth {
646 let archives: Vec<_> = find_archives(scan_dir)
647 .into_iter()
648 .filter(|(_, path)| processed.insert(path.clone()))
649 .collect();
650 if archives.is_empty() {
651 break;
652 }
653
654 extracted_any = true;
655 extracted_archives.extend(archives.iter().map(|(_, path)| path.clone()));
656 for (archive_type, path) in &archives {
657 info!(depth, kind = %archive_type, file = %path.display(), "Extracting archive");
658 let result = match archive_type {
659 ArchiveType::Rar => extract_rar(path, output_dir, password).await,
660 ArchiveType::SevenZip => extract_7z(path, output_dir, password).await,
661 ArchiveType::Tar => extract_tar(path, output_dir).await,
662 ArchiveType::Zip => extract_zip(path, output_dir).await,
663 };
664
665 match result {
666 Ok(unpack_result) if unpack_result.success => {
667 messages.push(format!("depth {depth} {archive_type}: OK"));
668 }
669 Ok(unpack_result) => {
670 all_ok = false;
671 let detail = unpack_result
672 .error_output
673 .trim()
674 .lines()
675 .find(|line| !line.trim().is_empty());
676 messages.push(match detail {
677 Some(detail) => format!("depth {depth} {archive_type}: failed ({detail})"),
678 None => format!("depth {depth} {archive_type}: failed"),
679 });
680 }
681 Err(e) => {
682 all_ok = false;
683 error!(depth, kind = %archive_type, file = %path.display(), error = %e, "Extraction error");
684 messages.push(format!("depth {depth} {archive_type}: {e}"));
685 }
686 }
687 }
688
689 if !all_ok {
690 break;
691 }
692 scan_dir = output_dir;
693 }
694
695 if all_ok
696 && !find_archives(scan_dir)
697 .into_iter()
698 .all(|(_, path)| processed.contains(&path))
699 {
700 all_ok = false;
701 messages.push(format!(
702 "nested archive depth limit ({max_nested_archive_depth}) reached; source files retained"
703 ));
704 }
705
706 if !extracted_any {
707 info!("No archives found — skipping extraction");
708 return (
709 StageResult {
710 name: "Extract".to_string(),
711 status: StageStatus::Skipped,
712 message: Some("No archives found".to_string()),
713 duration_secs: start.elapsed().as_secs_f64(),
714 },
715 Vec::new(),
716 );
717 }
718
719 (
720 StageResult {
721 name: "Extract".to_string(),
722 status: if all_ok {
723 StageStatus::Success
724 } else {
725 StageStatus::Failed
726 },
727 message: Some(messages.join("; ")),
728 duration_secs: start.elapsed().as_secs_f64(),
729 },
730 extracted_archives,
731 )
732}
733
734#[cfg(test)]
735fn run_cleanup_stage(job_dir: &Path, extracted_archives: &[PathBuf]) -> StageResult {
736 run_cleanup_stage_with_rules(job_dir, job_dir, extracted_archives, &[], &[])
737}
738
739fn wildcard_match(pattern: &str, value: &str) -> bool {
740 let (mut pattern_index, mut value_index) = (0usize, 0usize);
741 let mut star = None;
742 let mut star_value = 0usize;
743 let pattern = pattern.as_bytes();
744 let value = value.as_bytes();
745
746 while value_index < value.len() {
747 if pattern_index < pattern.len()
748 && (pattern[pattern_index] == value[value_index] || pattern[pattern_index] == b'?')
749 {
750 pattern_index += 1;
751 value_index += 1;
752 } else if pattern_index < pattern.len() && pattern[pattern_index] == b'*' {
753 star = Some(pattern_index);
754 pattern_index += 1;
755 star_value = value_index;
756 } else if let Some(star_index) = star {
757 pattern_index = star_index + 1;
758 star_value += 1;
759 value_index = star_value;
760 } else {
761 return false;
762 }
763 }
764 while pattern_index < pattern.len() && pattern[pattern_index] == b'*' {
765 pattern_index += 1;
766 }
767 pattern_index == pattern.len()
768}
769
770fn run_cleanup_stage_with_rules(
771 job_dir: &Path,
772 cleanup_root: &Path,
773 extracted_archives: &[PathBuf],
774 cleanup_patterns: &[String],
775 unwanted_extensions: &[String],
776) -> StageResult {
777 let start = Instant::now();
778 let mut files = find_cleanup_files(job_dir);
779 files.extend(
780 extracted_archives
781 .iter()
782 .filter(|path| {
783 std::fs::symlink_metadata(path)
784 .map(|metadata| metadata.file_type().is_file())
785 .unwrap_or(false)
786 })
787 .cloned(),
788 );
789
790 let normalized_extensions: Vec<String> = unwanted_extensions
791 .iter()
792 .map(|extension| {
793 let extension = extension.trim().to_ascii_lowercase();
794 if extension.starts_with('.') {
795 extension
796 } else {
797 format!(".{extension}")
798 }
799 })
800 .filter(|extension| extension.len() > 1)
801 .collect();
802 let patterns: Vec<String> = cleanup_patterns
803 .iter()
804 .map(|pattern| pattern.replace('\\', "/").to_ascii_lowercase())
805 .filter(|pattern| !pattern.is_empty())
806 .collect();
807 for root in [job_dir, cleanup_root] {
808 for entry in walkdir::WalkDir::new(root).into_iter().flatten() {
809 let path = entry.path();
810 let Ok(metadata) = std::fs::symlink_metadata(path) else {
811 continue;
812 };
813 if !metadata.file_type().is_file() {
814 continue;
815 }
816 let Ok(relative) = path.strip_prefix(root) else {
817 continue;
818 };
819 let relative = relative.to_string_lossy().replace('\\', "/");
820 let filename = path
821 .file_name()
822 .map(|name| name.to_string_lossy().to_string())
823 .unwrap_or_default();
824 if normalized_extensions
825 .iter()
826 .any(|extension| filename.to_ascii_lowercase().ends_with(extension))
827 || patterns.iter().any(|pattern| {
828 wildcard_match(pattern, &relative.to_ascii_lowercase())
829 || wildcard_match(pattern, &filename.to_ascii_lowercase())
830 })
831 {
832 files.push(path.to_path_buf());
833 }
834 }
835 }
836 files.sort();
837 files.dedup();
838
839 if files.is_empty() {
840 return StageResult {
841 name: "Cleanup".to_string(),
842 status: StageStatus::Skipped,
843 message: Some("No files to clean up".to_string()),
844 duration_secs: start.elapsed().as_secs_f64(),
845 };
846 }
847
848 let mut removed = 0u32;
849 let mut errors = 0u32;
850
851 for path in &files {
852 let under_allowed_root = path.starts_with(job_dir) || path.starts_with(cleanup_root);
853 let is_regular_file = std::fs::symlink_metadata(path)
854 .map(|metadata| metadata.file_type().is_file())
855 .unwrap_or(false);
856 if !under_allowed_root || !is_regular_file {
857 warn!(file = %path.display(), "Skipping cleanup path outside job roots or through a link");
858 errors += 1;
859 continue;
860 }
861 match std::fs::remove_file(path) {
862 Ok(()) => {
863 removed += 1;
864 }
865 Err(e) => {
866 warn!(file = %path.display(), error = %e, "Failed to remove cleanup file");
867 errors += 1;
868 }
869 }
870 }
871
872 let status = if errors == 0 {
873 StageStatus::Success
874 } else {
875 StageStatus::Failed
876 };
877
878 StageResult {
879 name: "Cleanup".to_string(),
880 status,
881 message: Some(format!("Removed {removed} files, {errors} errors")),
882 duration_secs: start.elapsed().as_secs_f64(),
883 }
884}
885
886#[cfg(test)]
891mod tests {
892 use super::*;
893 use std::fs;
894 use std::io::Write;
895
896 fn make_test_dir(files: &[&str]) -> tempfile::TempDir {
897 let dir = tempfile::tempdir().unwrap();
898 for name in files {
899 let path = dir.path().join(name);
900 if let Some(parent) = path.parent() {
901 fs::create_dir_all(parent).unwrap();
902 }
903 fs::write(&path, b"").unwrap();
904 }
905 dir
906 }
907
908 #[test]
909 fn test_post_proc_result_default() {
910 let result = PostProcResult {
911 success: true,
912 stages: vec![],
913 error: None,
914 };
915 assert!(result.success);
916 assert!(result.stages.is_empty());
917 assert!(result.error.is_none());
918 }
919
920 #[test]
921 fn test_config_default() {
922 let config = PostProcConfig::default();
923 assert!(config.cleanup_after_extract);
924 assert!(config.output_dir.is_none());
925 assert_eq!(config.articles_failed, 0);
926 }
927
928 #[tokio::test]
929 async fn test_pipeline_no_files() {
930 let dir = make_test_dir(&[]);
932 let config = PostProcConfig::default();
933 let result = run_pipeline(dir.path(), &config).await;
934
935 assert!(result.success, "Pipeline should succeed for empty dir");
936
937 let verify_stage = result.stages.iter().find(|s| s.name == "Verify");
939 assert!(verify_stage.is_some(), "Verify stage should be present");
940 assert_eq!(verify_stage.unwrap().status, StageStatus::Skipped);
941
942 let extract_stage = result.stages.iter().find(|s| s.name == "Extract");
943 assert!(extract_stage.is_some(), "Extract stage should be present");
944 assert_eq!(extract_stage.unwrap().status, StageStatus::Skipped);
945 }
946
947 #[tokio::test]
948 async fn test_pipeline_only_text_files() {
949 let dir = make_test_dir(&["readme.txt", "info.nfo"]);
950 let config = PostProcConfig::default();
951 let result = run_pipeline(dir.path(), &config).await;
952
953 assert!(result.success);
954 for stage in &result.stages {
956 assert_eq!(
957 stage.status,
958 StageStatus::Skipped,
959 "Stage '{}' should be skipped",
960 stage.name
961 );
962 }
963 }
964
965 #[test]
966 fn test_cleanup_removes_files() {
967 let dir = make_test_dir(&[
968 "movie.par2",
969 "movie.vol00+01.par2",
970 "movie.rar",
971 "movie.r00",
972 "movie.mkv", ]);
974
975 let result = run_cleanup_stage(dir.path(), &[]);
976 assert_eq!(result.status, StageStatus::Success);
977
978 assert!(dir.path().join("movie.mkv").exists());
980 assert!(!dir.path().join("movie.par2").exists());
982 assert!(!dir.path().join("movie.vol00+01.par2").exists());
983 assert!(!dir.path().join("movie.rar").exists());
984 assert!(!dir.path().join("movie.r00").exists());
985 }
986
987 fn write_zip(path: &Path, entries: &[(&str, &[u8])]) {
988 let file = fs::File::create(path).unwrap();
989 let mut writer = zip::ZipWriter::new(file);
990 for (name, contents) in entries {
991 writer
992 .start_file(*name, zip::write::SimpleFileOptions::default())
993 .unwrap();
994 writer.write_all(contents).unwrap();
995 }
996 writer.finish().unwrap();
997 }
998
999 #[tokio::test]
1000 async fn nested_zip_archives_are_extracted_recursively() {
1001 let source = tempfile::tempdir().unwrap();
1002 let output = tempfile::tempdir().unwrap();
1003 let inner = source.path().join("inner.zip");
1004 write_zip(&inner, &[("payload.txt", b"nested payload")]);
1005 let outer = source.path().join("outer.zip");
1006 write_zip(&outer, &[("inner.zip", &fs::read(&inner).unwrap())]);
1007 fs::remove_file(inner).unwrap();
1008
1009 let (result, _) = run_extract_stage(source.path(), output.path(), None, 1).await;
1010
1011 assert_eq!(result.status, StageStatus::Success, "{result:?}");
1012 assert_eq!(
1013 fs::read(output.path().join("payload.txt")).unwrap(),
1014 b"nested payload"
1015 );
1016 }
1017
1018 #[tokio::test]
1019 async fn nested_archive_depth_limit_fails_without_discarding_inner_archive() {
1020 let source = tempfile::tempdir().unwrap();
1021 let output = tempfile::tempdir().unwrap();
1022 let inner = source.path().join("inner.zip");
1023 write_zip(&inner, &[("payload.txt", b"nested payload")]);
1024 let outer = source.path().join("outer.zip");
1025 write_zip(&outer, &[("inner.zip", &fs::read(&inner).unwrap())]);
1026 fs::remove_file(inner).unwrap();
1027
1028 let (result, _) = run_extract_stage(source.path(), output.path(), None, 0).await;
1029
1030 assert_eq!(result.status, StageStatus::Failed, "{result:?}");
1031 assert!(output.path().join("inner.zip").exists());
1032 assert!(!output.path().join("payload.txt").exists());
1033 }
1034
1035 #[tokio::test]
1036 async fn recursive_cleanup_removes_nested_archives_from_output_directory() {
1037 let source = tempfile::tempdir().unwrap();
1038 let output = tempfile::tempdir().unwrap();
1039 let inner = source.path().join("inner.zip");
1040 write_zip(&inner, &[("payload.txt", b"nested payload")]);
1041 let outer = source.path().join("outer.zip");
1042 write_zip(&outer, &[("inner.zip", &fs::read(&inner).unwrap())]);
1043 fs::remove_file(inner).unwrap();
1044
1045 let config = PostProcConfig {
1046 output_dir: Some(output.path().to_path_buf()),
1047 ..Default::default()
1048 };
1049 let result = run_pipeline(source.path(), &config).await;
1050
1051 assert!(result.success, "{result:?}");
1052 assert_eq!(
1053 fs::read(output.path().join("payload.txt")).unwrap(),
1054 b"nested payload"
1055 );
1056 assert!(!output.path().join("inner.zip").exists());
1057 assert!(!source.path().join("outer.zip").exists());
1058 }
1059
1060 #[tokio::test]
1061 async fn cleanup_keeps_unrelated_archives_in_the_output_directory() {
1062 let source = tempfile::tempdir().unwrap();
1063 let output = tempfile::tempdir().unwrap();
1064 let inner = source.path().join("inner.zip");
1065 write_zip(&inner, &[("payload.txt", b"nested payload")]);
1066 let outer = source.path().join("outer.zip");
1067 write_zip(&outer, &[("inner.zip", &fs::read(&inner).unwrap())]);
1068 fs::remove_file(inner).unwrap();
1069 let unrelated = output.path().join("keep-me.zip");
1070 write_zip(&unrelated, &[("unrelated.txt", b"keep")]);
1071
1072 let config = PostProcConfig {
1073 output_dir: Some(output.path().to_path_buf()),
1074 ..Default::default()
1075 };
1076 let result = run_pipeline(source.path(), &config).await;
1077
1078 assert!(result.success, "{result:?}");
1079 assert!(unrelated.exists());
1080 assert!(!output.path().join("inner.zip").exists());
1081 }
1082
1083 #[tokio::test]
1084 async fn direct_unpack_still_extracts_nested_archives() {
1085 let job_dir = tempfile::tempdir().unwrap();
1086 let output = tempfile::tempdir().unwrap();
1087 let nested = output.path().join("nested.zip");
1088 write_zip(&nested, &[("payload.txt", b"nested payload")]);
1089
1090 let config = PostProcConfig {
1091 output_dir: Some(output.path().to_path_buf()),
1092 skip_extract: true,
1093 ..Default::default()
1094 };
1095 let result = run_pipeline(job_dir.path(), &config).await;
1096
1097 assert!(result.success, "{result:?}");
1098 assert_eq!(
1099 fs::read(output.path().join("payload.txt")).unwrap(),
1100 b"nested payload"
1101 );
1102 assert!(!nested.exists());
1103 }
1104
1105 #[tokio::test]
1106 async fn test_pipeline_stage_order() {
1107 let dir = make_test_dir(&[]);
1110 let config = PostProcConfig {
1111 cleanup_after_extract: false,
1112 ..Default::default()
1113 };
1114 let result = run_pipeline(dir.path(), &config).await;
1115
1116 let stage_names: Vec<&str> = result.stages.iter().map(|s| s.name.as_str()).collect();
1118 assert!(stage_names.contains(&"Verify"), "Should have Verify stage");
1119 assert!(
1120 stage_names.contains(&"Extract"),
1121 "Should have Extract stage"
1122 );
1123
1124 let verify_idx = stage_names.iter().position(|&n| n == "Verify").unwrap();
1126 let extract_idx = stage_names.iter().position(|&n| n == "Extract").unwrap();
1127 assert!(
1128 verify_idx < extract_idx,
1129 "Verify ({verify_idx}) should come before Extract ({extract_idx})"
1130 );
1131 }
1132
1133 #[tokio::test]
1134 async fn test_pipeline_skips_verify_with_zero_failures() {
1135 let dir = make_test_dir(&["movie.par2", "movie.vol00+01.par2", "movie.mkv"]);
1138 let config = PostProcConfig {
1139 cleanup_after_extract: false,
1140 ..Default::default()
1141 };
1142 let result = run_pipeline(dir.path(), &config).await;
1143 assert!(result.success);
1144
1145 let verify_stage = result.stages.iter().find(|s| s.name == "Verify").unwrap();
1146 assert_eq!(
1147 verify_stage.status,
1148 StageStatus::Skipped,
1149 "Verify should be skipped when articles_failed == 0"
1150 );
1151 assert!(
1152 verify_stage
1153 .message
1154 .as_deref()
1155 .unwrap_or("")
1156 .contains("zero article failures"),
1157 "Skip message should indicate zero failures"
1158 );
1159 }
1160
1161 #[tokio::test]
1162 async fn test_pipeline_no_par2_with_content_failures_is_terminal() {
1163 let dir = make_test_dir(&["movie.mkv"]);
1166 let config = PostProcConfig {
1167 cleanup_after_extract: false,
1168 articles_failed: 5,
1169 content_articles_failed: 5,
1170 ..Default::default()
1171 };
1172 let result = run_pipeline(dir.path(), &config).await;
1173 assert!(!result.success);
1174
1175 let verify_stage = result.stages.iter().find(|s| s.name == "Verify").unwrap();
1176 assert_eq!(
1177 verify_stage.status,
1178 StageStatus::Failed,
1179 "No-PAR content damage is unrecoverable"
1180 );
1181 assert!(
1182 verify_stage
1183 .message
1184 .as_deref()
1185 .unwrap_or("")
1186 .contains("no PAR2 recovery set"),
1187 "Failure should explain that recovery data is unavailable"
1188 );
1189 assert!(result.stages.iter().all(|stage| stage.name != "Extract"));
1190 }
1191
1192 #[tokio::test]
1193 async fn test_pipeline_runs_verify_then_repair_when_failures() {
1194 let dir = make_test_dir(&["movie.par2", "movie.vol00+01.par2", "movie.mkv"]);
1198 let config = PostProcConfig {
1199 cleanup_after_extract: false,
1200 articles_failed: 3,
1201 ..Default::default()
1202 };
1203 let result = run_pipeline(dir.path(), &config).await;
1204
1205 let stage_names: Vec<&str> = result.stages.iter().map(|s| s.name.as_str()).collect();
1207 assert!(
1208 stage_names.contains(&"Verify"),
1209 "Should have Verify stage (native par2), got: {stage_names:?}"
1210 );
1211 assert!(
1214 stage_names.contains(&"Repair"),
1215 "Should have Repair stage when articles_failed > 0, got: {stage_names:?}"
1216 );
1217 }
1218
1219 fn make_par2_file_set(tmp: &Path, files: &[(&str, &[u8])]) -> rust_par2::Par2FileSet {
1226 use rust_par2::{Par2File, Par2FileSet};
1227 let mut map = std::collections::HashMap::new();
1228 let mut file_order = Vec::with_capacity(files.len());
1229 for (i, (name, content)) in files.iter().enumerate() {
1230 let tmp_path = tmp.join(format!("_par2_tmp_{i}"));
1232 fs::write(&tmp_path, content).unwrap();
1233 let hash_16k = rust_par2::compute_hash_16k(&tmp_path).unwrap();
1234 let _ = fs::remove_file(&tmp_path);
1235
1236 let file_id = [i as u8; 16];
1237 file_order.push(file_id);
1238 map.insert(
1239 file_id,
1240 Par2File {
1241 file_id,
1242 hash: [0u8; 16],
1243 hash_16k,
1244 size: content.len() as u64,
1245 filename: name.to_string(),
1246 slices: vec![],
1247 },
1248 );
1249 }
1250 Par2FileSet {
1251 recovery_set_id: [0u8; 16],
1252 slice_size: 16384,
1253 file_order,
1254 files: map,
1255 recovery_block_count: 0,
1256 creator: None,
1257 }
1258 }
1259
1260 #[test]
1261 fn test_rename_to_par2_names_renames_mismatched() {
1262 let dir = tempfile::tempdir().unwrap();
1263
1264 let content_a = b"AAAA test data for part01";
1266 let content_b = b"BBBB test data for part02";
1267 fs::write(dir.path().join("Movie.Name.part01.rar"), content_a).unwrap();
1268 fs::write(dir.path().join("Movie.Name.part02.rar"), content_b).unwrap();
1269
1270 let file_set = make_par2_file_set(
1272 dir.path(),
1273 &[
1274 ("xY7kQ3.part01.rar", content_a),
1275 ("xY7kQ3.part02.rar", content_b),
1276 ],
1277 );
1278
1279 rename_to_par2_names(&file_set, dir.path());
1280
1281 assert!(
1283 dir.path().join("xY7kQ3.part01.rar").exists(),
1284 "part01 should be renamed to obfuscated name"
1285 );
1286 assert!(
1287 dir.path().join("xY7kQ3.part02.rar").exists(),
1288 "part02 should be renamed to obfuscated name"
1289 );
1290 assert!(
1291 !dir.path().join("Movie.Name.part01.rar").exists(),
1292 "old readable name should no longer exist"
1293 );
1294 assert!(
1295 !dir.path().join("Movie.Name.part02.rar").exists(),
1296 "old readable name should no longer exist"
1297 );
1298 }
1299
1300 #[test]
1301 fn test_rename_to_par2_names_skips_when_already_correct() {
1302 let dir = tempfile::tempdir().unwrap();
1303
1304 let content = b"test data already correct";
1306 fs::write(dir.path().join("xY7kQ3.part01.rar"), content).unwrap();
1307
1308 let file_set = make_par2_file_set(dir.path(), &[("xY7kQ3.part01.rar", content)]);
1309
1310 rename_to_par2_names(&file_set, dir.path());
1311
1312 assert!(dir.path().join("xY7kQ3.part01.rar").exists());
1314 }
1315
1316 #[test]
1317 fn test_rename_to_par2_names_skips_par2_files() {
1318 let dir = tempfile::tempdir().unwrap();
1319
1320 let content = b"par2 file content";
1322 fs::write(dir.path().join("Movie.Name.par2"), content).unwrap();
1323 fs::write(dir.path().join("Movie.Name.part01.rar"), b"rar data").unwrap();
1324
1325 let file_set = make_par2_file_set(dir.path(), &[("obfuscated.par2", content)]);
1326
1327 rename_to_par2_names(&file_set, dir.path());
1328
1329 assert!(
1331 dir.path().join("Movie.Name.par2").exists(),
1332 "PAR2 files should be skipped"
1333 );
1334 }
1335
1336 #[test]
1337 fn test_rename_to_par2_names_no_match() {
1338 let dir = tempfile::tempdir().unwrap();
1339
1340 fs::write(dir.path().join("Movie.Name.part01.rar"), b"unrelated data").unwrap();
1342
1343 let file_set = make_par2_file_set(
1344 dir.path(),
1345 &[("xY7kQ3.part01.rar", b"different data" as &[u8])],
1346 );
1347
1348 rename_to_par2_names(&file_set, dir.path());
1349
1350 assert!(dir.path().join("Movie.Name.part01.rar").exists());
1352 assert!(!dir.path().join("xY7kQ3.part01.rar").exists());
1353 }
1354}