Skip to main content

rust_par2/
packets.rs

1//! PAR2 binary packet parser.
2//!
3//! Parses PAR2 files according to the PAR 2.0 specification:
4//! <http://parchive.sourceforge.net/docs/specifications/parity-volume-spec/article-spec.html>
5//!
6//! Packet layout (all multi-byte fields are little-endian):
7//! ```text
8//! Offset  Size  Description
9//!   0       8   Magic: "PAR2\x00PKT"
10//!   8       8   Packet length (u64, includes header, must be multiple of 4)
11//!  16      16   MD5 hash of bytes 32..packet_end
12//!  32      16   Recovery Set ID
13//!  48      16   Packet Type
14//!  64       ?   Body (packet-type specific)
15//! ```
16
17use std::collections::HashMap;
18use std::io::{self, Read, Seek, SeekFrom};
19use std::path::Path;
20
21use md5::{Digest, Md5};
22use tracing::{debug, trace, warn};
23
24use crate::types::{Id16, Md5Hash, Par2File, Par2FileSet, SliceChecksum};
25
26/// PAR2 packet magic bytes.
27const PAR2_MAGIC: &[u8; 8] = b"PAR2\x00PKT";
28
29/// Public constants for use by the recovery module.
30pub const MAGIC: &[u8; 8] = PAR2_MAGIC;
31pub const HEADER_SIZE: usize = 64;
32
33/// Minimum packet length (header only, no body).
34const MIN_PACKET_LEN: u64 = 64;
35
36// Packet type identifiers (16 bytes each).
37const TYPE_MAIN: &[u8; 16] = b"PAR 2.0\x00Main\x00\x00\x00\x00";
38const TYPE_FILE_DESC: &[u8; 16] = b"PAR 2.0\x00FileDesc";
39const TYPE_IFSC: &[u8; 16] = b"PAR 2.0\x00IFSC\x00\x00\x00\x00";
40const TYPE_RECOVERY: &[u8; 16] = b"PAR 2.0\x00RecvSlic";
41const TYPE_CREATOR: &[u8; 16] = b"PAR 2.0\x00Creator\x00";
42
43/// Errors that can occur while parsing PAR2 files.
44#[derive(Debug, thiserror::Error)]
45pub enum ParseError {
46    #[error("I/O error: {0}")]
47    Io(#[from] io::Error),
48    #[error("no PAR2 packets found in file")]
49    NoPar2Packets,
50    #[error("missing Main packet — cannot determine slice size")]
51    NoMainPacket,
52}
53
54/// Intermediate storage during parsing (packets can arrive in any order).
55struct ParseState {
56    recovery_set_id: Option<Id16>,
57    slice_size: Option<u64>,
58    nr_files: Option<u32>,
59    file_order: Vec<Id16>,
60    /// FileDesc data keyed by File ID.
61    file_descs: HashMap<Id16, FileDescData>,
62    /// IFSC (slice checksum) data keyed by File ID.
63    ifsc_data: HashMap<Id16, Vec<SliceChecksum>>,
64    /// Recovery slice count.
65    recovery_count: u32,
66    /// Creator string.
67    creator: Option<String>,
68}
69
70struct FileDescData {
71    hash: Md5Hash,
72    hash_16k: Md5Hash,
73    size: u64,
74    filename: String,
75}
76
77/// Parse a PAR2 file and return the complete file set metadata.
78///
79/// This reads the entire PAR2 file (typically the index `.par2` file, not the
80/// large `.volNNN+NNN.par2` recovery volumes). For recovery volumes, only the
81/// header packets are read — the large recovery data is skipped.
82pub fn parse_par2_file(path: &Path) -> Result<Par2FileSet, ParseError> {
83    let file = std::fs::File::open(path)?;
84    let file_size = file.metadata()?.len();
85    let mut reader = io::BufReader::new(file);
86
87    parse_par2_reader(&mut reader, file_size)
88}
89
90/// Parse PAR2 packets from any `Read + Seek` source.
91pub fn parse_par2_reader<R: Read + Seek>(
92    reader: &mut R,
93    file_size: u64,
94) -> Result<Par2FileSet, ParseError> {
95    let mut state = ParseState {
96        recovery_set_id: None,
97        slice_size: None,
98        nr_files: None,
99        file_order: Vec::new(),
100        file_descs: HashMap::new(),
101        ifsc_data: HashMap::new(),
102        recovery_count: 0,
103        creator: None,
104    };
105
106    let mut magic_buf = [0u8; 8];
107    let mut packets_parsed = 0u32;
108
109    loop {
110        let pos = reader.stream_position()?;
111        if pos >= file_size {
112            break;
113        }
114
115        // Read magic
116        if reader.read_exact(&mut magic_buf).is_err() {
117            break;
118        }
119
120        if magic_buf != *PAR2_MAGIC {
121            // Not at a packet boundary — try to find the next one.
122            // This handles trailing garbage or alignment issues.
123            if let Some(next_pos) = scan_for_magic(reader, file_size)? {
124                reader.seek(SeekFrom::Start(next_pos))?;
125                continue;
126            }
127            break;
128        }
129
130        // Read packet length
131        let mut len_buf = [0u8; 8];
132        if reader.read_exact(&mut len_buf).is_err() {
133            break;
134        }
135        let packet_len = u64::from_le_bytes(len_buf);
136
137        // Validate length
138        if packet_len < MIN_PACKET_LEN || packet_len % 4 != 0 {
139            warn!(packet_len, pos, "invalid PAR2 packet length, skipping");
140            continue;
141        }
142
143        // Don't read absurdly large packets into memory (recovery slices
144        // can be many megabytes). We only need the type to count them.
145        let body_len = packet_len - 16; // everything after magic + length + md5
146        if body_len > 10 * 1024 * 1024 {
147            // Large packet — likely a recovery slice. Read just the type.
148            let mut md5_buf = [0u8; 16];
149            reader.read_exact(&mut md5_buf)?;
150
151            let mut type_header = [0u8; 32]; // recovery_set_id + type
152            reader.read_exact(&mut type_header)?;
153            let packet_type = &type_header[16..32];
154
155            if packet_type == TYPE_RECOVERY {
156                state.recovery_count += 1;
157                if state.recovery_set_id.is_none() {
158                    let mut id = [0u8; 16];
159                    id.copy_from_slice(&type_header[..16]);
160                    state.recovery_set_id = Some(id);
161                }
162            }
163
164            // Skip the rest
165            let remaining = packet_len - 64;
166            reader.seek(SeekFrom::Current(remaining as i64))?;
167            packets_parsed += 1;
168            continue;
169        }
170
171        // Read MD5 hash of packet body
172        let mut stored_md5 = [0u8; 16];
173        reader.read_exact(&mut stored_md5)?;
174
175        // Read the rest of the packet (recovery_set_id + type + body)
176        let data_len = (packet_len - 32) as usize;
177        let mut data = vec![0u8; data_len];
178        if reader.read_exact(&mut data).is_err() {
179            break;
180        }
181
182        // Verify packet MD5
183        let computed_md5: [u8; 16] = Md5::digest(&data).into();
184        if computed_md5 != stored_md5 {
185            warn!(pos, "PAR2 packet MD5 mismatch, skipping");
186            continue;
187        }
188
189        // Extract recovery set ID and packet type
190        let mut set_id = [0u8; 16];
191        set_id.copy_from_slice(&data[..16]);
192        if state.recovery_set_id.is_none() {
193            state.recovery_set_id = Some(set_id);
194        }
195
196        let packet_type = &data[16..32];
197
198        // Dispatch by type
199        if packet_type == TYPE_FILE_DESC {
200            parse_file_desc(&data, &mut state);
201        } else if packet_type == TYPE_IFSC {
202            parse_ifsc(&data, packet_len, &mut state);
203        } else if packet_type == TYPE_MAIN {
204            parse_main(&data, &mut state);
205        } else if packet_type == TYPE_RECOVERY {
206            state.recovery_count += 1;
207        } else if packet_type == TYPE_CREATOR {
208            parse_creator(&data, &mut state);
209        }
210
211        packets_parsed += 1;
212    }
213
214    if packets_parsed == 0 {
215        return Err(ParseError::NoPar2Packets);
216    }
217
218    let slice_size = state.slice_size.ok_or(ParseError::NoMainPacket)?;
219    let recovery_set_id = state.recovery_set_id.unwrap_or([0u8; 16]);
220
221    // Assemble Par2File entries by joining FileDesc + IFSC data on File ID
222    let mut files = HashMap::new();
223    for (file_id, desc) in state.file_descs {
224        let slices = state.ifsc_data.remove(&file_id).unwrap_or_default();
225        files.insert(
226            file_id,
227            Par2File {
228                file_id,
229                hash: desc.hash,
230                hash_16k: desc.hash_16k,
231                size: desc.size,
232                filename: desc.filename,
233                slices,
234            },
235        );
236    }
237
238    debug!(
239        files = files.len(),
240        recovery_blocks = state.recovery_count,
241        slice_size,
242        creator = state.creator.as_deref().unwrap_or("unknown"),
243        "PAR2 file parsed"
244    );
245
246    Ok(Par2FileSet {
247        recovery_set_id,
248        slice_size,
249        file_order: state.file_order,
250        files,
251        recovery_block_count: state.recovery_count,
252        creator: state.creator,
253    })
254}
255
256// ---------------------------------------------------------------------------
257// Packet body parsers
258// ---------------------------------------------------------------------------
259
260/// Parse a FileDesc packet body.
261///
262/// Layout (offsets relative to `data`, which starts at recovery_set_id):
263/// ```text
264///  0..16   Recovery Set ID (already extracted)
265/// 16..32   Packet Type (already matched)
266/// 32..48   File ID
267/// 48..64   Full-file MD5 hash
268/// 64..80   First-16K MD5 hash
269/// 80..88   File size (u64 LE)
270/// 88..     Filename (null-terminated, padded to multiple of 4)
271/// ```
272fn parse_file_desc(data: &[u8], state: &mut ParseState) {
273    if data.len() < 88 {
274        warn!("FileDesc packet too short ({} bytes)", data.len());
275        return;
276    }
277
278    let mut file_id = [0u8; 16];
279    file_id.copy_from_slice(&data[32..48]);
280
281    // Skip duplicates
282    if state.file_descs.contains_key(&file_id) {
283        return;
284    }
285
286    let mut hash = [0u8; 16];
287    hash.copy_from_slice(&data[48..64]);
288
289    let mut hash_16k = [0u8; 16];
290    hash_16k.copy_from_slice(&data[64..80]);
291
292    let size = u64::from_le_bytes(data[80..88].try_into().unwrap());
293
294    // Filename: everything after offset 88, strip null padding
295    let name_bytes = &data[88..];
296    let name_end = name_bytes
297        .iter()
298        .position(|&b| b == 0)
299        .unwrap_or(name_bytes.len());
300    let filename = String::from_utf8_lossy(&name_bytes[..name_end]).into_owned();
301
302    trace!(filename, size, "parsed FileDesc");
303
304    state.file_descs.insert(
305        file_id,
306        FileDescData {
307            hash,
308            hash_16k,
309            size,
310            filename,
311        },
312    );
313}
314
315/// Parse an IFSC (Input File Slice Checksum) packet.
316///
317/// Layout:
318/// ```text
319///  0..16   Recovery Set ID
320/// 16..32   Packet Type
321/// 32..48   File ID
322/// 48..     Pairs of (MD5[16] + CRC32[4]) for each slice
323/// ```
324fn parse_ifsc(data: &[u8], packet_len: u64, state: &mut ParseState) {
325    if data.len() < 48 {
326        warn!("IFSC packet too short ({} bytes)", data.len());
327        return;
328    }
329
330    let mut file_id = [0u8; 16];
331    file_id.copy_from_slice(&data[32..48]);
332
333    // Skip duplicates
334    if state.ifsc_data.contains_key(&file_id) {
335        return;
336    }
337
338    let body_len = (packet_len - 64) as usize; // body after 64-byte header
339    let checksum_data = &data[48..];
340    let num_slices = (body_len - 16) / 20; // subtract File ID, 20 bytes per slice
341
342    let mut slices = Vec::with_capacity(num_slices);
343    for i in 0..num_slices {
344        let offset = i * 20;
345        if offset + 20 > checksum_data.len() {
346            break;
347        }
348
349        let mut md5 = [0u8; 16];
350        md5.copy_from_slice(&checksum_data[offset..offset + 16]);
351        let crc32 = u32::from_le_bytes(checksum_data[offset + 16..offset + 20].try_into().unwrap());
352
353        slices.push(SliceChecksum { md5, crc32 });
354    }
355
356    trace!(slices = slices.len(), "parsed IFSC");
357
358    state.ifsc_data.insert(file_id, slices);
359}
360
361/// Parse the Main packet.
362///
363/// Layout:
364/// ```text
365///  0..16   Recovery Set ID
366/// 16..32   Packet Type
367/// 32..40   Slice size (u64 LE)
368/// 40..44   Number of files in recovery set (u32 LE)
369/// 44..     File IDs (16 bytes each)
370/// ```
371fn parse_main(data: &[u8], state: &mut ParseState) {
372    if data.len() < 44 {
373        warn!("Main packet too short ({} bytes)", data.len());
374        return;
375    }
376
377    let slice_size = u64::from_le_bytes(data[32..40].try_into().unwrap());
378    let nr_files = u32::from_le_bytes(data[40..44].try_into().unwrap());
379    let ids = &data[44..];
380    let count = (nr_files as usize).min(ids.len() / 16);
381    let mut file_order = Vec::with_capacity(count);
382    for i in 0..count {
383        let start = i * 16;
384        let mut id = [0u8; 16];
385        id.copy_from_slice(&ids[start..start + 16]);
386        file_order.push(id);
387    }
388
389    trace!(
390        slice_size,
391        nr_files,
392        main_file_ids = file_order.len(),
393        "parsed Main"
394    );
395
396    state.slice_size = Some(slice_size);
397    state.nr_files = Some(nr_files);
398    state.file_order = file_order;
399}
400
401/// Parse a Creator packet.
402fn parse_creator(data: &[u8], state: &mut ParseState) {
403    if data.len() <= 32 {
404        return;
405    }
406    let creator_bytes = &data[32..];
407    let end = creator_bytes
408        .iter()
409        .position(|&b| b == 0)
410        .unwrap_or(creator_bytes.len());
411    let creator = String::from_utf8_lossy(&creator_bytes[..end]).into_owned();
412    debug!(creator, "PAR2 creator");
413    state.creator = Some(creator);
414}
415
416// ---------------------------------------------------------------------------
417// Helpers
418// ---------------------------------------------------------------------------
419
420/// Scan forward to find the next PAR2_MAGIC occurrence.
421fn scan_for_magic<R: Read + Seek>(reader: &mut R, file_size: u64) -> io::Result<Option<u64>> {
422    let start = reader.stream_position()?;
423    // Read in chunks to find the magic
424    let mut buf = [0u8; 4096];
425    let mut search_pos = start;
426
427    while search_pos < file_size {
428        reader.seek(SeekFrom::Start(search_pos))?;
429        let n = reader.read(&mut buf)?;
430        if n < 8 {
431            return Ok(None);
432        }
433        for i in 0..n.saturating_sub(7) {
434            if &buf[i..i + 8] == PAR2_MAGIC {
435                return Ok(Some(search_pos + i as u64));
436            }
437        }
438        // Overlap by 7 to catch magic spanning chunk boundaries
439        search_pos += (n - 7) as u64;
440    }
441    Ok(None)
442}
443
444// ---------------------------------------------------------------------------
445// Tests
446// ---------------------------------------------------------------------------
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451    use std::io::Cursor;
452
453    fn build_packet(set_id: Id16, packet_type: &[u8; 16], body: &[u8]) -> Vec<u8> {
454        let mut data = Vec::with_capacity(32 + body.len());
455        data.extend_from_slice(&set_id);
456        data.extend_from_slice(packet_type);
457        data.extend_from_slice(body);
458
459        let mut packet = Vec::with_capacity(32 + data.len());
460        packet.extend_from_slice(PAR2_MAGIC);
461        packet.extend_from_slice(&((HEADER_SIZE + body.len()) as u64).to_le_bytes());
462        packet.extend_from_slice(&Md5::digest(&data));
463        packet.extend_from_slice(&data);
464        packet
465    }
466
467    /// Test parsing the real PAR2 file from SABnzbd test data.
468    #[test]
469    fn test_parse_par2test() {
470        let path = Path::new("/home/sprooty/sabnzbd/tests/data/par2repair/basic/par2test.par2");
471        if !path.exists() {
472            eprintln!("Skipping test: {path:?} not found");
473            return;
474        }
475
476        let set = parse_par2_file(path).unwrap();
477
478        // Should have 6 files
479        assert_eq!(set.files.len(), 6, "expected 6 files in par2 set");
480
481        // Slice size should be 100000 (0x186A0)
482        assert_eq!(set.slice_size, 100000, "expected slice_size = 100000");
483
484        // Creator should be QuickPar 0.9
485        assert_eq!(
486            set.creator.as_deref(),
487            Some("QuickPar 0.9"),
488            "expected creator = QuickPar 0.9"
489        );
490
491        // No recovery blocks in the index file
492        assert_eq!(set.recovery_block_count, 0);
493
494        // Check that all expected filenames are present
495        let filenames: Vec<&str> = set.files.values().map(|f| f.filename.as_str()).collect();
496        for i in 1..=6 {
497            let expected = format!("par2test.part{i}.rar");
498            assert!(
499                filenames.contains(&expected.as_str()),
500                "missing file: {expected}"
501            );
502        }
503
504        // Check file sizes
505        for f in set.files.values() {
506            if f.filename == "par2test.part6.rar" {
507                // Last part is smaller
508                assert!(f.size < 100000, "part6 should be smaller than slice_size");
509            } else {
510                assert_eq!(f.size, 102400, "{} should be 102400 bytes", f.filename);
511            }
512        }
513
514        // Each file should have IFSC slice data
515        for f in set.files.values() {
516            assert!(
517                !f.slices.is_empty(),
518                "{} should have slice checksums",
519                f.filename
520            );
521        }
522    }
523
524    /// Test parsing the basic_16k par2 file.
525    #[test]
526    fn test_parse_basic_16k() {
527        let path = Path::new("/home/sprooty/sabnzbd/tests/data/par2file/basic_16k.par2");
528        if !path.exists() {
529            eprintln!("Skipping test: {path:?} not found");
530            return;
531        }
532
533        let set = parse_par2_file(path).unwrap();
534        assert!(!set.files.is_empty(), "should parse at least one file");
535        assert!(set.slice_size > 0, "slice_size should be > 0");
536    }
537
538    /// Test that parsing a non-PAR2 file returns an error.
539    #[test]
540    fn test_parse_non_par2() {
541        let path =
542            Path::new("/home/sprooty/sabnzbd/tests/data/par2repair/basic/par2test.part2.rar");
543        if !path.exists() {
544            eprintln!("Skipping test: {path:?} not found");
545            return;
546        }
547
548        let result = parse_par2_file(path);
549        assert!(result.is_err(), "parsing a RAR file should fail");
550    }
551
552    /// Test parsing a recovery volume (should count recovery blocks).
553    #[test]
554    fn test_parse_recovery_volume() {
555        let path =
556            Path::new("/home/sprooty/sabnzbd/tests/data/par2repair/basic/par2test.vol0+1.par2");
557        if !path.exists() {
558            eprintln!("Skipping test: {path:?} not found");
559            return;
560        }
561
562        let set = parse_par2_file(path).unwrap();
563        assert!(
564            set.recovery_block_count >= 1,
565            "recovery volume should have at least 1 recovery block"
566        );
567    }
568
569    #[test]
570    fn test_parse_main_preserves_file_order() {
571        let set_id = [0xAB; 16];
572        let file_a = [0x10; 16];
573        let file_b = [0x01; 16];
574
575        let mut main_body = Vec::new();
576        main_body.extend_from_slice(&4096u64.to_le_bytes());
577        main_body.extend_from_slice(&2u32.to_le_bytes());
578        main_body.extend_from_slice(&file_a);
579        main_body.extend_from_slice(&file_b);
580
581        let packet = build_packet(set_id, TYPE_MAIN, &main_body);
582
583        let mut state = ParseState {
584            recovery_set_id: None,
585            slice_size: None,
586            nr_files: None,
587            file_order: Vec::new(),
588            file_descs: HashMap::new(),
589            ifsc_data: HashMap::new(),
590            recovery_count: 0,
591            creator: None,
592        };
593        parse_main(&packet[32..], &mut state);
594        assert_eq!(state.file_order, vec![file_a, file_b]);
595    }
596
597    #[test]
598    fn test_large_recovery_volume_counts_all_blocks() {
599        let set_id = [0x42; 16];
600        let file_id = [0x24; 16];
601        let mut bytes = Vec::new();
602
603        let mut main_body = Vec::new();
604        main_body.extend_from_slice(&524_288u64.to_le_bytes());
605        main_body.extend_from_slice(&1u32.to_le_bytes());
606        main_body.extend_from_slice(&file_id);
607        bytes.extend_from_slice(&build_packet(set_id, TYPE_MAIN, &main_body));
608
609        let mut file_desc_body = Vec::new();
610        file_desc_body.extend_from_slice(&file_id);
611        file_desc_body.extend_from_slice(&[0x11; 16]);
612        file_desc_body.extend_from_slice(&[0x22; 16]);
613        file_desc_body.extend_from_slice(&524_288u64.to_le_bytes());
614        file_desc_body.extend_from_slice(b"payload.bin\0");
615        while file_desc_body.len() % 4 != 0 {
616            file_desc_body.push(0);
617        }
618        bytes.extend_from_slice(&build_packet(set_id, TYPE_FILE_DESC, &file_desc_body));
619
620        let mut ifsc_body = Vec::new();
621        ifsc_body.extend_from_slice(&file_id);
622        ifsc_body.extend_from_slice(&[0x33; 16]);
623        ifsc_body.extend_from_slice(&0u32.to_le_bytes());
624        bytes.extend_from_slice(&build_packet(set_id, TYPE_IFSC, &ifsc_body));
625
626        for exp in 0..20u32 {
627            let mut rec_body = Vec::with_capacity(4 + 524_288);
628            rec_body.extend_from_slice(&exp.to_le_bytes());
629            rec_body.extend(std::iter::repeat_n(exp as u8, 524_288));
630            bytes.extend_from_slice(&build_packet(set_id, TYPE_RECOVERY, &rec_body));
631        }
632
633        let mut cursor = Cursor::new(bytes);
634        let file_size = cursor.get_ref().len() as u64;
635        let parsed = parse_par2_reader(&mut cursor, file_size).unwrap();
636        assert_eq!(parsed.recovery_block_count, 20);
637        assert_eq!(parsed.file_order, vec![file_id]);
638    }
639}