1use std::collections::{BTreeMap, HashMap};
2use std::path::Path;
3
4use tracing::{debug, warn};
5
6use crate::checksum;
7use crate::error::{Par2Error, Result};
8use crate::packet::budget::packet_retained_bytes;
9use crate::packet::{
10 MAX_RECOVERY_EXPONENT, Packet, PacketScanBudget, PacketScanLimits, PacketSink,
11 RecoverySliceData, scan_packets_bounded, scan_packets_from_path_bounded,
12};
13use crate::types::{FileId, MAX_FILES_PER_SET, RecoveryExponent, RecoverySetId, SliceChecksum};
14
15#[derive(Debug, Clone)]
17pub struct FileDescription {
18 pub file_id: FileId,
19 pub hash_full: [u8; 16],
20 pub hash_16k: [u8; 16],
21 pub length: u64,
22 pub par2_name: String,
23 pub filename: String,
24}
25
26#[derive(Debug, Clone)]
28pub struct RecoverySlice {
29 pub exponent: RecoveryExponent,
30 pub data: RecoverySliceData,
31}
32
33#[derive(Debug, Clone)]
38pub struct Par2FileSet {
39 pub recovery_set_id: RecoverySetId,
41 pub slice_size: u64,
43 pub recovery_file_ids: Vec<FileId>,
45 pub non_recovery_file_ids: Vec<FileId>,
47 pub files: HashMap<FileId, FileDescription>,
49 pub slice_checksums: HashMap<FileId, Vec<SliceChecksum>>,
51 pub recovery_slices: BTreeMap<RecoveryExponent, RecoverySlice>,
53 pub creator: Option<String>,
55}
56
57impl Par2FileSet {
58 pub fn from_files(par2_files: &[&[u8]]) -> Result<Self> {
65 Self::from_files_with_limits(par2_files, PacketScanLimits::default())
66 }
67
68 pub fn from_files_with_limits(par2_files: &[&[u8]], limits: PacketScanLimits) -> Result<Self> {
70 let budget = PacketScanBudget::new(limits);
71 let mut sink = BuilderSink::new(&budget);
72
73 for (i, data) in par2_files.iter().enumerate() {
74 debug!("scanning par2 file {} ({} bytes)", i, data.len());
75 scan_packets_bounded(data, 0, &budget, &mut sink)?;
76 }
77
78 sink.into_builder().build()
79 }
80
81 pub fn from_paths<P: AsRef<Path>>(par2_files: &[P]) -> Result<Self> {
84 Self::from_paths_with_limits(par2_files, PacketScanLimits::default())
85 }
86
87 pub fn from_paths_with_limits<P: AsRef<Path>>(
89 par2_files: &[P],
90 limits: PacketScanLimits,
91 ) -> Result<Self> {
92 let budget = PacketScanBudget::new(limits);
93 let mut sink = BuilderSink::new(&budget);
94
95 for (i, path) in par2_files.iter().enumerate() {
96 debug!("scanning par2 file {} ({})", i, path.as_ref().display());
97 scan_packets_from_path_bounded(path.as_ref(), &budget, &mut sink)?;
98 }
99
100 sink.into_builder().build()
101 }
102
103 pub fn from_files_with_diagnostics(par2_files: &[&[u8]]) -> Result<Par2ParseResult> {
111 let budget = PacketScanBudget::new(PacketScanLimits::default());
112 let mut builder = Par2FileSetBuilder::new();
113 let mut diagnostic = Par2Diagnostic::default();
114
115 for (i, data) in par2_files.iter().enumerate() {
116 debug!("scanning par2 file {} ({} bytes)", i, data.len());
117 let mut accepted = 0usize;
118 let mut sink = |packet: Packet, offset: u64, _set_id: RecoverySetId| -> Result<()> {
119 accepted += 1;
120 match builder.add_packet_budgeted(packet, offset, &budget) {
121 Ok(_) => {}
122 Err(
125 error @ (Par2Error::ResourceLimitExceeded { .. } | Par2Error::Cancelled),
126 ) => {
127 return Err(error);
128 }
129 Err(error) => {
130 diagnostic
131 .damaged_files
132 .push((i, format!("packet error at offset {offset}: {error}")));
133 diagnostic.skipped_packets += 1;
134 }
135 }
136 Ok(())
137 };
138 scan_packets_bounded(data, 0, &budget, &mut sink)?;
139
140 if accepted == 0 && !data.is_empty() {
141 diagnostic
142 .damaged_files
143 .push((i, "no valid packets found".to_string()));
144 diagnostic.skipped_packets += 1;
145 }
146 }
147
148 let file_set = builder.build()?;
149 Ok(Par2ParseResult {
150 file_set,
151 diagnostic,
152 })
153 }
154
155 pub fn from_packets(packets: Vec<Packet>) -> Result<Self> {
160 let budget = PacketScanBudget::new(PacketScanLimits::default());
161 let mut builder = Par2FileSetBuilder::new();
162 for packet in packets {
163 builder.add_packet_budgeted(packet, 0, &budget)?;
164 }
165 builder.build()
166 }
167
168 pub fn file_description(&self, file_id: &FileId) -> Option<&FileDescription> {
170 self.files.get(file_id)
171 }
172
173 pub fn file_checksums(&self, file_id: &FileId) -> Option<&[SliceChecksum]> {
175 self.slice_checksums.get(file_id).map(|v| v.as_slice())
176 }
177
178 pub fn expected_file_crc32(&self, file_id: FileId) -> Option<u32> {
186 let description = self.files.get(&file_id)?;
187 if self.slice_size == 0 {
188 return None;
189 }
190
191 if description.length == 0 {
195 return match self.slice_checksums.get(&file_id) {
196 Some(checksums) if !checksums.is_empty() => None,
197 _ => Some(checksum::crc32(&[])),
198 };
199 }
200
201 let expected_slice_count = description.length.div_ceil(self.slice_size);
202 if expected_slice_count > u32::MAX as u64 {
203 return None;
204 }
205 let expected_slice_count = usize::try_from(expected_slice_count).ok()?;
206 let checksums = self.slice_checksums.get(&file_id)?;
207 if checksums.len() != expected_slice_count {
208 return None;
209 }
210
211 let mut file_crc = None;
212 for (index, checksum) in checksums.iter().enumerate() {
213 let is_last = index + 1 == checksums.len();
214 let short_final_slice = is_last && description.length % self.slice_size != 0;
215 let slice_len = if short_final_slice {
216 description.length % self.slice_size
217 } else {
218 self.slice_size
219 };
220 let slice_crc = if short_final_slice {
221 let padding_len = self.slice_size.checked_sub(slice_len)?;
222 let padding_crc = checksum::crc32_padded(&[], padding_len);
223 checksum::crc32_uncombine(checksum.crc32, padding_crc, padding_len)
224 } else {
225 checksum.crc32
226 };
227
228 file_crc = Some(match file_crc {
229 Some(prefix_crc) => checksum::crc32_combine(prefix_crc, slice_crc, slice_len),
230 None => slice_crc,
231 });
232 }
233
234 file_crc
235 }
236
237 pub fn recovery_files(&self) -> Vec<&FileDescription> {
239 self.recovery_file_ids
240 .iter()
241 .filter_map(|id| self.files.get(id))
242 .collect()
243 }
244
245 pub fn recovery_block_count(&self) -> u32 {
252 self.recovery_slices.len() as u32
253 }
254
255 pub fn merge_packets(&mut self, packets: Vec<Packet>) -> Result<MergeResult> {
261 if packets.iter().any(|packet| {
262 matches!(
263 packet,
264 Packet::Main(main) if main.recovery_set_id != self.recovery_set_id
265 )
266 }) {
267 return Err(Par2Error::ConflictingRecoverySet);
268 }
269
270 let mut new_recovery_slices = 0u32;
271 let mut duplicates_ignored = 0u32;
272
273 for packet in packets {
274 match packet {
275 Packet::Main(main) => {
276 if main.recovery_set_id != self.recovery_set_id {
277 return Err(Par2Error::ConflictingRecoverySet);
278 }
279 duplicates_ignored += 1;
280 }
281 Packet::FileDescription(fd) => {
282 if let std::collections::hash_map::Entry::Vacant(e) =
283 self.files.entry(fd.file_id)
284 {
285 e.insert(FileDescription {
286 file_id: fd.file_id,
287 hash_full: fd.hash_full,
288 hash_16k: fd.hash_16k,
289 length: fd.file_length,
290 par2_name: fd.par2_name,
291 filename: fd.filename,
292 });
293 } else {
294 duplicates_ignored += 1;
295 }
296 }
297 Packet::InputFileSliceChecksum(ifsc) => {
298 if let std::collections::hash_map::Entry::Vacant(e) =
299 self.slice_checksums.entry(ifsc.file_id)
300 {
301 e.insert(ifsc.checksums);
302 } else {
303 duplicates_ignored += 1;
304 }
305 }
306 Packet::RecoverySlice(rs) => {
307 if let std::collections::btree_map::Entry::Vacant(e) =
308 self.recovery_slices.entry(rs.exponent)
309 {
310 e.insert(RecoverySlice {
311 exponent: rs.exponent,
312 data: rs.data,
313 });
314 new_recovery_slices += 1;
315 } else {
316 duplicates_ignored += 1;
317 }
318 }
319 Packet::Creator(c) => {
320 if self.creator.is_none() {
321 self.creator = Some(c.creator_id);
322 } else {
323 duplicates_ignored += 1;
324 }
325 }
326 Packet::Unknown { .. } => {}
327 }
328 }
329
330 Ok(MergeResult {
331 new_recovery_slices,
332 duplicates_ignored,
333 })
334 }
335
336 pub fn slice_count_for_file(&self, file_length: u64) -> u32 {
343 if file_length == 0 || self.slice_size == 0 {
344 return 0;
345 }
346 let count = file_length.div_ceil(self.slice_size);
347 u32::try_from(count).unwrap_or(u32::MAX)
349 }
350}
351
352#[derive(Debug, Clone)]
354pub struct MergeResult {
355 pub new_recovery_slices: u32,
357 pub duplicates_ignored: u32,
359}
360
361#[derive(Debug, Clone, Default)]
363pub struct Par2Diagnostic {
364 pub damaged_files: Vec<(usize, String)>,
366 pub skipped_packets: u32,
368}
369
370#[derive(Debug)]
372pub struct Par2ParseResult {
373 pub file_set: Par2FileSet,
374 pub diagnostic: Par2Diagnostic,
375}
376
377struct BuilderSink<'a> {
384 budget: &'a PacketScanBudget,
385 builder: Par2FileSetBuilder,
386}
387
388impl<'a> BuilderSink<'a> {
389 fn new(budget: &'a PacketScanBudget) -> Self {
390 Self {
391 budget,
392 builder: Par2FileSetBuilder::new(),
393 }
394 }
395
396 fn into_builder(self) -> Par2FileSetBuilder {
397 self.builder
398 }
399}
400
401impl PacketSink for BuilderSink<'_> {
402 fn accept(
403 &mut self,
404 packet: Packet,
405 offset: u64,
406 _recovery_set_id: RecoverySetId,
407 ) -> Result<()> {
408 self.builder
409 .add_packet_budgeted(packet, offset, self.budget)?;
410 Ok(())
411 }
412}
413
414#[derive(Debug, Clone, Copy, PartialEq, Eq)]
420pub(crate) enum PacketAdmission {
421 Retained,
423 Duplicate,
425 Rejected,
429}
430
431pub(crate) struct Par2FileSetBuilder {
436 main_packet: Option<crate::packet::MainPacket>,
437 files: HashMap<FileId, FileDescription>,
438 slice_checksums: HashMap<FileId, Vec<SliceChecksum>>,
439 recovery_slices: BTreeMap<RecoveryExponent, RecoverySlice>,
440 creator: Option<String>,
441}
442
443impl Par2FileSetBuilder {
444 pub(crate) fn new() -> Self {
445 Self {
446 main_packet: None,
447 files: HashMap::new(),
448 slice_checksums: HashMap::new(),
449 recovery_slices: BTreeMap::new(),
450 creator: None,
451 }
452 }
453
454 pub(crate) fn would_duplicate(&self, packet: &Packet) -> bool {
459 match packet {
460 Packet::Main(_) => self.main_packet.is_some(),
461 Packet::FileDescription(desc) => self.files.contains_key(&desc.file_id),
462 Packet::InputFileSliceChecksum(ifsc) => {
463 self.slice_checksums.contains_key(&ifsc.file_id)
464 }
465 Packet::RecoverySlice(slice) => self.recovery_slices.contains_key(&slice.exponent),
466 Packet::Creator(_) => self.creator.is_some(),
467 Packet::Unknown { .. } => false,
468 }
469 }
470
471 pub(crate) fn would_retain(&self, packet: &Packet) -> bool {
479 match packet {
480 Packet::Main(_) => self.main_packet.is_none(),
481 Packet::FileDescription(desc) => {
482 !self.files.contains_key(&desc.file_id) && self.files.len() < MAX_FILES_PER_SET
483 }
484 Packet::InputFileSliceChecksum(ifsc) => {
485 !self.slice_checksums.contains_key(&ifsc.file_id)
486 && self.slice_checksums.len() < MAX_FILES_PER_SET
487 }
488 Packet::RecoverySlice(slice) => {
489 slice.exponent <= MAX_RECOVERY_EXPONENT
490 && !self.recovery_slices.contains_key(&slice.exponent)
491 }
492 Packet::Creator(_) => self.creator.is_none(),
493 Packet::Unknown { .. } => false,
494 }
495 }
496
497 pub(crate) fn add_packet_budgeted(
502 &mut self,
503 packet: Packet,
504 offset: u64,
505 budget: &PacketScanBudget,
506 ) -> Result<PacketAdmission> {
507 if !self.would_retain(&packet) {
508 return self.add_packet(packet, offset);
511 }
512 let bytes = packet_retained_bytes(&packet);
513 budget.charge_retained(bytes)?;
514 let admission = self.add_packet(packet, offset)?;
515 if admission != PacketAdmission::Retained {
516 budget.release_retained(bytes);
517 }
518 Ok(admission)
519 }
520
521 pub(crate) fn add_packet(&mut self, packet: Packet, _offset: u64) -> Result<PacketAdmission> {
522 Ok(match packet {
523 Packet::Main(main) => {
524 if let Some(existing) = &self.main_packet {
525 if existing.recovery_set_id != main.recovery_set_id {
526 return Err(Par2Error::ConflictingRecoverySet);
527 }
528 debug!("duplicate main packet (same recovery set ID), ignoring");
530 PacketAdmission::Duplicate
531 } else {
532 self.main_packet = Some(main);
533 PacketAdmission::Retained
534 }
535 }
536 Packet::FileDescription(fd) => {
537 let file_id = fd.file_id;
538 if self.files.contains_key(&file_id) {
539 PacketAdmission::Duplicate
540 } else if self.files.len() >= MAX_FILES_PER_SET {
541 warn!(
545 "discarding file description beyond the {MAX_FILES_PER_SET}-file set limit"
546 );
547 PacketAdmission::Rejected
548 } else {
549 self.files.insert(
550 file_id,
551 FileDescription {
552 file_id: fd.file_id,
553 hash_full: fd.hash_full,
554 hash_16k: fd.hash_16k,
555 length: fd.file_length,
556 par2_name: fd.par2_name,
557 filename: fd.filename,
558 },
559 );
560 PacketAdmission::Retained
561 }
562 }
563 Packet::InputFileSliceChecksum(ifsc) => {
564 if self.slice_checksums.contains_key(&ifsc.file_id) {
566 PacketAdmission::Duplicate
567 } else if self.slice_checksums.len() >= MAX_FILES_PER_SET {
568 warn!("discarding IFSC packet beyond the {MAX_FILES_PER_SET}-file set limit");
569 PacketAdmission::Rejected
570 } else {
571 self.slice_checksums.insert(ifsc.file_id, ifsc.checksums);
572 PacketAdmission::Retained
573 }
574 }
575 Packet::RecoverySlice(rs) => {
576 if rs.exponent > MAX_RECOVERY_EXPONENT {
577 warn!(
581 exponent = rs.exponent,
582 "discarding recovery block outside the usable exponent domain"
583 );
584 PacketAdmission::Rejected
585 } else {
586 match self.recovery_slices.entry(rs.exponent) {
587 std::collections::btree_map::Entry::Occupied(_) => {
588 PacketAdmission::Duplicate
589 }
590 std::collections::btree_map::Entry::Vacant(slot) => {
591 slot.insert(RecoverySlice {
592 exponent: rs.exponent,
593 data: rs.data,
594 });
595 PacketAdmission::Retained
596 }
597 }
598 }
599 }
600 Packet::Creator(c) => {
601 if self.creator.is_none() {
602 self.creator = Some(c.creator_id);
603 PacketAdmission::Retained
604 } else {
605 PacketAdmission::Duplicate
606 }
607 }
608 Packet::Unknown { packet_type, .. } => {
609 warn!("ignoring unknown packet type: {packet_type:02x?}");
610 PacketAdmission::Rejected
611 }
612 })
613 }
614
615 pub(crate) fn build(self) -> Result<Par2FileSet> {
616 let main = self.main_packet.ok_or(Par2Error::NoMainPacket)?;
617
618 let mut recovery_slices = self.recovery_slices;
622 let slice_size = main.slice_size;
623
624 recovery_slices.retain(|exp, rs| {
625 let data_len = rs.data.len() as u64;
626 if data_len != slice_size {
627 warn!(
628 "recovery block exponent {exp}: data length {data_len} does not equal slice_size {slice_size}, discarding"
629 );
630 return false;
631 }
632 true
633 });
634
635 let mut slice_checksums = self.slice_checksums;
636 slice_checksums.retain(|file_id, checksums| {
637 let Some(desc) = self.files.get(file_id) else {
638 warn!("IFSC packet for unknown file {file_id}, discarding");
639 return false;
640 };
641 let expected = if desc.length == 0 {
642 0
643 } else {
644 desc.length.div_ceil(slice_size) as usize
645 };
646 if checksums.len() != expected {
647 warn!(
648 file = %desc.filename,
649 actual = checksums.len(),
650 expected,
651 "IFSC entry count does not match file block count, discarding"
652 );
653 return false;
654 }
655 true
656 });
657
658 Ok(Par2FileSet {
659 recovery_set_id: main.recovery_set_id,
660 slice_size,
661 recovery_file_ids: main.recovery_file_ids,
662 non_recovery_file_ids: main.non_recovery_file_ids,
663 files: self.files,
664 slice_checksums,
665 recovery_slices,
666 creator: self.creator,
667 })
668 }
669}
670
671#[cfg(test)]
672mod tests {
673 use super::*;
674 use crate::checksum;
675 use crate::packet::header;
676 use md5::{Digest, Md5};
677 use tempfile::tempdir;
678
679 fn make_full_packet(packet_type: &[u8; 16], body: &[u8], recovery_set_id: [u8; 16]) -> Vec<u8> {
681 let length = (header::HEADER_SIZE + body.len()) as u64;
682
683 let mut hash_input = Vec::new();
684 hash_input.extend_from_slice(&recovery_set_id);
685 hash_input.extend_from_slice(packet_type);
686 hash_input.extend_from_slice(body);
687
688 let packet_hash: [u8; 16] = Md5::digest(&hash_input).into();
689
690 let mut data = Vec::new();
691 data.extend_from_slice(header::MAGIC);
692 data.extend_from_slice(&length.to_le_bytes());
693 data.extend_from_slice(&packet_hash);
694 data.extend_from_slice(&recovery_set_id);
695 data.extend_from_slice(packet_type);
696 data.extend_from_slice(body);
697 data
698 }
699
700 fn make_main_body(slice_size: u64, file_ids: &[[u8; 16]]) -> Vec<u8> {
701 let mut body = Vec::new();
702 body.extend_from_slice(&slice_size.to_le_bytes());
703 body.extend_from_slice(&(file_ids.len() as u32).to_le_bytes());
704 for id in file_ids {
705 body.extend_from_slice(id);
706 }
707 body
708 }
709
710 fn make_file_desc_body(
711 file_id: [u8; 16],
712 hash_full: [u8; 16],
713 hash_16k: [u8; 16],
714 file_length: u64,
715 filename: &str,
716 ) -> Vec<u8> {
717 let mut body = Vec::new();
718 body.extend_from_slice(&file_id);
719 body.extend_from_slice(&hash_full);
720 body.extend_from_slice(&hash_16k);
721 body.extend_from_slice(&file_length.to_le_bytes());
722 body.extend_from_slice(filename.as_bytes());
723 while body.len() % 4 != 0 {
725 body.push(0);
726 }
727 body
728 }
729
730 fn make_ifsc_body(file_id: [u8; 16], checksums: &[(u32, [u8; 16])]) -> Vec<u8> {
731 let mut body = Vec::new();
732 body.extend_from_slice(&file_id);
733 for &(crc, md5) in checksums {
734 body.extend_from_slice(&md5);
736 body.extend_from_slice(&crc.to_le_bytes());
737 }
738 body
739 }
740
741 fn compute_rsid(main_body: &[u8]) -> [u8; 16] {
743 Md5::digest(main_body).into()
744 }
745
746 fn crc_test_set(
747 slice_size: u64,
748 length: u64,
749 checksums: Vec<SliceChecksum>,
750 ) -> (Par2FileSet, FileId) {
751 let file_id = FileId::from_bytes([0x7A; 16]);
752 let description = FileDescription {
753 file_id,
754 hash_full: [0; 16],
755 hash_16k: [0; 16],
756 length,
757 par2_name: "crc-test.bin".to_string(),
758 filename: "crc-test.bin".to_string(),
759 };
760 let set = Par2FileSet {
761 recovery_set_id: RecoverySetId::from_bytes([0; 16]),
762 slice_size,
763 recovery_file_ids: vec![file_id],
764 non_recovery_file_ids: Vec::new(),
765 files: std::collections::HashMap::from([(file_id, description)]),
766 slice_checksums: std::collections::HashMap::from([(file_id, checksums)]),
767 recovery_slices: std::collections::BTreeMap::new(),
768 creator: None,
769 };
770 (set, file_id)
771 }
772
773 #[test]
774 fn build_par2_set_from_single_file() {
775 let file_id_a = [0x01; 16];
776 let main_body = make_main_body(4096, &[file_id_a]);
777 let rsid = compute_rsid(&main_body);
778
779 let fd_body = make_file_desc_body(file_id_a, [0xAA; 16], [0xAA; 16], 8192, "test.bin");
780 let ifsc_body = make_ifsc_body(file_id_a, &[(0x1234, [0xCC; 16]), (0x5678, [0xDD; 16])]);
781 let creator_body = b"TestApp\x00";
782
783 let mut stream = Vec::new();
784 stream.extend_from_slice(&make_full_packet(header::TYPE_MAIN, &main_body, rsid));
785 stream.extend_from_slice(&make_full_packet(header::TYPE_FILE_DESC, &fd_body, rsid));
786 stream.extend_from_slice(&make_full_packet(header::TYPE_IFSC, &ifsc_body, rsid));
787 stream.extend_from_slice(&make_full_packet(header::TYPE_CREATOR, creator_body, rsid));
788
789 let set = Par2FileSet::from_files(&[&stream]).unwrap();
790
791 assert_eq!(set.slice_size, 4096);
792 assert_eq!(set.recovery_file_ids.len(), 1);
793 assert_eq!(*set.recovery_file_ids[0].as_bytes(), file_id_a);
794
795 let fd = set
796 .file_description(&FileId::from_bytes(file_id_a))
797 .unwrap();
798 assert_eq!(fd.filename, "test.bin");
799 assert_eq!(fd.length, 8192);
800
801 let checksums = set.file_checksums(&FileId::from_bytes(file_id_a)).unwrap();
802 assert_eq!(checksums.len(), 2);
803 assert_eq!(checksums[0].crc32, 0x1234);
804
805 assert_eq!(set.creator.as_deref(), Some("TestApp"));
806 assert_eq!(set.recovery_block_count(), 0);
807 }
808
809 #[test]
810 fn build_par2_set_from_multiple_files() {
811 let file_id_a = [0x01; 16];
812 let main_body = make_main_body(1024, &[file_id_a]);
813 let rsid = compute_rsid(&main_body);
814
815 let mut file1 = Vec::new();
817 file1.extend_from_slice(&make_full_packet(header::TYPE_MAIN, &main_body, rsid));
818 let fd_body = make_file_desc_body(file_id_a, [0; 16], [0; 16], 2048, "data.bin");
819 file1.extend_from_slice(&make_full_packet(header::TYPE_FILE_DESC, &fd_body, rsid));
820
821 let mut recovery_body = Vec::new();
823 recovery_body.extend_from_slice(&0u32.to_le_bytes()); recovery_body.extend_from_slice(&[0xAB; 1024]); let mut file2 = Vec::new();
826 file2.extend_from_slice(&make_full_packet(header::TYPE_MAIN, &main_body, rsid));
827 file2.extend_from_slice(&make_full_packet(
828 header::TYPE_RECOVERY,
829 &recovery_body,
830 rsid,
831 ));
832
833 let set = Par2FileSet::from_files(&[&file1[..], &file2[..]]).unwrap();
834 assert_eq!(set.files.len(), 1);
835 assert_eq!(set.recovery_block_count(), 1);
836 assert!(set.recovery_slices.contains_key(&0));
837 }
838
839 #[test]
840 fn build_par2_set_from_paths_keeps_recovery_file_backed() {
841 let file_id_a = [0x01; 16];
842 let main_body = make_main_body(1024, &[file_id_a]);
843 let rsid = compute_rsid(&main_body);
844
845 let mut recovery_body = Vec::new();
846 recovery_body.extend_from_slice(&0u32.to_le_bytes());
847 recovery_body.extend_from_slice(&[0xAB; 1024]);
848
849 let mut stream = Vec::new();
850 stream.extend_from_slice(&make_full_packet(header::TYPE_MAIN, &main_body, rsid));
851 stream.extend_from_slice(&make_full_packet(
852 header::TYPE_RECOVERY,
853 &recovery_body,
854 rsid,
855 ));
856
857 let dir = tempdir().unwrap();
858 let path = dir.path().join("sample.par2");
859 std::fs::write(&path, &stream).unwrap();
860
861 let set = Par2FileSet::from_paths(&[path]).unwrap();
862 let recovery = set.recovery_slices.get(&0).unwrap();
863 assert!(recovery.data.as_bytes().is_none());
864
865 let mut head = vec![0u8; 16];
866 recovery.data.read_range_padded(0, &mut head).unwrap();
867 assert_eq!(head, vec![0xAB; 16]);
868 }
869
870 #[test]
871 fn no_main_packet_error() {
872 let rsid = [0; 16];
873 let creator_body = b"test";
874 let stream = make_full_packet(header::TYPE_CREATOR, creator_body, rsid);
875
876 let err = Par2FileSet::from_files(&[&stream]).unwrap_err();
877 assert!(matches!(err, Par2Error::NoMainPacket));
878 }
879
880 #[test]
881 fn conflicting_recovery_set_error() {
882 let main_body_1 = make_main_body(1024, &[]);
883 let rsid_1 = compute_rsid(&main_body_1);
884 let main_body_2 = make_main_body(2048, &[]);
885 let rsid_2 = compute_rsid(&main_body_2);
886
887 let mut stream = Vec::new();
888 stream.extend_from_slice(&make_full_packet(header::TYPE_MAIN, &main_body_1, rsid_1));
889 stream.extend_from_slice(&make_full_packet(header::TYPE_MAIN, &main_body_2, rsid_2));
890
891 let err = Par2FileSet::from_files(&[&stream]).unwrap_err();
892 assert!(matches!(err, Par2Error::ConflictingRecoverySet));
893 }
894
895 #[test]
896 fn slice_count_calculation() {
897 let main_body = make_main_body(1000, &[]);
898 let rsid = compute_rsid(&main_body);
899 let stream = make_full_packet(header::TYPE_MAIN, &main_body, rsid);
900 let set = Par2FileSet::from_files(&[&stream]).unwrap();
901
902 assert_eq!(set.slice_count_for_file(0), 0);
903 assert_eq!(set.slice_count_for_file(1), 1);
904 assert_eq!(set.slice_count_for_file(999), 1);
905 assert_eq!(set.slice_count_for_file(1000), 1);
906 assert_eq!(set.slice_count_for_file(1001), 2);
907 assert_eq!(set.slice_count_for_file(3000), 3);
908 }
909
910 #[test]
911 fn expected_file_crc32_handles_empty_and_exact_slices() {
912 let (empty_set, empty_id) = crc_test_set(4, 0, Vec::new());
913 assert_eq!(
914 empty_set.expected_file_crc32(empty_id),
915 Some(checksum::crc32(&[]))
916 );
917
918 let (mut empty_without_ifsc, empty_id) = crc_test_set(4, 0, Vec::new());
919 empty_without_ifsc.slice_checksums.clear();
920 assert_eq!(
921 empty_without_ifsc.expected_file_crc32(empty_id),
922 Some(checksum::crc32(&[]))
923 );
924
925 let data = b"exactly-four-bytes";
926 let (set, file_id) = crc_test_set(
927 6,
928 data.len() as u64,
929 vec![
930 SliceChecksum {
931 crc32: checksum::crc32(&data[..6]),
932 md5: [0; 16],
933 },
934 SliceChecksum {
935 crc32: checksum::crc32(&data[6..12]),
936 md5: [0; 16],
937 },
938 SliceChecksum {
939 crc32: checksum::crc32(&data[12..]),
940 md5: [0; 16],
941 },
942 ],
943 );
944 assert_eq!(
945 set.expected_file_crc32(file_id),
946 Some(checksum::crc32(data))
947 );
948 }
949
950 #[test]
951 fn expected_file_crc32_unpads_short_final_slice_before_combining() {
952 let data = b"abcdefghij";
953 let (set, file_id) = crc_test_set(
954 4,
955 data.len() as u64,
956 vec![
957 SliceChecksum {
958 crc32: checksum::crc32(&data[..4]),
959 md5: [0; 16],
960 },
961 SliceChecksum {
962 crc32: checksum::crc32(&data[4..8]),
963 md5: [0; 16],
964 },
965 SliceChecksum {
966 crc32: checksum::crc32_padded(&data[8..], 4),
967 md5: [0; 16],
968 },
969 ],
970 );
971
972 assert_eq!(
973 set.expected_file_crc32(file_id),
974 Some(checksum::crc32(data))
975 );
976 }
977
978 #[test]
979 fn expected_file_crc32_matches_randomized_direct_file_crc32() {
980 let mut seed = 0xD1CE_BAAD_F00D_CAFEu64;
981 for case in 0..128usize {
982 seed ^= seed << 13;
983 seed ^= seed >> 7;
984 seed ^= seed << 17;
985 let slice_size = 1 + (seed as usize % 257);
986 let length = 1 + ((seed >> 16) as usize % 4096) + case;
987 let mut data = vec![0u8; length];
988 for byte in &mut data {
989 seed ^= seed << 13;
990 seed ^= seed >> 7;
991 seed ^= seed << 17;
992 *byte = seed as u8;
993 }
994 let checksums = data
995 .chunks(slice_size)
996 .map(|slice| {
997 let mut state = checksum::SliceChecksumState::new();
998 state.update(slice);
999 let (crc32, md5) = state.finalize(Some(slice_size as u64));
1000 SliceChecksum { crc32, md5 }
1001 })
1002 .collect();
1003 let (set, file_id) = crc_test_set(slice_size as u64, length as u64, checksums);
1004
1005 assert_eq!(
1006 set.expected_file_crc32(file_id),
1007 Some(checksum::crc32(&data)),
1008 "case={case} slice_size={slice_size} length={length}"
1009 );
1010 }
1011 }
1012
1013 #[test]
1014 fn expected_file_crc32_rejects_absent_or_inconsistent_metadata() {
1015 let data = b"five!";
1016 let checksums = vec![SliceChecksum {
1017 crc32: checksum::crc32_padded(data, 4),
1018 md5: [0; 16],
1019 }];
1020 let (mut set, file_id) = crc_test_set(4, data.len() as u64, checksums);
1021 assert_eq!(set.expected_file_crc32(file_id), None);
1022
1023 set.slice_checksums.clear();
1024 assert_eq!(set.expected_file_crc32(file_id), None);
1025
1026 let (mut no_description, file_id) = crc_test_set(4, 0, Vec::new());
1027 no_description.files.clear();
1028 assert_eq!(no_description.expected_file_crc32(file_id), None);
1029
1030 let (zero_slice_size, file_id) = crc_test_set(0, 0, Vec::new());
1031 assert_eq!(zero_slice_size.expected_file_crc32(file_id), None);
1032 }
1033
1034 #[test]
1035 fn merge_packets_adds_recovery() {
1036 let file_id_a = [0x01; 16];
1037 let main_body = make_main_body(1024, &[file_id_a]);
1038 let rsid = compute_rsid(&main_body);
1039
1040 let mut file1 = Vec::new();
1041 file1.extend_from_slice(&make_full_packet(header::TYPE_MAIN, &main_body, rsid));
1042 let fd_body = make_file_desc_body(file_id_a, [0; 16], [0; 16], 2048, "data.bin");
1043 file1.extend_from_slice(&make_full_packet(header::TYPE_FILE_DESC, &fd_body, rsid));
1044
1045 let mut set = Par2FileSet::from_files(&[&file1[..]]).unwrap();
1046 assert_eq!(set.recovery_block_count(), 0);
1047
1048 let mut recovery_body = Vec::new();
1050 recovery_body.extend_from_slice(&0u32.to_le_bytes()); recovery_body.extend_from_slice(&[0xAB; 1024]);
1052 let mut file2 = Vec::new();
1053 file2.extend_from_slice(&make_full_packet(header::TYPE_MAIN, &main_body, rsid));
1054 file2.extend_from_slice(&make_full_packet(
1055 header::TYPE_RECOVERY,
1056 &recovery_body,
1057 rsid,
1058 ));
1059
1060 let packets: Vec<_> = crate::packet::scan_packets(&file2, 0)
1061 .unwrap()
1062 .into_iter()
1063 .map(|(p, _)| p)
1064 .collect();
1065
1066 let result = set.merge_packets(packets).unwrap();
1067 assert_eq!(result.new_recovery_slices, 1);
1068 assert_eq!(result.duplicates_ignored, 1); assert_eq!(set.recovery_block_count(), 1);
1070 }
1071
1072 #[test]
1073 fn merge_packets_rejects_conflicting_rsid() {
1074 let file_id_a = [0x01; 16];
1075 let main_body = make_main_body(1024, &[file_id_a]);
1076 let rsid = compute_rsid(&main_body);
1077
1078 let stream = make_full_packet(header::TYPE_MAIN, &main_body, rsid);
1079 let mut set = Par2FileSet::from_files(&[&stream[..]]).unwrap();
1080
1081 let other_main_body = make_main_body(2048, &[file_id_a]);
1083 let other_rsid = compute_rsid(&other_main_body);
1084 let mut recovery_body = Vec::new();
1085 recovery_body.extend_from_slice(&0u32.to_le_bytes());
1086 recovery_body.extend_from_slice(&[0xAB; 2048]);
1087 let mut other_stream = make_full_packet(header::TYPE_RECOVERY, &recovery_body, other_rsid);
1088 other_stream.extend_from_slice(&make_full_packet(
1089 header::TYPE_MAIN,
1090 &other_main_body,
1091 other_rsid,
1092 ));
1093
1094 let packets: Vec<_> = crate::packet::scan_packets(&other_stream, 0)
1095 .unwrap()
1096 .into_iter()
1097 .map(|(p, _)| p)
1098 .collect();
1099
1100 let err = set.merge_packets(packets).unwrap_err();
1101 assert!(matches!(err, Par2Error::ConflictingRecoverySet));
1102 assert_eq!(set.recovery_block_count(), 0);
1103 }
1104
1105 #[test]
1106 fn merge_packets_deduplicates() {
1107 let file_id_a = [0x01; 16];
1108 let main_body = make_main_body(1024, &[file_id_a]);
1109 let rsid = compute_rsid(&main_body);
1110
1111 let mut recovery_body = Vec::new();
1112 recovery_body.extend_from_slice(&0u32.to_le_bytes());
1113 recovery_body.extend_from_slice(&[0xAB; 1024]);
1114
1115 let mut stream = Vec::new();
1116 stream.extend_from_slice(&make_full_packet(header::TYPE_MAIN, &main_body, rsid));
1117 stream.extend_from_slice(&make_full_packet(
1118 header::TYPE_RECOVERY,
1119 &recovery_body,
1120 rsid,
1121 ));
1122
1123 let mut set = Par2FileSet::from_files(&[&stream[..]]).unwrap();
1124 assert_eq!(set.recovery_block_count(), 1);
1125
1126 let packets: Vec<_> = crate::packet::scan_packets(&stream, 0)
1128 .unwrap()
1129 .into_iter()
1130 .map(|(p, _)| p)
1131 .collect();
1132
1133 let result = set.merge_packets(packets).unwrap();
1134 assert_eq!(result.new_recovery_slices, 0);
1135 assert_eq!(result.duplicates_ignored, 2); assert_eq!(set.recovery_block_count(), 1);
1137 }
1138
1139 #[test]
1140 fn duplicate_main_same_rsid_accepted() {
1141 let main_body = make_main_body(4096, &[]);
1142 let rsid = compute_rsid(&main_body);
1143
1144 let mut stream = Vec::new();
1145 stream.extend_from_slice(&make_full_packet(header::TYPE_MAIN, &main_body, rsid));
1146 stream.extend_from_slice(&make_full_packet(header::TYPE_MAIN, &main_body, rsid));
1147
1148 let set = Par2FileSet::from_files(&[&stream]).unwrap();
1150 assert_eq!(set.slice_size, 4096);
1151 }
1152
1153 fn make_recovery_body(exponent: u32, payload: &[u8]) -> Vec<u8> {
1154 let mut body = Vec::with_capacity(4 + payload.len());
1155 body.extend_from_slice(&exponent.to_le_bytes());
1156 body.extend_from_slice(payload);
1157 body
1158 }
1159
1160 #[test]
1163 fn recovery_exponents_outside_the_usable_domain_are_discarded() {
1164 let main_body = make_main_body(4, &[]);
1165 let rsid = compute_rsid(&main_body);
1166
1167 let mut stream = make_full_packet(header::TYPE_MAIN, &main_body, rsid);
1168 for exponent in [
1169 0u32,
1170 MAX_RECOVERY_EXPONENT,
1171 MAX_RECOVERY_EXPONENT + 1,
1172 u32::MAX,
1173 ] {
1174 stream.extend_from_slice(&make_full_packet(
1175 header::TYPE_RECOVERY,
1176 &make_recovery_body(exponent, &[0xAB; 4]),
1177 rsid,
1178 ));
1179 }
1180
1181 let set = Par2FileSet::from_files(&[&stream]).unwrap();
1182 assert_eq!(set.recovery_block_count(), 2);
1183 assert!(set.recovery_slices.contains_key(&0));
1184 assert!(set.recovery_slices.contains_key(&MAX_RECOVERY_EXPONENT));
1185 assert!(
1186 !set.recovery_slices
1187 .contains_key(&(MAX_RECOVERY_EXPONENT + 1))
1188 );
1189 }
1190
1191 #[test]
1195 fn duplicate_packets_do_not_consume_the_retained_packet_budget() {
1196 let file_id = [0x31; 16];
1197 let main_body = make_main_body(4, &[file_id]);
1198 let rsid = compute_rsid(&main_body);
1199 let fd = make_full_packet(
1200 header::TYPE_FILE_DESC,
1201 &make_file_desc_body(file_id, [0xAA; 16], [0xAA; 16], 4, "dup.bin"),
1202 rsid,
1203 );
1204 let ifsc = make_full_packet(
1205 header::TYPE_IFSC,
1206 &make_ifsc_body(file_id, &[(0x1234, [0xCC; 16])]),
1207 rsid,
1208 );
1209
1210 let mut stream = make_full_packet(header::TYPE_MAIN, &main_body, rsid);
1211 for _ in 0..2_000 {
1212 stream.extend_from_slice(&fd);
1213 stream.extend_from_slice(&ifsc);
1214 }
1215
1216 let limits = PacketScanLimits::default().with_max_retained_packets(3);
1218 let set = Par2FileSet::from_files_with_limits(&[&stream], limits).unwrap();
1219 assert_eq!(set.files.len(), 1);
1220 assert_eq!(set.slice_checksums.len(), 1);
1221 }
1222
1223 #[test]
1226 fn the_retained_budget_is_shared_across_every_input_file() {
1227 let main_body = make_main_body(4, &[]);
1228 let rsid = compute_rsid(&main_body);
1229
1230 let build = |exponents: std::ops::Range<u32>| {
1231 let mut stream = make_full_packet(header::TYPE_MAIN, &main_body, rsid);
1232 for exponent in exponents {
1233 stream.extend_from_slice(&make_full_packet(
1234 header::TYPE_RECOVERY,
1235 &make_recovery_body(exponent, &[0xAB; 4]),
1236 rsid,
1237 ));
1238 }
1239 stream
1240 };
1241 let first = build(0..5);
1242 let second = build(5..10);
1243
1244 let limits = PacketScanLimits::default().with_max_retained_packets(6);
1246 assert_eq!(
1247 Par2FileSet::from_files_with_limits(&[&first], limits)
1248 .unwrap()
1249 .recovery_block_count(),
1250 5
1251 );
1252 assert_eq!(
1253 Par2FileSet::from_files_with_limits(&[&second], limits)
1254 .unwrap()
1255 .recovery_block_count(),
1256 5
1257 );
1258
1259 let error = Par2FileSet::from_files_with_limits(&[&first, &second], limits).unwrap_err();
1261 assert!(matches!(error, Par2Error::ResourceLimitExceeded { .. }));
1262
1263 let limits = limits.with_max_retained_packets(11);
1264 assert_eq!(
1265 Par2FileSet::from_files_with_limits(&[&first, &second], limits)
1266 .unwrap()
1267 .recovery_block_count(),
1268 10
1269 );
1270 }
1271
1272 #[test]
1274 fn an_inventory_near_the_documented_recovery_ceiling_still_loads() {
1275 let main_body = make_main_body(4, &[]);
1276 let rsid = compute_rsid(&main_body);
1277 let mut stream = make_full_packet(header::TYPE_MAIN, &main_body, rsid);
1278 for exponent in (MAX_RECOVERY_EXPONENT - 999)..=MAX_RECOVERY_EXPONENT {
1281 stream.extend_from_slice(&make_full_packet(
1282 header::TYPE_RECOVERY,
1283 &make_recovery_body(exponent, &[0xAB; 4]),
1284 rsid,
1285 ));
1286 }
1287
1288 let set = Par2FileSet::from_files(&[&stream]).unwrap();
1289 assert_eq!(set.recovery_block_count(), 1_000);
1290 assert!(set.recovery_slices.contains_key(&MAX_RECOVERY_EXPONENT));
1291 }
1292
1293 #[test]
1297 fn a_refused_load_produces_no_file_set_at_all() {
1298 let main_body = make_main_body(4, &[]);
1299 let rsid = compute_rsid(&main_body);
1300 let mut stream = make_full_packet(header::TYPE_MAIN, &main_body, rsid);
1301 for exponent in 0..32u32 {
1302 stream.extend_from_slice(&make_full_packet(
1303 header::TYPE_RECOVERY,
1304 &make_recovery_body(exponent, &[0xAB; 4]),
1305 rsid,
1306 ));
1307 }
1308
1309 let limits = PacketScanLimits::default().with_max_retained_packets(4);
1310 let outcome = Par2FileSet::from_files_with_limits(&[&stream], limits);
1311 assert!(matches!(
1312 outcome,
1313 Err(Par2Error::ResourceLimitExceeded { .. })
1314 ));
1315 assert!(outcome.is_err(), "no Par2FileSet is reachable from Err");
1316
1317 let dir = tempdir().unwrap();
1318 let path = dir.path().join("refused.par2");
1319 std::fs::write(&path, &stream).unwrap();
1320 assert!(matches!(
1321 Par2FileSet::from_paths_with_limits(&[path], limits),
1322 Err(Par2Error::ResourceLimitExceeded { .. })
1323 ));
1324 }
1325}