Skip to main content

nzb_postproc/
par2.rs

1//! Par2 verification and repair via pure-Rust `rust-par2` library.
2//!
3//! No external binary or subprocess is needed — all PAR2 parsing, verification,
4//! and Reed-Solomon repair runs natively in-process.
5
6use std::path::Path;
7
8use tracing::{info, warn};
9
10/// Result of a par2 verify/repair operation.
11#[derive(Debug)]
12pub struct Par2Result {
13    pub success: bool,
14    pub blocks_needed: u32,
15    pub blocks_available: u32,
16    pub repaired: bool,
17    pub message: String,
18}
19
20/// Return whether the available recovery blocks can cover verified damage.
21pub fn recovery_can_cover(blocks_needed: u32, blocks_available: u32) -> bool {
22    blocks_needed <= blocks_available
23}
24
25/// Repair damaged/missing files using native Rust PAR2 repair.
26///
27/// This runs verification internally and then repairs if needed.
28/// Blocking — call from `spawn_blocking` in async contexts.
29pub fn par2_repair_blocking(par2_file: &Path) -> anyhow::Result<Par2Result> {
30    let dir = par2_file.parent().unwrap_or(Path::new("."));
31
32    let file_set = rust_par2::parse(par2_file).map_err(|e| anyhow::anyhow!("PAR2 parse: {e}"))?;
33    let verify_result = rust_par2::verify(&file_set, dir);
34
35    if verify_result.all_correct() {
36        return Ok(Par2Result {
37            success: true,
38            blocks_needed: 0,
39            blocks_available: verify_result.recovery_blocks_available,
40            repaired: false,
41            message: "All files correct".to_string(),
42        });
43    }
44
45    let blocks_needed = verify_result.blocks_needed();
46    let blocks_available = verify_result.recovery_blocks_available;
47
48    info!(
49        blocks_needed,
50        blocks_available,
51        damaged = verify_result.damaged.len(),
52        missing = verify_result.missing.len(),
53        "Damage detected, attempting native repair"
54    );
55
56    if !recovery_can_cover(blocks_needed, blocks_available) {
57        let message = format!(
58            "Insufficient recovery data: need {blocks_needed} blocks, have {blocks_available}"
59        );
60        warn!(
61            blocks_needed,
62            blocks_available, "Native PAR2 repair cannot cover damage"
63        );
64        return Ok(Par2Result {
65            success: false,
66            blocks_needed,
67            blocks_available,
68            repaired: false,
69            message,
70        });
71    }
72
73    match rust_par2::repair_from_verify(&file_set, dir, &verify_result) {
74        Ok(repair_result) => {
75            info!(
76                blocks_repaired = repair_result.blocks_repaired,
77                files_repaired = repair_result.files_repaired,
78                "Native PAR2 repair complete"
79            );
80            Ok(Par2Result {
81                success: repair_result.success,
82                blocks_needed,
83                blocks_available,
84                repaired: repair_result.success,
85                message: repair_result.message,
86            })
87        }
88        Err(e) => {
89            warn!(error = %e, "Native PAR2 repair failed");
90            Ok(Par2Result {
91                success: false,
92                blocks_needed,
93                blocks_available,
94                repaired: false,
95                message: format!("{e}"),
96            })
97        }
98    }
99}
100
101/// Async wrapper around `par2_repair_blocking`.
102pub async fn par2_repair(par2_file: &Path) -> anyhow::Result<Par2Result> {
103    let par2_file = par2_file.to_path_buf();
104    tokio::task::spawn_blocking(move || par2_repair_blocking(&par2_file)).await?
105}
106
107// ---------------------------------------------------------------------------
108// Tests
109// ---------------------------------------------------------------------------
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[tokio::test]
116    async fn test_repair_nonexistent_file() {
117        let result = par2_repair(Path::new("/nonexistent/file.par2")).await;
118        assert!(result.is_err() || !result.unwrap().success);
119    }
120
121    #[test]
122    fn test_repair_blocking_nonexistent() {
123        let result = par2_repair_blocking(Path::new("/no/such/file.par2"));
124        assert!(result.is_err());
125    }
126
127    #[test]
128    fn test_par2_result_fields() {
129        let result = Par2Result {
130            success: true,
131            blocks_needed: 0,
132            blocks_available: 10,
133            repaired: false,
134            message: "All files correct".to_string(),
135        };
136        assert!(result.success);
137        assert!(!result.repaired);
138        assert_eq!(result.blocks_needed, 0);
139        assert_eq!(result.blocks_available, 10);
140    }
141
142    #[test]
143    fn repair_succeeds_when_recovery_covers_damage() {
144        assert!(recovery_can_cover(10, 10));
145        assert!(recovery_can_cover(2, 671));
146    }
147
148    #[test]
149    fn repair_fails_when_recovery_is_insufficient() {
150        assert!(!recovery_can_cover(207, 71));
151        assert!(!recovery_can_cover(1, 0));
152    }
153}