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::sync::Arc;
12use std::time::Instant;
13
14use nzb_core::models::{JobFailureCode, StageResult, StageStatus};
15use tracing::{debug, error, info, warn};
16
17use crate::detect::{
18    ArchiveType, find_archives, find_cleanup_files, find_par2_files, is_par2_volume,
19};
20use crate::par2::par2_repair;
21use crate::resources::PostProcResourcePool;
22use crate::unpack::{ArchivePasswordRequired, extract_7z, extract_rar, extract_tar, extract_zip};
23
24fn increment_counter(name: &'static str) {
25    opentelemetry::global::meter_provider()
26        .meter("rustnzb")
27        .u64_counter(name)
28        .build()
29        .add(1, &[]);
30}
31
32/// Outcome of the combined verify+repair spawn_blocking task.
33/// Keeps VerifyResult (which is !Send) on the blocking thread, then returns
34/// only Send-safe data back to the async context.
35enum VerifyRepairOutcome {
36    AllCorrect {
37        intact_count: usize,
38    },
39    Damaged {
40        intact: usize,
41        damaged: usize,
42        missing: usize,
43        blocks_needed: u32,
44        blocks_available: u32,
45        repair_result: Result<rust_par2::RepairResult, rust_par2::RepairError>,
46    },
47}
48
49/// Parse every distinct PAR2 recovery set present in `paths`.
50///
51/// Volume files (`*.volNNN+NNN.par2`) are skipped — they carry recovery blocks,
52/// not an index — and duplicate index files that describe the same recovery set
53/// are collapsed by `recovery_set_id`. Parse failures are collected so the
54/// caller can report them without aborting the other sets. A job that carries
55/// two independently posted releases yields two sets here; earlier code only
56/// ever inspected `par2_files[0]`, silently ignoring the rest.
57fn parse_par2_sets(paths: &[PathBuf]) -> (Vec<rust_par2::Par2FileSet>, Vec<String>) {
58    let mut recovery_set_ids = HashSet::new();
59    let mut sets = Vec::new();
60    let mut errors = Vec::new();
61    for path in paths.iter().filter(|path| {
62        path.file_name()
63            .and_then(|name| name.to_str())
64            .is_none_or(|name| !is_par2_volume(&name.to_ascii_lowercase()))
65    }) {
66        match rust_par2::parse(path) {
67            Ok(set) if recovery_set_ids.insert(set.recovery_set_id) => sets.push(set),
68            Ok(_) => {}
69            Err(error) => errors.push(format!("{}: {error}", path.display())),
70        }
71    }
72    (sets, errors)
73}
74
75/// Verify one recovery set and, if it is damaged, attempt a native repair using
76/// the pre-computed verify result (no redundant second verification pass).
77fn verify_repair_set(file_set: &rust_par2::Par2FileSet, dir: &Path) -> VerifyRepairOutcome {
78    let verify_result = rust_par2::verify(file_set, dir);
79    if verify_result.all_correct() {
80        return VerifyRepairOutcome::AllCorrect {
81            intact_count: verify_result.intact.len(),
82        };
83    }
84
85    let intact = verify_result.intact.len();
86    let damaged = verify_result.damaged.len();
87    let missing = verify_result.missing.len();
88    let blocks_needed = verify_result.blocks_needed();
89    let blocks_available = verify_result.recovery_blocks_available;
90    info!(
91        intact,
92        damaged,
93        missing,
94        blocks_needed,
95        "Native PAR2 verify: damage detected, attempting native repair"
96    );
97    let repair_result = rust_par2::repair_from_verify(file_set, dir, &verify_result);
98    VerifyRepairOutcome::Damaged {
99        intact,
100        damaged,
101        missing,
102        blocks_needed,
103        blocks_available,
104        repair_result,
105    }
106}
107
108/// Final result of the complete post-processing pipeline.
109#[derive(Debug)]
110pub struct PostProcResult {
111    /// Whether all stages completed successfully.
112    pub success: bool,
113    /// Results from each stage that was attempted.
114    pub stages: Vec<StageResult>,
115    /// Error message if the pipeline failed.
116    pub error: Option<String>,
117    /// Typed terminal failure code when the pipeline failed.
118    pub failure_code: Option<JobFailureCode>,
119}
120
121/// Configuration for the post-processing pipeline.
122#[derive(Debug, Clone)]
123pub struct PostProcConfig {
124    /// Remove par2 and archive files after successful extraction.
125    pub cleanup_after_extract: bool,
126    /// Whether cleanup removes the archive volumes it extracted. When false
127    /// (SABnzbd `pp=2`, "+Unpack" without "+Delete") the archives are kept
128    /// and cleanup only removes PAR2 files and the category's cleanup
129    /// patterns / unwanted extensions.
130    pub delete_archives: bool,
131    /// Directory where extracted files should be placed.
132    /// If None, extracts into the job directory itself.
133    pub output_dir: Option<PathBuf>,
134    /// Number of articles that failed during download.
135    /// When 0, par2 verification is skipped (files are known-good).
136    /// When > 0, `par2 repair` is run directly (which verifies + repairs
137    /// in a single pass), avoiding the redundant verify-then-repair double-scan.
138    pub articles_failed: usize,
139    /// Failed articles belonging to source/content files. PAR2 volume
140    /// failures do not make an otherwise intact archive unextractable.
141    pub content_articles_failed: usize,
142    /// When true, the extract stage is skipped because direct unpack already
143    /// handled RAR extraction during the download phase.
144    pub skip_extract: bool,
145    /// Optional archive password (from NZB metadata or indexer API).
146    pub password: Option<String>,
147    /// Maximum archive nesting depth processed automatically. Zero permits
148    /// the outer archive only; the default handles five nested layers.
149    pub max_nested_archive_depth: u8,
150}
151
152impl Default for PostProcConfig {
153    fn default() -> Self {
154        Self {
155            cleanup_after_extract: true,
156            delete_archives: true,
157            output_dir: None,
158            articles_failed: 0,
159            content_articles_failed: 0,
160            skip_extract: false,
161            password: None,
162            max_nested_archive_depth: 5,
163        }
164    }
165}
166
167/// Run the full post-processing pipeline on a completed job directory.
168///
169/// Stages executed in order:
170/// 1. **Verify** — par2 verification
171/// 2. **Repair** — par2 repair (only if verify found issues)
172/// 3. **Extract** — unpack RAR, 7z, ZIP archives
173/// 4. **Cleanup** — remove archive/par2 files (if configured)
174pub async fn run_pipeline(job_dir: &Path, config: &PostProcConfig) -> PostProcResult {
175    run_pipeline_with_resources(job_dir, config, None).await
176}
177
178/// Run the pipeline under optional shared stage-specific resource gates.
179///
180/// This additive entry point keeps [`PostProcConfig`] source-compatible for
181/// library consumers while allowing applications to coordinate independent
182/// jobs through one [`PostProcResourcePool`].
183pub async fn run_pipeline_with_resources(
184    job_dir: &Path,
185    config: &PostProcConfig,
186    resources: Option<&Arc<PostProcResourcePool>>,
187) -> PostProcResult {
188    run_pipeline_with_cleanup(job_dir, config, resources, &[], &[]).await
189}
190
191/// Run the pipeline with optional category cleanup rules.
192pub async fn run_pipeline_with_cleanup(
193    job_dir: &Path,
194    config: &PostProcConfig,
195    resources: Option<&Arc<PostProcResourcePool>>,
196    cleanup_patterns: &[String],
197    unwanted_extensions: &[String],
198) -> PostProcResult {
199    run_stages(
200        job_dir,
201        config,
202        resources,
203        cleanup_patterns,
204        unwanted_extensions,
205        true,
206    )
207    .await
208}
209
210/// Run only PAR2 verification and repair, without extraction or cleanup.
211///
212/// This is SABnzbd's "repair only" post-processing level (`pp=1`): the
213/// archives themselves are the job's output, so they are neither unpacked
214/// nor removed.
215pub async fn run_repair_pipeline(
216    job_dir: &Path,
217    config: &PostProcConfig,
218    resources: Option<&Arc<PostProcResourcePool>>,
219) -> PostProcResult {
220    run_stages(job_dir, config, resources, &[], &[], false).await
221}
222
223async fn run_stages(
224    job_dir: &Path,
225    config: &PostProcConfig,
226    resources: Option<&Arc<PostProcResourcePool>>,
227    cleanup_patterns: &[String],
228    unwanted_extensions: &[String],
229    unpack: bool,
230) -> PostProcResult {
231    let mut stages: Vec<StageResult> = Vec::new();
232    let mut pipeline_ok = true;
233    let mut failure_code = None;
234
235    info!(dir = %job_dir.display(), "Starting post-processing pipeline");
236
237    // ------------------------------------------------------------------
238    // Stage 1: Native PAR2 verification
239    // ------------------------------------------------------------------
240    // Parse the PAR2 index file and verify all files via MD5 hashing.
241    // This is pure Rust — no process spawn, no stdout parsing.
242    //
243    // If all files pass → done (no par2cmdline needed).
244    // If files are damaged → attempt native repair.
245    //
246    // When articles_failed == 0 the files are known-good from CRC checks
247    // during yEnc decode, so we skip the expensive MD5 verification pass.
248    let par2_files = find_par2_files(job_dir);
249
250    info!(
251        par2_files = par2_files.len(),
252        "PAR2 files discovered for post-processing"
253    );
254
255    if par2_files.is_empty() {
256        if config.content_articles_failed > 0 {
257            pipeline_ok = false;
258            failure_code = Some(JobFailureCode::ArticlesUnavailable);
259            stages.push(StageResult {
260                name: "Verify".to_string(),
261                status: StageStatus::Failed,
262                message: Some(format!(
263                    "{} content article(s) missing and no PAR2 recovery set is available",
264                    config.content_articles_failed
265                )),
266                duration_secs: 0.0,
267            });
268        } else {
269            stages.push(StageResult {
270                name: "Verify".to_string(),
271                status: StageStatus::Skipped,
272                message: Some("No par2 files found".to_string()),
273                duration_secs: 0.0,
274            });
275        }
276    } else {
277        let started = Instant::now();
278        let (file_sets, parse_errors) = parse_par2_sets(&par2_files);
279        debug!(
280            recovery_sets = file_sets.len(),
281            parse_failures = parse_errors.len(),
282            "Parsed distinct PAR2 recovery sets"
283        );
284
285        if file_sets.is_empty() {
286            // Nothing parsed into a usable recovery set (every index file failed
287            // to parse). Fall back to main's existing behaviour.
288            let error = parse_errors
289                .first()
290                .map(String::as_str)
291                .unwrap_or("no PAR2 index file found");
292            if config.articles_failed == 0 {
293                stages.push(StageResult {
294                    name: "Verify".to_string(),
295                    status: StageStatus::Skipped,
296                    message: Some(format!(
297                        "PAR2 parse failed ({error}), but zero article failures"
298                    )),
299                    duration_secs: started.elapsed().as_secs_f64(),
300                });
301            } else {
302                stages.push(StageResult {
303                    name: "Verify".to_string(),
304                    status: StageStatus::Skipped,
305                    message: Some(format!("PAR2 parse failed ({error}), attempting repair")),
306                    duration_secs: started.elapsed().as_secs_f64(),
307                });
308                let repair_result = run_repair_stage(job_dir).await;
309                increment_counter(if repair_result.status == StageStatus::Failed {
310                    "par2.repair_failure"
311                } else {
312                    "par2.repair_success"
313                });
314                if repair_result.status == StageStatus::Failed {
315                    pipeline_ok = false;
316                    failure_code = Some(JobFailureCode::RepairFailed);
317                }
318                stages.push(repair_result);
319            }
320        } else if config.articles_failed == 0 {
321            // Files are known-good from CRC checks during yEnc decode, so the
322            // expensive MD5 verification pass is skipped.
323            //
324            // PAR2-guided deobfuscation still has to run for every recovery set.
325            // Obfuscated posts arrive with meaningless filenames whether or not
326            // an article failed, and the PAR2 metadata is the only record of the
327            // real names. A *clean* download of an obfuscated post must be
328            // deobfuscated too, or it completes with raw volumes on disk — a
329            // damaged download self-heals while a healthy one would not (issue
330            // #87). Before this, only the first recovery set was ever renamed.
331            info!(
332                recovery_sets = file_sets.len(),
333                "Skipping PAR2 verification — zero article failures (CRC-verified)"
334            );
335            for file_set in &file_sets {
336                rename_to_par2_names(file_set, job_dir);
337            }
338            stages.push(StageResult {
339                name: "Verify".to_string(),
340                status: StageStatus::Skipped,
341                message: Some(format!(
342                    "Skipped — zero article failures (PAR2-guided rename applied to {} recovery set(s))",
343                    file_sets.len()
344                )),
345                duration_secs: started.elapsed().as_secs_f64(),
346            });
347        } else {
348            let _repair_permit = if let Some(resources) = resources {
349                Some(resources.acquire_repair().await)
350            } else {
351                None
352            };
353            // Deobfuscate before verification so damaged obfuscated posts match
354            // their PAR2-expected names. Every distinct recovery set participates.
355            for file_set in &file_sets {
356                rename_to_par2_names(file_set, job_dir);
357            }
358            let recovery_set_count = file_sets.len();
359            let dir = job_dir.to_path_buf();
360            // Verify (and repair when needed) every set in a single
361            // spawn_blocking call. This keeps CPU-intensive work off the async
362            // runtime and keeps each non-Send VerifyResult on one thread so the
363            // repair can reuse it — no redundant second verification pass.
364            let outcomes = tokio::task::spawn_blocking(move || {
365                file_sets
366                    .iter()
367                    .map(|file_set| verify_repair_set(file_set, &dir))
368                    .collect::<Vec<_>>()
369            })
370            .await;
371            let duration = started.elapsed().as_secs_f64();
372
373            match outcomes {
374                Ok(outcomes) => {
375                    for (index, outcome) in outcomes.into_iter().enumerate() {
376                        let set_number = index + 1;
377                        match outcome {
378                            VerifyRepairOutcome::AllCorrect { intact_count } => {
379                                increment_counter("par2.verify_success");
380                                info!(
381                                    set_number,
382                                    recovery_set_count,
383                                    files = intact_count,
384                                    "Native PAR2 verify: all files correct"
385                                );
386                                stages.push(StageResult {
387                                    name: "Verify".to_string(),
388                                    status: StageStatus::Success,
389                                    message: Some(format!(
390                                        "Recovery set {set_number}/{recovery_set_count}: all {intact_count} files correct"
391                                    )),
392                                    duration_secs: duration,
393                                });
394                            }
395                            VerifyRepairOutcome::Damaged {
396                                intact,
397                                damaged,
398                                missing,
399                                blocks_needed,
400                                blocks_available,
401                                repair_result,
402                            } => {
403                                stages.push(StageResult {
404                                    name: "Verify".to_string(),
405                                    status: StageStatus::Success,
406                                    message: Some(format!(
407                                        "Recovery set {set_number}/{recovery_set_count}: {intact} intact, {damaged} damaged, {missing} missing — {blocks_needed} blocks needed"
408                                    )),
409                                    duration_secs: duration,
410                                });
411                                match repair_result {
412                                    Ok(result) => {
413                                        increment_counter(if result.success {
414                                            "par2.repair_success"
415                                        } else {
416                                            "par2.repair_failure"
417                                        });
418                                        info!(
419                                            set_number,
420                                            recovery_set_count,
421                                            blocks_repaired = result.blocks_repaired,
422                                            files_repaired = result.files_repaired,
423                                            "Native PAR2 repair complete"
424                                        );
425                                        if !result.success {
426                                            pipeline_ok = false;
427                                            failure_code = Some(JobFailureCode::RepairFailed);
428                                        }
429                                        stages.push(StageResult {
430                                            name: "Repair".to_string(),
431                                            status: if result.success {
432                                                StageStatus::Success
433                                            } else {
434                                                StageStatus::Failed
435                                            },
436                                            message: Some(format!(
437                                                "Recovery set {set_number}/{recovery_set_count}: {}",
438                                                result.message
439                                            )),
440                                            duration_secs: duration,
441                                        });
442                                    }
443                                    Err(e) => {
444                                        increment_counter("par2.repair_failure");
445                                        error!(
446                                            error = %e,
447                                            blocks_needed,
448                                            blocks_available,
449                                            damaged,
450                                            missing,
451                                            set_number,
452                                            recovery_set_count,
453                                            "Native PAR2 repair failed"
454                                        );
455                                        pipeline_ok = false;
456                                        failure_code = Some(JobFailureCode::RepairFailed);
457                                        stages.push(StageResult {
458                                            name: "Repair".to_string(),
459                                            status: StageStatus::Failed,
460                                            message: Some(format!(
461                                                "Recovery set {set_number}/{recovery_set_count}: repair failed: {e}"
462                                            )),
463                                            duration_secs: duration,
464                                        });
465                                    }
466                                }
467                            }
468                        }
469                    }
470                }
471                Err(e) => {
472                    error!(error = %e, "Verify/repair task panicked");
473                    pipeline_ok = false;
474                    failure_code = Some(JobFailureCode::RepairFailed);
475                    stages.push(StageResult {
476                        name: "Verify".to_string(),
477                        status: StageStatus::Failed,
478                        message: Some(format!("Verify task panicked: {e}")),
479                        duration_secs: duration,
480                    });
481                }
482            }
483        }
484    }
485
486    // ------------------------------------------------------------------
487    // Stage 3: Extract
488    // ------------------------------------------------------------------
489    // Extraction is safe only after verification/repair succeeded (or source
490    // content was already known-good). Confirmed unrepaired content damage
491    // must not be mistaken for a successful archive.
492    let should_extract = unpack && pipeline_ok;
493    let mut extracted_archives = Vec::new();
494    if should_extract {
495        let output_dir = config.output_dir.as_deref().unwrap_or(job_dir);
496        // Direct unpack has already extracted the outer archive, but its output
497        // can itself contain archives. Scan that directory so a nested archive
498        // cannot be silently left behind just because direct unpack was used.
499        let source_dir = if config.skip_extract {
500            info!("Outer extraction completed by direct unpack; checking for nested archives");
501            output_dir
502        } else {
503            job_dir
504        };
505        // Direct unpack only handles RAR sets. Any ZIP/7z/TAR posted alongside
506        // them is still in the job directory and must be extracted here, or
507        // cleanup would delete it without its contents ever reaching output.
508        let job_dir_archives: Vec<_> = if config.skip_extract && source_dir != job_dir {
509            find_archives(job_dir)
510                .into_iter()
511                .filter(|(archive_type, _)| *archive_type != ArchiveType::Rar)
512                .collect()
513        } else {
514            Vec::new()
515        };
516        let _extract_permit = if let Some(resources) = resources {
517            Some(resources.acquire_extract().await)
518        } else {
519            None
520        };
521        let (result, processed_archives, extract_failure_code) = run_extract_stage(
522            source_dir,
523            job_dir_archives,
524            output_dir,
525            config.password.as_deref(),
526            config.max_nested_archive_depth,
527        )
528        .await;
529        extracted_archives = processed_archives;
530        if result.status == StageStatus::Failed {
531            pipeline_ok = false;
532            failure_code = extract_failure_code.or(Some(JobFailureCode::ArchiveInvalid));
533        } else if result.status == StageStatus::Success {
534            // Extraction succeeded despite verify/repair failure — recover
535            pipeline_ok = true;
536        }
537        stages.push(result);
538    }
539
540    // ------------------------------------------------------------------
541    // Stage 4: Cleanup
542    // ------------------------------------------------------------------
543    if unpack && pipeline_ok && config.cleanup_after_extract {
544        let cleanup_root = config.output_dir.as_deref().unwrap_or(job_dir);
545        let result = run_cleanup_stage_with_rules(
546            job_dir,
547            cleanup_root,
548            &extracted_archives,
549            cleanup_patterns,
550            unwanted_extensions,
551            config.delete_archives,
552        );
553        stages.push(result);
554    }
555
556    let error = if pipeline_ok {
557        None
558    } else {
559        // Collect failure messages from stages
560        let msgs: Vec<String> = stages
561            .iter()
562            .filter(|s| s.status == StageStatus::Failed)
563            .filter_map(|s| s.message.clone())
564            .collect();
565        Some(msgs.join("; "))
566    };
567
568    info!(
569        success = pipeline_ok,
570        stages = stages.len(),
571        "Post-processing pipeline finished"
572    );
573
574    PostProcResult {
575        success: pipeline_ok,
576        stages,
577        error,
578        failure_code: if pipeline_ok {
579            None
580        } else {
581            failure_code.or(Some(JobFailureCode::DownloadFailed))
582        },
583    }
584}
585
586// ---------------------------------------------------------------------------
587// PAR2-guided deobfuscation
588// ---------------------------------------------------------------------------
589
590/// Rename files on disk to match PAR2 expected filenames.
591///
592/// Obfuscated Usenet posts often have readable names in NZB subjects but the
593/// actual PAR2 metadata references the original obfuscated filenames. This
594/// causes PAR2 verify to report all files as "missing" even though they exist.
595///
596/// This function matches files by MD5 hash of their first 16 KiB (which PAR2
597/// stores for each file) and renames them to what PAR2 expects. This is the
598/// same approach used by SABnzbd's `decode_par2()`.
599fn rename_to_par2_names(file_set: &rust_par2::Par2FileSet, dir: &Path) {
600    // Build a map of expected hash_16k → par2 filename
601    let mut expected: std::collections::HashMap<[u8; 16], &str> = std::collections::HashMap::new();
602    for par2_file in file_set.files.values() {
603        expected.insert(par2_file.hash_16k, &par2_file.filename);
604    }
605
606    // Check if any PAR2-expected files are already present — if so, no renaming needed
607    let any_match = file_set
608        .files
609        .values()
610        .any(|f| dir.join(&f.filename).exists());
611    if any_match {
612        return;
613    }
614
615    // Scan all files on disk and try to match by hash_16k
616    let entries: Vec<_> = match std::fs::read_dir(dir) {
617        Ok(rd) => rd.filter_map(|e| e.ok()).collect(),
618        Err(_) => return,
619    };
620
621    let mut renamed = 0u32;
622    for entry in &entries {
623        let path = entry.path();
624        if !path.is_file() {
625            continue;
626        }
627        let current_name = match path.file_name().and_then(|n| n.to_str()) {
628            Some(n) => n.to_string(),
629            None => continue,
630        };
631
632        // Skip PAR2 files themselves — they don't need renaming
633        if current_name.to_lowercase().ends_with(".par2") {
634            continue;
635        }
636
637        let hash = match rust_par2::compute_hash_16k(&path) {
638            Ok(h) => h,
639            Err(_) => continue,
640        };
641
642        if let Some(&par2_name) = expected.get(&hash)
643            && current_name != par2_name
644        {
645            let new_path = dir.join(par2_name);
646            if !new_path.exists() {
647                if let Err(e) = std::fs::rename(&path, &new_path) {
648                    warn!(
649                        from = %current_name,
650                        to = %par2_name,
651                        "Failed to rename file to PAR2 expected name: {e}"
652                    );
653                } else {
654                    renamed += 1;
655                    debug!(
656                        from = %current_name,
657                        to = %par2_name,
658                        "Renamed file to match PAR2 metadata"
659                    );
660                }
661            }
662        }
663    }
664
665    if renamed > 0 {
666        info!(
667            renamed,
668            "PAR2-guided deobfuscation: renamed files to match PAR2 expected names"
669        );
670    }
671}
672
673// ---------------------------------------------------------------------------
674// Internal stage runners
675// ---------------------------------------------------------------------------
676
677/// Repair stage when we don't have a pre-computed verify result.
678/// Uses par2_repair which does its own parse + verify + repair.
679async fn run_repair_stage(job_dir: &Path) -> StageResult {
680    let start = Instant::now();
681    let par2_files = find_par2_files(job_dir);
682
683    if par2_files.is_empty() {
684        return StageResult {
685            name: "Repair".to_string(),
686            status: StageStatus::Skipped,
687            message: Some("No par2 files found".to_string()),
688            duration_secs: start.elapsed().as_secs_f64(),
689        };
690    }
691
692    let index_par2 = &par2_files[0];
693    info!(file = %index_par2.display(), "Running native par2 repair");
694
695    match par2_repair(index_par2).await {
696        Ok(result) => {
697            let status = if result.repaired || result.success {
698                StageStatus::Success
699            } else {
700                StageStatus::Failed
701            };
702
703            StageResult {
704                name: "Repair".to_string(),
705                status,
706                message: Some(result.message),
707                duration_secs: start.elapsed().as_secs_f64(),
708            }
709        }
710        Err(e) => {
711            error!(error = %e, "par2 repair failed with error");
712            StageResult {
713                name: "Repair".to_string(),
714                status: StageStatus::Failed,
715                message: Some(format!("par2 repair error: {e}")),
716                duration_secs: start.elapsed().as_secs_f64(),
717            }
718        }
719    }
720}
721
722/// `extra_archives` are extracted alongside the first-level archives found in
723/// `source_dir` (used for non-RAR archives left in the job directory after
724/// direct unpack).
725async fn run_extract_stage(
726    source_dir: &Path,
727    extra_archives: Vec<(ArchiveType, PathBuf)>,
728    output_dir: &Path,
729    password: Option<&str>,
730    max_nested_archive_depth: u8,
731) -> (StageResult, Vec<PathBuf>, Option<JobFailureCode>) {
732    let start = Instant::now();
733    let mut all_ok = true;
734    let mut messages: Vec<String> = Vec::new();
735    // The completed directory can pre-exist (for example a category
736    // directory). Do not recurse into archives that were already there before
737    // this job extracted anything. Direct unpack writes to a job-specific
738    // output directory, so its initial archives are the intended input.
739    let mut processed: HashSet<PathBuf> = if source_dir == output_dir {
740        HashSet::new()
741    } else {
742        find_archives(output_dir)
743            .into_iter()
744            .map(|(_, path)| path)
745            .collect()
746    };
747    let mut extracted_archives = Vec::new();
748    let mut scan_dir = source_dir;
749    let mut extracted_any = false;
750    let mut failure_code = None;
751    let mut extra_archives = Some(extra_archives);
752
753    for depth in 0..=max_nested_archive_depth {
754        let archives: Vec<_> = find_archives(scan_dir)
755            .into_iter()
756            .chain(extra_archives.take().unwrap_or_default())
757            .filter(|(_, path)| processed.insert(path.clone()))
758            .collect();
759        if archives.is_empty() {
760            break;
761        }
762
763        extracted_any = true;
764        extracted_archives.extend(archives.iter().map(|(_, path)| path.clone()));
765        for (archive_type, path) in &archives {
766            info!(depth, kind = %archive_type, file = %path.display(), "Extracting archive");
767            let result = match archive_type {
768                ArchiveType::Rar => extract_rar(path, output_dir, password).await,
769                ArchiveType::SevenZip => extract_7z(path, output_dir, password).await,
770                ArchiveType::Tar => extract_tar(path, output_dir).await,
771                ArchiveType::Zip => extract_zip(path, output_dir).await,
772            };
773
774            match result {
775                Ok(unpack_result) if unpack_result.success => {
776                    messages.push(format!("depth {depth} {archive_type}: OK"));
777                }
778                Ok(unpack_result) => {
779                    all_ok = false;
780                    failure_code = Some(JobFailureCode::ArchiveInvalid);
781                    let detail = unpack_result
782                        .error_output
783                        .trim()
784                        .lines()
785                        .find(|line| !line.trim().is_empty());
786                    messages.push(match detail {
787                        Some(detail) => format!("depth {depth} {archive_type}: failed ({detail})"),
788                        None => format!("depth {depth} {archive_type}: failed"),
789                    });
790                }
791                Err(e) => {
792                    all_ok = false;
793                    failure_code = Some(if e.downcast_ref::<ArchivePasswordRequired>().is_some() {
794                        JobFailureCode::ArchivePasswordRequired
795                    } else {
796                        JobFailureCode::ArchiveInvalid
797                    });
798                    error!(depth, kind = %archive_type, file = %path.display(), error = %e, "Extraction error");
799                    messages.push(format!("depth {depth} {archive_type}: {e}"));
800                }
801            }
802        }
803
804        if !all_ok {
805            break;
806        }
807        scan_dir = output_dir;
808    }
809
810    if all_ok
811        && !find_archives(scan_dir)
812            .into_iter()
813            .all(|(_, path)| processed.contains(&path))
814    {
815        all_ok = false;
816        failure_code = Some(JobFailureCode::ArchiveInvalid);
817        messages.push(format!(
818            "nested archive depth limit ({max_nested_archive_depth}) reached; source files retained"
819        ));
820    }
821
822    if !extracted_any {
823        info!("No archives found — skipping extraction");
824        return (
825            StageResult {
826                name: "Extract".to_string(),
827                status: StageStatus::Skipped,
828                message: Some("No archives found".to_string()),
829                duration_secs: start.elapsed().as_secs_f64(),
830            },
831            Vec::new(),
832            None,
833        );
834    }
835
836    (
837        StageResult {
838            name: "Extract".to_string(),
839            status: if all_ok {
840                StageStatus::Success
841            } else {
842                StageStatus::Failed
843            },
844            message: Some(messages.join("; ")),
845            duration_secs: start.elapsed().as_secs_f64(),
846        },
847        extracted_archives,
848        failure_code,
849    )
850}
851
852#[cfg(test)]
853fn run_cleanup_stage(job_dir: &Path, extracted_archives: &[PathBuf]) -> StageResult {
854    run_cleanup_stage_with_rules(job_dir, job_dir, extracted_archives, &[], &[], true)
855}
856
857fn wildcard_match(pattern: &str, value: &str) -> bool {
858    let (mut pattern_index, mut value_index) = (0usize, 0usize);
859    let mut star = None;
860    let mut star_value = 0usize;
861    let pattern = pattern.as_bytes();
862    let value = value.as_bytes();
863
864    while value_index < value.len() {
865        if pattern_index < pattern.len()
866            && (pattern[pattern_index] == value[value_index] || pattern[pattern_index] == b'?')
867        {
868            pattern_index += 1;
869            value_index += 1;
870        } else if pattern_index < pattern.len() && pattern[pattern_index] == b'*' {
871            star = Some(pattern_index);
872            pattern_index += 1;
873            star_value = value_index;
874        } else if let Some(star_index) = star {
875            pattern_index = star_index + 1;
876            star_value += 1;
877            value_index = star_value;
878        } else {
879            return false;
880        }
881    }
882    while pattern_index < pattern.len() && pattern[pattern_index] == b'*' {
883        pattern_index += 1;
884    }
885    pattern_index == pattern.len()
886}
887
888fn run_cleanup_stage_with_rules(
889    job_dir: &Path,
890    cleanup_root: &Path,
891    extracted_archives: &[PathBuf],
892    cleanup_patterns: &[String],
893    unwanted_extensions: &[String],
894    delete_archives: bool,
895) -> StageResult {
896    let start = Instant::now();
897    let mut files = find_cleanup_files(job_dir);
898    if delete_archives {
899        files.extend(
900            extracted_archives
901                .iter()
902                .filter(|path| {
903                    std::fs::symlink_metadata(path)
904                        .map(|metadata| metadata.file_type().is_file())
905                        .unwrap_or(false)
906                })
907                .cloned(),
908        );
909    } else {
910        // Keep the archive set; PAR2 recovery files are still removed, as
911        // SABnzbd does after a successful verify regardless of "+Delete".
912        files.retain(|path| {
913            path.extension()
914                .is_some_and(|extension| extension.eq_ignore_ascii_case("par2"))
915        });
916    }
917
918    let normalized_extensions: Vec<String> = unwanted_extensions
919        .iter()
920        .map(|extension| {
921            let extension = extension.trim().to_ascii_lowercase();
922            if extension.starts_with('.') {
923                extension
924            } else {
925                format!(".{extension}")
926            }
927        })
928        .filter(|extension| extension.len() > 1)
929        .collect();
930    let patterns: Vec<String> = cleanup_patterns
931        .iter()
932        .map(|pattern| pattern.replace('\\', "/").to_ascii_lowercase())
933        .filter(|pattern| !pattern.is_empty())
934        .collect();
935    for root in [job_dir, cleanup_root] {
936        for entry in walkdir::WalkDir::new(root).into_iter().flatten() {
937            let path = entry.path();
938            let Ok(metadata) = std::fs::symlink_metadata(path) else {
939                continue;
940            };
941            if !metadata.file_type().is_file() {
942                continue;
943            }
944            let Ok(relative) = path.strip_prefix(root) else {
945                continue;
946            };
947            let relative = relative.to_string_lossy().replace('\\', "/");
948            let filename = path
949                .file_name()
950                .map(|name| name.to_string_lossy().to_string())
951                .unwrap_or_default();
952            if normalized_extensions
953                .iter()
954                .any(|extension| filename.to_ascii_lowercase().ends_with(extension))
955                || patterns.iter().any(|pattern| {
956                    wildcard_match(pattern, &relative.to_ascii_lowercase())
957                        || wildcard_match(pattern, &filename.to_ascii_lowercase())
958                })
959            {
960                files.push(path.to_path_buf());
961            }
962        }
963    }
964    files.sort();
965    files.dedup();
966
967    if files.is_empty() {
968        return StageResult {
969            name: "Cleanup".to_string(),
970            status: StageStatus::Skipped,
971            message: Some("No files to clean up".to_string()),
972            duration_secs: start.elapsed().as_secs_f64(),
973        };
974    }
975
976    let mut removed = 0u32;
977    let mut errors = 0u32;
978
979    for path in &files {
980        let under_allowed_root = path.starts_with(job_dir) || path.starts_with(cleanup_root);
981        let is_regular_file = std::fs::symlink_metadata(path)
982            .map(|metadata| metadata.file_type().is_file())
983            .unwrap_or(false);
984        if !under_allowed_root || !is_regular_file {
985            warn!(file = %path.display(), "Skipping cleanup path outside job roots or through a link");
986            errors += 1;
987            continue;
988        }
989        match std::fs::remove_file(path) {
990            Ok(()) => {
991                removed += 1;
992            }
993            Err(e) => {
994                warn!(file = %path.display(), error = %e, "Failed to remove cleanup file");
995                errors += 1;
996            }
997        }
998    }
999
1000    let status = if errors == 0 {
1001        StageStatus::Success
1002    } else {
1003        StageStatus::Failed
1004    };
1005
1006    StageResult {
1007        name: "Cleanup".to_string(),
1008        status,
1009        message: Some(format!("Removed {removed} files, {errors} errors")),
1010        duration_secs: start.elapsed().as_secs_f64(),
1011    }
1012}
1013
1014// ---------------------------------------------------------------------------
1015// Tests
1016// ---------------------------------------------------------------------------
1017
1018#[cfg(test)]
1019mod tests {
1020    use super::*;
1021    use std::fs;
1022    use std::io::Write;
1023
1024    fn make_test_dir(files: &[&str]) -> tempfile::TempDir {
1025        let dir = tempfile::tempdir().unwrap();
1026        for name in files {
1027            let path = dir.path().join(name);
1028            if let Some(parent) = path.parent() {
1029                fs::create_dir_all(parent).unwrap();
1030            }
1031            fs::write(&path, b"").unwrap();
1032        }
1033        dir
1034    }
1035
1036    #[test]
1037    fn test_post_proc_result_default() {
1038        let result = PostProcResult {
1039            success: true,
1040            stages: vec![],
1041            error: None,
1042            failure_code: None,
1043        };
1044        assert!(result.success);
1045        assert!(result.stages.is_empty());
1046        assert!(result.error.is_none());
1047        assert!(result.failure_code.is_none());
1048    }
1049
1050    #[test]
1051    fn test_config_default() {
1052        let config = PostProcConfig::default();
1053        assert!(config.cleanup_after_extract);
1054        assert!(config.output_dir.is_none());
1055        assert_eq!(config.articles_failed, 0);
1056    }
1057
1058    #[tokio::test]
1059    async fn test_pipeline_no_files() {
1060        // An empty directory should skip all stages
1061        let dir = make_test_dir(&[]);
1062        let config = PostProcConfig::default();
1063        let result = run_pipeline(dir.path(), &config).await;
1064
1065        assert!(result.success, "Pipeline should succeed for empty dir");
1066
1067        // Verify should be skipped (no par2), Extract should be skipped (no archives)
1068        let verify_stage = result.stages.iter().find(|s| s.name == "Verify");
1069        assert!(verify_stage.is_some(), "Verify stage should be present");
1070        assert_eq!(verify_stage.unwrap().status, StageStatus::Skipped);
1071
1072        let extract_stage = result.stages.iter().find(|s| s.name == "Extract");
1073        assert!(extract_stage.is_some(), "Extract stage should be present");
1074        assert_eq!(extract_stage.unwrap().status, StageStatus::Skipped);
1075    }
1076
1077    #[tokio::test]
1078    async fn test_pipeline_only_text_files() {
1079        let dir = make_test_dir(&["readme.txt", "info.nfo"]);
1080        let config = PostProcConfig::default();
1081        let result = run_pipeline(dir.path(), &config).await;
1082
1083        assert!(result.success);
1084        // All stages should be skipped
1085        for stage in &result.stages {
1086            assert_eq!(
1087                stage.status,
1088                StageStatus::Skipped,
1089                "Stage '{}' should be skipped",
1090                stage.name
1091            );
1092        }
1093    }
1094
1095    #[test]
1096    fn test_cleanup_removes_files() {
1097        let dir = make_test_dir(&[
1098            "movie.par2",
1099            "movie.vol00+01.par2",
1100            "movie.rar",
1101            "movie.r00",
1102            "movie.mkv", // should NOT be removed
1103        ]);
1104
1105        let result = run_cleanup_stage(dir.path(), &[]);
1106        assert_eq!(result.status, StageStatus::Success);
1107
1108        // movie.mkv should still exist
1109        assert!(dir.path().join("movie.mkv").exists());
1110        // par2 and rar files should be gone
1111        assert!(!dir.path().join("movie.par2").exists());
1112        assert!(!dir.path().join("movie.vol00+01.par2").exists());
1113        assert!(!dir.path().join("movie.rar").exists());
1114        assert!(!dir.path().join("movie.r00").exists());
1115    }
1116
1117    fn write_zip(path: &Path, entries: &[(&str, &[u8])]) {
1118        let file = fs::File::create(path).unwrap();
1119        let mut writer = zip::ZipWriter::new(file);
1120        for (name, contents) in entries {
1121            writer
1122                .start_file(*name, zip::write::SimpleFileOptions::default())
1123                .unwrap();
1124            writer.write_all(contents).unwrap();
1125        }
1126        writer.finish().unwrap();
1127    }
1128
1129    #[tokio::test]
1130    async fn nested_zip_archives_are_extracted_recursively() {
1131        let source = tempfile::tempdir().unwrap();
1132        let output = tempfile::tempdir().unwrap();
1133        let inner = source.path().join("inner.zip");
1134        write_zip(&inner, &[("payload.txt", b"nested payload")]);
1135        let outer = source.path().join("outer.zip");
1136        write_zip(&outer, &[("inner.zip", &fs::read(&inner).unwrap())]);
1137        fs::remove_file(inner).unwrap();
1138
1139        let (result, _, failure_code) =
1140            run_extract_stage(source.path(), Vec::new(), output.path(), None, 1).await;
1141
1142        assert_eq!(result.status, StageStatus::Success, "{result:?}");
1143        assert_eq!(failure_code, None);
1144        assert_eq!(
1145            fs::read(output.path().join("payload.txt")).unwrap(),
1146            b"nested payload"
1147        );
1148    }
1149
1150    #[tokio::test]
1151    async fn nested_archive_depth_limit_fails_without_discarding_inner_archive() {
1152        let source = tempfile::tempdir().unwrap();
1153        let output = tempfile::tempdir().unwrap();
1154        let inner = source.path().join("inner.zip");
1155        write_zip(&inner, &[("payload.txt", b"nested payload")]);
1156        let outer = source.path().join("outer.zip");
1157        write_zip(&outer, &[("inner.zip", &fs::read(&inner).unwrap())]);
1158        fs::remove_file(inner).unwrap();
1159
1160        let (result, _, failure_code) =
1161            run_extract_stage(source.path(), Vec::new(), output.path(), None, 0).await;
1162
1163        assert_eq!(result.status, StageStatus::Failed, "{result:?}");
1164        assert_eq!(failure_code, Some(JobFailureCode::ArchiveInvalid));
1165        assert!(output.path().join("inner.zip").exists());
1166        assert!(!output.path().join("payload.txt").exists());
1167    }
1168
1169    #[tokio::test]
1170    async fn repair_pipeline_neither_extracts_nor_cleans_up() {
1171        let source = tempfile::tempdir().unwrap();
1172        let output = tempfile::tempdir().unwrap();
1173        let archive = source.path().join("release.zip");
1174        write_zip(&archive, &[("payload.txt", b"payload")]);
1175
1176        let config = PostProcConfig {
1177            output_dir: Some(output.path().to_path_buf()),
1178            ..Default::default()
1179        };
1180        let result = run_repair_pipeline(source.path(), &config, None).await;
1181
1182        assert!(result.success, "{result:?}");
1183        assert!(
1184            result
1185                .stages
1186                .iter()
1187                .all(|stage| stage.name != "Extract" && stage.name != "Cleanup"),
1188            "{:?}",
1189            result.stages
1190        );
1191        assert!(archive.exists());
1192        assert!(!output.path().join("payload.txt").exists());
1193    }
1194
1195    #[tokio::test]
1196    async fn recursive_cleanup_removes_nested_archives_from_output_directory() {
1197        let source = tempfile::tempdir().unwrap();
1198        let output = tempfile::tempdir().unwrap();
1199        let inner = source.path().join("inner.zip");
1200        write_zip(&inner, &[("payload.txt", b"nested payload")]);
1201        let outer = source.path().join("outer.zip");
1202        write_zip(&outer, &[("inner.zip", &fs::read(&inner).unwrap())]);
1203        fs::remove_file(inner).unwrap();
1204
1205        let config = PostProcConfig {
1206            output_dir: Some(output.path().to_path_buf()),
1207            ..Default::default()
1208        };
1209        let result = run_pipeline(source.path(), &config).await;
1210
1211        assert!(result.success, "{result:?}");
1212        assert_eq!(
1213            fs::read(output.path().join("payload.txt")).unwrap(),
1214            b"nested payload"
1215        );
1216        assert!(!output.path().join("inner.zip").exists());
1217        assert!(!source.path().join("outer.zip").exists());
1218    }
1219
1220    #[tokio::test]
1221    async fn cleanup_keeps_unrelated_archives_in_the_output_directory() {
1222        let source = tempfile::tempdir().unwrap();
1223        let output = tempfile::tempdir().unwrap();
1224        let inner = source.path().join("inner.zip");
1225        write_zip(&inner, &[("payload.txt", b"nested payload")]);
1226        let outer = source.path().join("outer.zip");
1227        write_zip(&outer, &[("inner.zip", &fs::read(&inner).unwrap())]);
1228        fs::remove_file(inner).unwrap();
1229        let unrelated = output.path().join("keep-me.zip");
1230        write_zip(&unrelated, &[("unrelated.txt", b"keep")]);
1231
1232        let config = PostProcConfig {
1233            output_dir: Some(output.path().to_path_buf()),
1234            ..Default::default()
1235        };
1236        let result = run_pipeline(source.path(), &config).await;
1237
1238        assert!(result.success, "{result:?}");
1239        assert!(unrelated.exists());
1240        assert!(!output.path().join("inner.zip").exists());
1241    }
1242
1243    #[tokio::test]
1244    async fn cleanup_keeps_letter_digit_payload_when_no_archive_exists() {
1245        let job_dir = tempfile::tempdir().unwrap();
1246        let output = tempfile::tempdir().unwrap();
1247        fs::write(job_dir.path().join("Game.n64"), b"rom").unwrap();
1248        fs::write(job_dir.path().join("disk.d64"), b"disk").unwrap();
1249
1250        let config = PostProcConfig {
1251            output_dir: Some(output.path().to_path_buf()),
1252            ..Default::default()
1253        };
1254        let result = run_pipeline(job_dir.path(), &config).await;
1255
1256        assert!(result.success, "{result:?}");
1257        assert_eq!(fs::read(job_dir.path().join("Game.n64")).unwrap(), b"rom");
1258        assert_eq!(fs::read(job_dir.path().join("disk.d64")).unwrap(), b"disk");
1259    }
1260
1261    #[tokio::test]
1262    async fn direct_unpack_still_extracts_nested_archives() {
1263        let job_dir = tempfile::tempdir().unwrap();
1264        let output = tempfile::tempdir().unwrap();
1265        let nested = output.path().join("nested.zip");
1266        write_zip(&nested, &[("payload.txt", b"nested payload")]);
1267
1268        let config = PostProcConfig {
1269            output_dir: Some(output.path().to_path_buf()),
1270            skip_extract: true,
1271            ..Default::default()
1272        };
1273        let result = run_pipeline(job_dir.path(), &config).await;
1274
1275        assert!(result.success, "{result:?}");
1276        assert_eq!(
1277            fs::read(output.path().join("payload.txt")).unwrap(),
1278            b"nested payload"
1279        );
1280        assert!(!nested.exists());
1281    }
1282
1283    #[tokio::test]
1284    async fn direct_unpack_still_extracts_non_rar_archives_in_job_dir() {
1285        // Direct unpack only handles RAR sets. A ZIP posted alongside the RAR
1286        // set must still be extracted before cleanup removes it.
1287        let job_dir = tempfile::tempdir().unwrap();
1288        let output = tempfile::tempdir().unwrap();
1289        fs::write(job_dir.path().join("movie.part01.rar"), b"already unpacked").unwrap();
1290        fs::write(output.path().join("movie.mkv"), b"movie").unwrap();
1291        let subs = job_dir.path().join("Subs.zip");
1292        write_zip(&subs, &[("movie.en.srt", b"subtitles")]);
1293
1294        let config = PostProcConfig {
1295            output_dir: Some(output.path().to_path_buf()),
1296            skip_extract: true,
1297            ..Default::default()
1298        };
1299        let result = run_pipeline(job_dir.path(), &config).await;
1300
1301        assert!(result.success, "{result:?}");
1302        assert_eq!(
1303            fs::read(output.path().join("movie.en.srt")).unwrap(),
1304            b"subtitles"
1305        );
1306        assert_eq!(fs::read(output.path().join("movie.mkv")).unwrap(), b"movie");
1307        assert!(!subs.exists());
1308    }
1309
1310    #[tokio::test]
1311    async fn keep_archives_preserves_unpacked_rar_set_and_removes_par2() {
1312        // SABnzbd pp=2 ("+Unpack" without "+Delete"): the RAR set is unpacked
1313        // (here by direct unpack) but its volumes must survive cleanup.
1314        let job_dir = tempfile::tempdir().unwrap();
1315        let output = tempfile::tempdir().unwrap();
1316        let volumes = ["movie.part01.rar", "movie.part02.rar", "movie.r00"];
1317        for volume in volumes {
1318            fs::write(job_dir.path().join(volume), b"Rar!\x1a\x07\x01\x00 volume").unwrap();
1319        }
1320        fs::write(job_dir.path().join("movie.par2"), b"par2").unwrap();
1321        fs::write(output.path().join("movie.mkv"), b"movie").unwrap();
1322
1323        let config = PostProcConfig {
1324            output_dir: Some(output.path().to_path_buf()),
1325            skip_extract: true,
1326            delete_archives: false,
1327            ..Default::default()
1328        };
1329        let result = run_pipeline(job_dir.path(), &config).await;
1330
1331        assert!(result.success, "{result:?}");
1332        for volume in volumes {
1333            assert!(job_dir.path().join(volume).exists(), "{volume} was deleted");
1334        }
1335        assert!(!job_dir.path().join("movie.par2").exists());
1336        assert_eq!(fs::read(output.path().join("movie.mkv")).unwrap(), b"movie");
1337    }
1338
1339    #[tokio::test]
1340    async fn keep_archives_preserves_extracted_zip_and_nested_archives() {
1341        let source = tempfile::tempdir().unwrap();
1342        let output = tempfile::tempdir().unwrap();
1343        let inner = source.path().join("inner.zip");
1344        write_zip(&inner, &[("payload.txt", b"nested payload")]);
1345        let outer = source.path().join("outer.zip");
1346        write_zip(&outer, &[("inner.zip", &fs::read(&inner).unwrap())]);
1347        fs::remove_file(inner).unwrap();
1348
1349        let config = PostProcConfig {
1350            output_dir: Some(output.path().to_path_buf()),
1351            delete_archives: false,
1352            ..Default::default()
1353        };
1354        let result = run_pipeline(source.path(), &config).await;
1355
1356        assert!(result.success, "{result:?}");
1357        assert_eq!(
1358            fs::read(output.path().join("payload.txt")).unwrap(),
1359            b"nested payload"
1360        );
1361        assert!(outer.exists());
1362        assert!(output.path().join("inner.zip").exists());
1363    }
1364
1365    #[tokio::test]
1366    async fn direct_unpack_keeps_non_rar_archive_that_fails_to_extract() {
1367        let job_dir = tempfile::tempdir().unwrap();
1368        let output = tempfile::tempdir().unwrap();
1369        fs::write(output.path().join("movie.mkv"), b"movie").unwrap();
1370        let broken = job_dir.path().join("Subs.zip");
1371        fs::write(&broken, b"not a zip").unwrap();
1372
1373        let config = PostProcConfig {
1374            output_dir: Some(output.path().to_path_buf()),
1375            skip_extract: true,
1376            ..Default::default()
1377        };
1378        let result = run_pipeline(job_dir.path(), &config).await;
1379
1380        assert!(!result.success, "{result:?}");
1381        assert!(broken.exists(), "unextracted archive must not be deleted");
1382    }
1383
1384    #[tokio::test]
1385    async fn test_pipeline_stage_order() {
1386        // With an empty dir, we can at least verify the stages that run
1387        // are in the correct order.
1388        let dir = make_test_dir(&[]);
1389        let config = PostProcConfig {
1390            cleanup_after_extract: false,
1391            ..Default::default()
1392        };
1393        let result = run_pipeline(dir.path(), &config).await;
1394
1395        // Should have Verify and Extract (both skipped). Cleanup is disabled.
1396        let stage_names: Vec<&str> = result.stages.iter().map(|s| s.name.as_str()).collect();
1397        assert!(stage_names.contains(&"Verify"), "Should have Verify stage");
1398        assert!(
1399            stage_names.contains(&"Extract"),
1400            "Should have Extract stage"
1401        );
1402
1403        // Verify should come before Extract
1404        let verify_idx = stage_names.iter().position(|&n| n == "Verify").unwrap();
1405        let extract_idx = stage_names.iter().position(|&n| n == "Extract").unwrap();
1406        assert!(
1407            verify_idx < extract_idx,
1408            "Verify ({verify_idx}) should come before Extract ({extract_idx})"
1409        );
1410    }
1411
1412    #[tokio::test]
1413    async fn test_pipeline_skips_verify_with_zero_failures() {
1414        // With par2 files present and articles_failed == 0, verify is skipped
1415        // because files are known-good from CRC checks during yEnc decode.
1416        let dir = make_test_dir(&["movie.par2", "movie.vol00+01.par2", "movie.mkv"]);
1417        let config = PostProcConfig {
1418            cleanup_after_extract: false,
1419            ..Default::default()
1420        };
1421        let result = run_pipeline(dir.path(), &config).await;
1422        assert!(result.success);
1423
1424        let verify_stage = result.stages.iter().find(|s| s.name == "Verify").unwrap();
1425        assert_eq!(
1426            verify_stage.status,
1427            StageStatus::Skipped,
1428            "Verify should be skipped when articles_failed == 0"
1429        );
1430        assert!(
1431            verify_stage
1432                .message
1433                .as_deref()
1434                .unwrap_or("")
1435                .contains("zero article failures"),
1436            "Skip message should indicate zero failures"
1437        );
1438    }
1439
1440    #[tokio::test]
1441    async fn test_pipeline_no_par2_with_content_failures_is_terminal() {
1442        // Confirmed source damage without PAR2 cannot be repaired and must
1443        // not continue into extraction.
1444        let dir = make_test_dir(&["movie.mkv"]);
1445        let config = PostProcConfig {
1446            cleanup_after_extract: false,
1447            articles_failed: 5,
1448            content_articles_failed: 5,
1449            ..Default::default()
1450        };
1451        let result = run_pipeline(dir.path(), &config).await;
1452        assert!(!result.success);
1453        assert_eq!(
1454            result.failure_code,
1455            Some(JobFailureCode::ArticlesUnavailable)
1456        );
1457
1458        let verify_stage = result.stages.iter().find(|s| s.name == "Verify").unwrap();
1459        assert_eq!(
1460            verify_stage.status,
1461            StageStatus::Failed,
1462            "No-PAR content damage is unrecoverable"
1463        );
1464        assert!(
1465            verify_stage
1466                .message
1467                .as_deref()
1468                .unwrap_or("")
1469                .contains("no PAR2 recovery set"),
1470            "Failure should explain that recovery data is unavailable"
1471        );
1472        assert!(result.stages.iter().all(|stage| stage.name != "Extract"));
1473    }
1474
1475    #[tokio::test]
1476    async fn test_pipeline_runs_verify_then_repair_when_failures() {
1477        // With par2 files and articles_failed > 0, native verify should run first.
1478        // Since these are dummy empty par2 files, native parse will fail and
1479        // the pipeline should fall back to par2cmdline for repair.
1480        let dir = make_test_dir(&["movie.par2", "movie.vol00+01.par2", "movie.mkv"]);
1481        let config = PostProcConfig {
1482            cleanup_after_extract: false,
1483            articles_failed: 3,
1484            ..Default::default()
1485        };
1486        let result = run_pipeline(dir.path(), &config).await;
1487
1488        // Should always have a Verify stage now (native par2 verify runs first)
1489        let stage_names: Vec<&str> = result.stages.iter().map(|s| s.name.as_str()).collect();
1490        assert!(
1491            stage_names.contains(&"Verify"),
1492            "Should have Verify stage (native par2), got: {stage_names:?}"
1493        );
1494        // Repair stage should also be present since dummy par2 files
1495        // will either fail to parse or report damage
1496        assert!(
1497            stage_names.contains(&"Repair"),
1498            "Should have Repair stage when articles_failed > 0, got: {stage_names:?}"
1499        );
1500    }
1501
1502    // -----------------------------------------------------------------------
1503    // PAR2-guided deobfuscation tests
1504    // -----------------------------------------------------------------------
1505
1506    /// Helper to build a Par2FileSet with given filename→content mappings.
1507    /// Computes hash_16k by writing content to temp files and using rust_par2.
1508    fn make_par2_file_set(tmp: &Path, files: &[(&str, &[u8])]) -> rust_par2::Par2FileSet {
1509        use rust_par2::{Par2File, Par2FileSet};
1510        let mut map = std::collections::HashMap::new();
1511        let mut file_order = Vec::with_capacity(files.len());
1512        for (i, (name, content)) in files.iter().enumerate() {
1513            // Write to temp file so we can use compute_hash_16k
1514            let tmp_path = tmp.join(format!("_par2_tmp_{i}"));
1515            fs::write(&tmp_path, content).unwrap();
1516            let hash_16k = rust_par2::compute_hash_16k(&tmp_path).unwrap();
1517            let _ = fs::remove_file(&tmp_path);
1518
1519            let file_id = [i as u8; 16];
1520            file_order.push(file_id);
1521            map.insert(
1522                file_id,
1523                Par2File {
1524                    file_id,
1525                    hash: [0u8; 16],
1526                    hash_16k,
1527                    size: content.len() as u64,
1528                    filename: name.to_string(),
1529                    slices: vec![],
1530                },
1531            );
1532        }
1533        Par2FileSet {
1534            recovery_set_id: [0u8; 16],
1535            slice_size: 16384,
1536            file_order,
1537            files: map,
1538            recovery_block_count: 0,
1539            creator: None,
1540        }
1541    }
1542
1543    #[test]
1544    fn test_rename_to_par2_names_renames_mismatched() {
1545        let dir = tempfile::tempdir().unwrap();
1546
1547        // Write files with "readable" names
1548        let content_a = b"AAAA test data for part01";
1549        let content_b = b"BBBB test data for part02";
1550        fs::write(dir.path().join("Movie.Name.part01.rar"), content_a).unwrap();
1551        fs::write(dir.path().join("Movie.Name.part02.rar"), content_b).unwrap();
1552
1553        // PAR2 expects obfuscated names with the same content
1554        let file_set = make_par2_file_set(
1555            dir.path(),
1556            &[
1557                ("xY7kQ3.part01.rar", content_a),
1558                ("xY7kQ3.part02.rar", content_b),
1559            ],
1560        );
1561
1562        rename_to_par2_names(&file_set, dir.path());
1563
1564        // Files should be renamed to PAR2 expected names
1565        assert!(
1566            dir.path().join("xY7kQ3.part01.rar").exists(),
1567            "part01 should be renamed to obfuscated name"
1568        );
1569        assert!(
1570            dir.path().join("xY7kQ3.part02.rar").exists(),
1571            "part02 should be renamed to obfuscated name"
1572        );
1573        assert!(
1574            !dir.path().join("Movie.Name.part01.rar").exists(),
1575            "old readable name should no longer exist"
1576        );
1577        assert!(
1578            !dir.path().join("Movie.Name.part02.rar").exists(),
1579            "old readable name should no longer exist"
1580        );
1581    }
1582
1583    #[test]
1584    fn test_rename_to_par2_names_skips_when_already_correct() {
1585        let dir = tempfile::tempdir().unwrap();
1586
1587        // Files already have the PAR2-expected names
1588        let content = b"test data already correct";
1589        fs::write(dir.path().join("xY7kQ3.part01.rar"), content).unwrap();
1590
1591        let file_set = make_par2_file_set(dir.path(), &[("xY7kQ3.part01.rar", content)]);
1592
1593        rename_to_par2_names(&file_set, dir.path());
1594
1595        // File should still exist with same name (no rename needed)
1596        assert!(dir.path().join("xY7kQ3.part01.rar").exists());
1597    }
1598
1599    #[test]
1600    fn test_rename_to_par2_names_skips_par2_files() {
1601        let dir = tempfile::tempdir().unwrap();
1602
1603        // PAR2 files should not be renamed even if hash matches
1604        let content = b"par2 file content";
1605        fs::write(dir.path().join("Movie.Name.par2"), content).unwrap();
1606        fs::write(dir.path().join("Movie.Name.part01.rar"), b"rar data").unwrap();
1607
1608        let file_set = make_par2_file_set(dir.path(), &[("obfuscated.par2", content)]);
1609
1610        rename_to_par2_names(&file_set, dir.path());
1611
1612        // PAR2 file should NOT be renamed
1613        assert!(
1614            dir.path().join("Movie.Name.par2").exists(),
1615            "PAR2 files should be skipped"
1616        );
1617    }
1618
1619    #[test]
1620    fn test_rename_to_par2_names_no_match() {
1621        let dir = tempfile::tempdir().unwrap();
1622
1623        // File content doesn't match any PAR2 entry
1624        fs::write(dir.path().join("Movie.Name.part01.rar"), b"unrelated data").unwrap();
1625
1626        let file_set = make_par2_file_set(
1627            dir.path(),
1628            &[("xY7kQ3.part01.rar", b"different data" as &[u8])],
1629        );
1630
1631        rename_to_par2_names(&file_set, dir.path());
1632
1633        // File should remain with original name (no hash match)
1634        assert!(dir.path().join("Movie.Name.part01.rar").exists());
1635        assert!(!dir.path().join("xY7kQ3.part01.rar").exists());
1636    }
1637}