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