Skip to main content

nzb_postproc/
pipeline.rs

1//! Post-processing pipeline orchestrator.
2//!
3//! Stages:
4//! - **Verify** — native PAR2 verification (skipped when articles_failed == 0)
5//! - **Repair** — native PAR2 repair when files are damaged
6//! - **Extract** — unpack RAR, 7z, ZIP archives
7//! - **Cleanup** — remove archive/par2 files
8
9use 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
19/// Outcome of the combined verify+repair spawn_blocking task.
20/// Keeps VerifyResult (which is !Send) on the blocking thread, then returns
21/// only Send-safe data back to the async context.
22enum 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/// Final result of the complete post-processing pipeline.
37#[derive(Debug)]
38pub struct PostProcResult {
39    /// Whether all stages completed successfully.
40    pub success: bool,
41    /// Results from each stage that was attempted.
42    pub stages: Vec<StageResult>,
43    /// Error message if the pipeline failed.
44    pub error: Option<String>,
45}
46
47/// Configuration for the post-processing pipeline.
48#[derive(Debug, Clone)]
49pub struct PostProcConfig {
50    /// Remove par2 and archive files after successful extraction.
51    pub cleanup_after_extract: bool,
52    /// Directory where extracted files should be placed.
53    /// If None, extracts into the job directory itself.
54    pub output_dir: Option<PathBuf>,
55    /// Number of articles that failed during download.
56    /// When 0, par2 verification is skipped (files are known-good).
57    /// When > 0, `par2 repair` is run directly (which verifies + repairs
58    /// in a single pass), avoiding the redundant verify-then-repair double-scan.
59    pub articles_failed: usize,
60    /// When true, the extract stage is skipped because direct unpack already
61    /// handled RAR extraction during the download phase.
62    pub skip_extract: bool,
63    /// Optional archive password (from NZB metadata or indexer API).
64    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
79/// Run the full post-processing pipeline on a completed job directory.
80///
81/// Stages executed in order:
82/// 1. **Verify** — par2 verification
83/// 2. **Repair** — par2 repair (only if verify found issues)
84/// 3. **Extract** — unpack RAR, 7z, ZIP archives
85/// 4. **Cleanup** — remove archive/par2 files (if configured)
86pub 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    // ------------------------------------------------------------------
93    // Stage 1: Native PAR2 verification
94    // ------------------------------------------------------------------
95    // Parse the PAR2 index file and verify all files via MD5 hashing.
96    // This is pure Rust — no process spawn, no stdout parsing.
97    //
98    // If all files pass → done (no par2cmdline needed).
99    // If files are damaged → attempt native repair.
100    //
101    // When articles_failed == 0 the files are known-good from CRC checks
102    // during yEnc decode, so we skip the expensive MD5 verification pass.
103    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                // PAR2-guided deobfuscation: if files on disk don't match
127                // PAR2 expected names (common with obfuscated posts where
128                // NZB subjects have readable names but PAR2 references
129                // the original obfuscated filenames), rename them using
130                // MD5-16k hash matching before verification runs.
131                rename_to_par2_names(&file_set, job_dir);
132
133                // Run verify (and repair if needed) in a single spawn_blocking call.
134                // This avoids two problems:
135                //   1. CPU-intensive verify/repair doesn't block the async runtime
136                //   2. VerifyResult (not Send) stays on one thread, so repair_from_verify
137                //      can reuse it — no redundant second verification pass
138                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                        // Repair using the pre-computed verify result — no second verify pass
162                        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                        // Push the verify stage result
205                        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                        // Push the repair stage result
215                        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                // Native parse failed — try full repair path as fallback.
269                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                    // No article failures, can't parse par2 — skip.
274                    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                    // Articles failed and can't parse par2 — try repair with fresh parse.
284                    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    // ------------------------------------------------------------------
302    // Stage 3: Extract
303    // ------------------------------------------------------------------
304    // Attempt extraction even if verify/repair failed when only a few articles
305    // were missing — the failed articles may have been PAR2 files rather than
306    // data files, so the RAR archive could still be intact.
307    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            // Extraction succeeded despite verify/repair failure — recover
326            pipeline_ok = true;
327        }
328        stages.push(result);
329    }
330
331    // ------------------------------------------------------------------
332    // Stage 4: Cleanup
333    // ------------------------------------------------------------------
334    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        // Collect failure messages from stages
343        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
364// ---------------------------------------------------------------------------
365// PAR2-guided deobfuscation
366// ---------------------------------------------------------------------------
367
368/// Rename files on disk to match PAR2 expected filenames.
369///
370/// Obfuscated Usenet posts often have readable names in NZB subjects but the
371/// actual PAR2 metadata references the original obfuscated filenames. This
372/// causes PAR2 verify to report all files as "missing" even though they exist.
373///
374/// This function matches files by MD5 hash of their first 16 KiB (which PAR2
375/// stores for each file) and renames them to what PAR2 expects. This is the
376/// same approach used by SABnzbd's `decode_par2()`.
377fn rename_to_par2_names(file_set: &rust_par2::Par2FileSet, dir: &Path) {
378    // Build a map of expected hash_16k → par2 filename
379    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    // Check if any PAR2-expected files are already present — if so, no renaming needed
385    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    // Scan all files on disk and try to match by hash_16k
394    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        // Skip PAR2 files themselves — they don't need renaming
411        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
468// ---------------------------------------------------------------------------
469// Internal stage runners
470// ---------------------------------------------------------------------------
471
472/// Repair stage when we don't have a pre-computed verify result.
473/// Uses par2_repair which does its own parse + verify + repair.
474async 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// ---------------------------------------------------------------------------
620// Tests
621// ---------------------------------------------------------------------------
622
623#[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        // An empty directory should skip all stages
680        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        // Verify should be skipped (no par2), Extract should be skipped (no archives)
687        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        // All stages should be skipped
704        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", // should NOT be removed
722        ]);
723
724        let result = run_cleanup_stage(dir.path());
725        assert_eq!(result.status, StageStatus::Success);
726
727        // movie.mkv should still exist
728        assert!(dir.path().join("movie.mkv").exists());
729        // par2 and rar files should be gone
730        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        // With an empty dir, we can at least verify the stages that run
739        // are in the correct order.
740        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        // Should have Verify and Extract (both skipped). Cleanup is disabled.
748        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        // Verify should come before Extract
756        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        // With par2 files present and articles_failed == 0, verify is skipped
767        // because files are known-good from CRC checks during yEnc decode.
768        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        // No par2 files — should skip even if articles_failed > 0
795        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        // With par2 files and articles_failed > 0, native verify should run first.
823        // Since these are dummy empty par2 files, native parse will fail and
824        // the pipeline should fall back to par2cmdline for repair.
825        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        // Should always have a Verify stage now (native par2 verify runs first)
834        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        // Repair stage should also be present since dummy par2 files
840        // will either fail to parse or report damage
841        assert!(
842            stage_names.contains(&"Repair"),
843            "Should have Repair stage when articles_failed > 0, got: {stage_names:?}"
844        );
845    }
846
847    // -----------------------------------------------------------------------
848    // PAR2-guided deobfuscation tests
849    // -----------------------------------------------------------------------
850
851    /// Helper to build a Par2FileSet with given filename→content mappings.
852    /// Computes hash_16k by writing content to temp files and using rust_par2.
853    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            // Write to temp file so we can use compute_hash_16k
858            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        // Write files with "readable" names
891        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        // PAR2 expects obfuscated names with the same content
897        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        // Files should be renamed to PAR2 expected names
908        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        // Files already have the PAR2-expected names
931        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        // File should still exist with same name (no rename needed)
939        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        // PAR2 files should not be renamed even if hash matches
947        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        // PAR2 file should NOT be renamed
956        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        // File content doesn't match any PAR2 entry
967        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        // File should remain with original name (no hash match)
977        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}