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::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
28/// Outcome of the combined verify+repair spawn_blocking task.
29/// Keeps VerifyResult (which is !Send) on the blocking thread, then returns
30/// only Send-safe data back to the async context.
31enum 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/// Final result of the complete post-processing pipeline.
46#[derive(Debug)]
47pub struct PostProcResult {
48    /// Whether all stages completed successfully.
49    pub success: bool,
50    /// Results from each stage that was attempted.
51    pub stages: Vec<StageResult>,
52    /// Error message if the pipeline failed.
53    pub error: Option<String>,
54}
55
56/// Configuration for the post-processing pipeline.
57#[derive(Debug, Clone)]
58pub struct PostProcConfig {
59    /// Remove par2 and archive files after successful extraction.
60    pub cleanup_after_extract: bool,
61    /// Directory where extracted files should be placed.
62    /// If None, extracts into the job directory itself.
63    pub output_dir: Option<PathBuf>,
64    /// Number of articles that failed during download.
65    /// When 0, par2 verification is skipped (files are known-good).
66    /// When > 0, `par2 repair` is run directly (which verifies + repairs
67    /// in a single pass), avoiding the redundant verify-then-repair double-scan.
68    pub articles_failed: usize,
69    /// Failed articles belonging to source/content files. PAR2 volume
70    /// failures do not make an otherwise intact archive unextractable.
71    pub content_articles_failed: usize,
72    /// When true, the extract stage is skipped because direct unpack already
73    /// handled RAR extraction during the download phase.
74    pub skip_extract: bool,
75    /// Optional archive password (from NZB metadata or indexer API).
76    pub password: Option<String>,
77    /// Maximum archive nesting depth processed automatically. Zero permits
78    /// the outer archive only; the default handles five nested layers.
79    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
96/// Run the full post-processing pipeline on a completed job directory.
97///
98/// Stages executed in order:
99/// 1. **Verify** — par2 verification
100/// 2. **Repair** — par2 repair (only if verify found issues)
101/// 3. **Extract** — unpack RAR, 7z, ZIP archives
102/// 4. **Cleanup** — remove archive/par2 files (if configured)
103pub 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    // ------------------------------------------------------------------
110    // Stage 1: Native PAR2 verification
111    // ------------------------------------------------------------------
112    // Parse the PAR2 index file and verify all files via MD5 hashing.
113    // This is pure Rust — no process spawn, no stdout parsing.
114    //
115    // If all files pass → done (no par2cmdline needed).
116    // If files are damaged → attempt native repair.
117    //
118    // When articles_failed == 0 the files are known-good from CRC checks
119    // during yEnc decode, so we skip the expensive MD5 verification pass.
120    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                // PAR2-guided deobfuscation: if files on disk don't match
162                // PAR2 expected names (common with obfuscated posts where
163                // NZB subjects have readable names but PAR2 references
164                // the original obfuscated filenames), rename them using
165                // MD5-16k hash matching before verification runs.
166                rename_to_par2_names(&file_set, job_dir);
167
168                // Run verify (and repair if needed) in a single spawn_blocking call.
169                // This avoids two problems:
170                //   1. CPU-intensive verify/repair doesn't block the async runtime
171                //   2. VerifyResult (not Send) stays on one thread, so repair_from_verify
172                //      can reuse it — no redundant second verification pass
173                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                        // Repair using the pre-computed verify result — no second verify pass
197                        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                        // Push the verify stage result
241                        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                        // Push the repair stage result
251                        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                // Native parse failed — try full repair path as fallback.
311                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                    // No article failures, can't parse par2 — skip.
316                    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                    // Articles failed and can't parse par2 — try repair with fresh parse.
326                    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    // ------------------------------------------------------------------
349    // Stage 3: Extract
350    // ------------------------------------------------------------------
351    // Extraction is safe only after verification/repair succeeded (or source
352    // content was already known-good). Confirmed unrepaired content damage
353    // must not be mistaken for a successful archive.
354    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        // Direct unpack has already extracted the outer archive, but its output
359        // can itself contain archives. Scan that directory so a nested archive
360        // cannot be silently left behind just because direct unpack was used.
361        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            // Extraction succeeded despite verify/repair failure — recover
379            pipeline_ok = true;
380        }
381        stages.push(result);
382    }
383
384    // ------------------------------------------------------------------
385    // Stage 4: Cleanup
386    // ------------------------------------------------------------------
387    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        // Collect failure messages from stages
396        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
417// ---------------------------------------------------------------------------
418// PAR2-guided deobfuscation
419// ---------------------------------------------------------------------------
420
421/// Rename files on disk to match PAR2 expected filenames.
422///
423/// Obfuscated Usenet posts often have readable names in NZB subjects but the
424/// actual PAR2 metadata references the original obfuscated filenames. This
425/// causes PAR2 verify to report all files as "missing" even though they exist.
426///
427/// This function matches files by MD5 hash of their first 16 KiB (which PAR2
428/// stores for each file) and renames them to what PAR2 expects. This is the
429/// same approach used by SABnzbd's `decode_par2()`.
430fn rename_to_par2_names(file_set: &rust_par2::Par2FileSet, dir: &Path) {
431    // Build a map of expected hash_16k → par2 filename
432    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    // Check if any PAR2-expected files are already present — if so, no renaming needed
438    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    // Scan all files on disk and try to match by hash_16k
447    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        // Skip PAR2 files themselves — they don't need renaming
464        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
504// ---------------------------------------------------------------------------
505// Internal stage runners
506// ---------------------------------------------------------------------------
507
508/// Repair stage when we don't have a pre-computed verify result.
509/// Uses par2_repair which does its own parse + verify + repair.
510async 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    // The completed directory can pre-exist (for example a category
563    // directory). Do not recurse into archives that were already there before
564    // this job extracted anything. Direct unpack writes to a job-specific
565    // output directory, so its initial archives are the intended input.
566    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// ---------------------------------------------------------------------------
717// Tests
718// ---------------------------------------------------------------------------
719
720#[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        // An empty directory should skip all stages
761        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        // Verify should be skipped (no par2), Extract should be skipped (no archives)
768        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        // All stages should be skipped
785        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", // should NOT be removed
803        ]);
804
805        let result = run_cleanup_stage(dir.path(), &[]);
806        assert_eq!(result.status, StageStatus::Success);
807
808        // movie.mkv should still exist
809        assert!(dir.path().join("movie.mkv").exists());
810        // par2 and rar files should be gone
811        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        // With an empty dir, we can at least verify the stages that run
938        // are in the correct order.
939        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        // Should have Verify and Extract (both skipped). Cleanup is disabled.
947        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        // Verify should come before Extract
955        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        // With par2 files present and articles_failed == 0, verify is skipped
966        // because files are known-good from CRC checks during yEnc decode.
967        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        // Confirmed source damage without PAR2 cannot be repaired and must
994        // not continue into extraction.
995        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        // With par2 files and articles_failed > 0, native verify should run first.
1025        // Since these are dummy empty par2 files, native parse will fail and
1026        // the pipeline should fall back to par2cmdline for repair.
1027        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        // Should always have a Verify stage now (native par2 verify runs first)
1036        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        // Repair stage should also be present since dummy par2 files
1042        // will either fail to parse or report damage
1043        assert!(
1044            stage_names.contains(&"Repair"),
1045            "Should have Repair stage when articles_failed > 0, got: {stage_names:?}"
1046        );
1047    }
1048
1049    // -----------------------------------------------------------------------
1050    // PAR2-guided deobfuscation tests
1051    // -----------------------------------------------------------------------
1052
1053    /// Helper to build a Par2FileSet with given filename→content mappings.
1054    /// Computes hash_16k by writing content to temp files and using rust_par2.
1055    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            // Write to temp file so we can use compute_hash_16k
1061            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        // Write files with "readable" names
1095        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        // PAR2 expects obfuscated names with the same content
1101        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        // Files should be renamed to PAR2 expected names
1112        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        // Files already have the PAR2-expected names
1135        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        // File should still exist with same name (no rename needed)
1143        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        // PAR2 files should not be renamed even if hash matches
1151        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        // PAR2 file should NOT be renamed
1160        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        // File content doesn't match any PAR2 entry
1171        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        // File should remain with original name (no hash match)
1181        assert!(dir.path().join("Movie.Name.part01.rar").exists());
1182        assert!(!dir.path().join("xY7kQ3.part01.rar").exists());
1183    }
1184}