Skip to main content

par2_rs/
par2_set.rs

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/// Description of a single file in the PAR2 set.
16#[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/// A recovery slice: exponent + data.
27#[derive(Debug, Clone)]
28pub struct RecoverySlice {
29    pub exponent: RecoveryExponent,
30    pub data: RecoverySliceData,
31}
32
33/// Aggregated state from one or more .par2 files.
34///
35/// Collects Main, FileDescription, IFSC, RecoverySlice, and Creator packets
36/// into a unified view of the PAR2 recovery set.
37#[derive(Debug, Clone)]
38pub struct Par2FileSet {
39    /// The recovery set ID (MD5 of the main packet body).
40    pub recovery_set_id: RecoverySetId,
41    /// Block/slice size in bytes.
42    pub slice_size: u64,
43    /// File IDs that are in the recovery set (order matters for RS coding).
44    pub recovery_file_ids: Vec<FileId>,
45    /// File IDs that are NOT in the recovery set.
46    pub non_recovery_file_ids: Vec<FileId>,
47    /// File descriptions keyed by file ID.
48    pub files: HashMap<FileId, FileDescription>,
49    /// Slice checksums (IFSC data) keyed by file ID.
50    pub slice_checksums: HashMap<FileId, Vec<SliceChecksum>>,
51    /// Recovery slices keyed by exponent.
52    pub recovery_slices: BTreeMap<RecoveryExponent, RecoverySlice>,
53    /// Creator application identifier, if found.
54    pub creator: Option<String>,
55}
56
57impl Par2FileSet {
58    /// Create a new Par2FileSet by parsing packets from one or more .par2 file contents.
59    ///
60    /// Each element of `par2_files` is the raw bytes of a .par2 file. Packets
61    /// stream straight into the builder, which deduplicates as it goes, so the
62    /// peak is the deduplicated inventory rather than the packet stream. One
63    /// [`PacketScanBudget`] spans every input, under the default limits.
64    pub fn from_files(par2_files: &[&[u8]]) -> Result<Self> {
65        Self::from_files_with_limits(par2_files, PacketScanLimits::default())
66    }
67
68    /// [`Self::from_files`] under caller-chosen limits.
69    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    /// Create a new Par2FileSet by parsing packets directly from one or more
82    /// on-disk .par2 files. Recovery slice payloads are kept file-backed.
83    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    /// [`Self::from_paths`] under caller-chosen limits.
88    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    /// Create a Par2FileSet with diagnostic information about parse errors.
104    ///
105    /// Unlike [`Self::from_files`], this does not fail on individual file parse errors.
106    /// Instead, errors are collected into [`Par2Diagnostic`]. Returns an error
107    /// only if no valid main packet was found across all files — or if the
108    /// shared budget is exhausted, which is never downgraded to a diagnostic
109    /// because a partial inventory is indistinguishable from a small one.
110    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                    // A budget refusal is the caller's problem, not a per-file
123                    // diagnostic: it means the inventory would be incomplete.
124                    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    /// Create from an already-parsed list of packets.
156    ///
157    /// The caller already holds the packets, so this charges only the builder's
158    /// own retention against a default budget.
159    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    /// Get the file description for a given file ID.
169    pub fn file_description(&self, file_id: &FileId) -> Option<&FileDescription> {
170        self.files.get(file_id)
171    }
172
173    /// Get the slice checksums for a given file ID.
174    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    /// Return the CRC32 expected for the unpadded bytes of a file.
179    ///
180    /// PAR2 IFSC records store the CRC32 for each full input slice. A short
181    /// final slice is zero-padded before its IFSC checksum is calculated, so
182    /// its padding must be removed in GF(2) before the per-slice CRCs can be
183    /// combined into the file's actual CRC32. Missing or internally
184    /// inconsistent metadata produces no value.
185    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        // Empty files have a well-defined CRC32 without IFSC data. PAR2
192        // producers commonly omit the corresponding zero-entry IFSC packet;
193        // accept that absence, but reject a contradictory non-empty packet.
194        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    /// Return all file descriptions in the recovery set, in order.
238    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    /// Number of recovery blocks available.
246    ///
247    /// An upper bound for a set loaded from disk: the path scanner records
248    /// recovery payloads as file-backed spans without hashing them, so a block
249    /// whose payload is damaged is still counted here. It drops out only when
250    /// something validates its packet hash, which repair does before using it.
251    pub fn recovery_block_count(&self) -> u32 {
252        self.recovery_slices.len() as u32
253    }
254
255    /// Merge additional packets into this file set.
256    ///
257    /// Used to add recovery slices from newly-downloaded PAR2 volumes.
258    /// Ignores duplicate packets. Returns error if a conflicting recovery
259    /// set ID is encountered.
260    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    /// Compute the number of slices for a file given its length and the slice size.
337    ///
338    /// Uses checked arithmetic to avoid overflow on malicious inputs.
339    /// Returns 0 for zero-length files and caps at `u32::MAX` (the PAR2 spec
340    /// uses 32-bit slice indices, so files larger than `slice_size * u32::MAX`
341    /// are already outside spec).
342    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        // Saturate to u32::MAX rather than silently truncating.
348        u32::try_from(count).unwrap_or(u32::MAX)
349    }
350}
351
352/// Result of merging packets into an existing [`Par2FileSet`].
353#[derive(Debug, Clone)]
354pub struct MergeResult {
355    /// Number of new recovery slices added.
356    pub new_recovery_slices: u32,
357    /// Number of duplicate packets ignored.
358    pub duplicates_ignored: u32,
359}
360
361/// Diagnostic information about PAR2 file parsing issues.
362#[derive(Debug, Clone, Default)]
363pub struct Par2Diagnostic {
364    /// Files that had errors during parsing. (file_index, error description).
365    pub damaged_files: Vec<(usize, String)>,
366    /// Total number of packets that were skipped due to corruption.
367    pub skipped_packets: u32,
368}
369
370/// Result of [`Par2FileSet::from_files_with_diagnostics`].
371#[derive(Debug)]
372pub struct Par2ParseResult {
373    pub file_set: Par2FileSet,
374    pub diagnostic: Par2Diagnostic,
375}
376
377/// Sink that feeds a [`Par2FileSetBuilder`] directly from a scan.
378///
379/// This is the streaming replacement for scan-into-`Vec` then drain-into-
380/// builder: a duplicate or rejected packet is dropped where it is parsed, and
381/// its budget charge is handed straight back, so redundant critical packets
382/// spread across many volumes never accumulate.
383struct 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/// What [`Par2FileSetBuilder::add_packet`] did with a packet.
415///
416/// The caller needs this to keep its budget honest — a duplicate holds nothing,
417/// so its charge must be released — and to count diagnostics without keeping a
418/// second set of dedup maps beside the builder's own.
419#[derive(Debug, Clone, Copy, PartialEq, Eq)]
420pub(crate) enum PacketAdmission {
421    /// The packet's contents are now held by the builder.
422    Retained,
423    /// A packet of this kind and key was already held; nothing was kept.
424    Duplicate,
425    /// The packet cannot contribute to a repair and nothing was kept: an
426    /// unknown packet type, a recovery exponent outside the usable GF domain,
427    /// or a packet that would push a logical bound past its limit.
428    Rejected,
429}
430
431/// Builder that aggregates packets into a Par2FileSet.
432///
433/// Packets arrive one at a time and are deduplicated on arrival, so the builder
434/// holds the logical inventory and never a copy of the packet stream.
435pub(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    /// Whether a packet with this key is already held.
455    ///
456    /// Lets a caller that must defer a packet decide, before retaining it, that
457    /// it is bound to be a duplicate: the builder's keys only ever accumulate.
458    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    /// Whether [`Self::add_packet`] would keep this packet's contents.
472    ///
473    /// Mirrors `add_packet`'s decision exactly so a caller can charge its budget
474    /// *before* handing the packet over. Charging first and refunding on a
475    /// duplicate would make a duplicate briefly occupy a retained slot, and an
476    /// inventory sitting exactly on its limit would then be refused by its own
477    /// redundancy.
478    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    /// [`Self::add_packet`], charging `budget` for exactly what is kept.
498    ///
499    /// A duplicate, an unknown packet, or a packet past a logical bound costs
500    /// the budget nothing: it is never charged in the first place.
501    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            // Still goes through `add_packet`, which is where a Main packet from
509            // a foreign recovery set is detected.
510            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                    // Duplicate main packet with same RSID: ignore
529                    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                    // Bound the map before it grows: a Main packet cannot name
542                    // more than MAX_FILES_PER_SET files, so descriptions past
543                    // that describe files no repair can address.
544                    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                // Use the first IFSC packet for each file ID
565                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                    // Outside the usable GF exponent domain, so this block can
578                    // never take part in a solve. Refuse it before it claims a
579                    // map slot.
580                    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        // Validate recovery block data lengths against slice_size.
619        // PAR2 spec requires each recovery block to be exactly slice_size bytes.
620        // Wrong-sized recovery blocks are unusable and must not be zero-padded.
621        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    /// Helper to build a complete valid packet (header + body).
680    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        // Pad to multiple of 4
724        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            // PAR2 spec order: MD5 (16 bytes) then CRC32 (4 bytes)
735            body.extend_from_slice(&md5);
736            body.extend_from_slice(&crc.to_le_bytes());
737        }
738        body
739    }
740
741    /// Compute RSID as MD5 of main body (that's how PAR2 spec works).
742    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        // File 1: main + file desc
816        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        // File 2: recovery slice
822        let mut recovery_body = Vec::new();
823        recovery_body.extend_from_slice(&0u32.to_le_bytes()); // exponent 0
824        recovery_body.extend_from_slice(&[0xAB; 1024]); // recovery data
825        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        // Build recovery packet
1049        let mut recovery_body = Vec::new();
1050        recovery_body.extend_from_slice(&0u32.to_le_bytes()); // exponent 0
1051        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); // duplicate main
1069        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        // Different main body = different RSID
1082        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        // Merge the same recovery block again
1127        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); // main + recovery
1136        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        // Should succeed (duplicate with same RSID is ok)
1149        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    /// Exponents outside the usable GF domain describe no recoverable block, so
1161    /// they must be refused before they claim a slot in the recovery map.
1162    #[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    /// Redundant critical packets are what a real multi-volume set is mostly
1192    /// made of. They have to cost work but not retention, or the retained
1193    /// budget would be spent on copies of the same few packets.
1194    #[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        // Exactly the logical inventory: Main + one File Description + one IFSC.
1217        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    /// One budget spans every input, so redundancy spread across volumes is
1224    /// counted once for the load rather than once per file.
1225    #[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        // Main plus five recovery blocks fits either file on its own.
1245        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        // Together they need eleven, and the shared budget refuses them.
1260        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    /// A set right up against the documented ceilings still loads.
1273    #[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        // A slice of the domain rather than all 65,536: the full-scale run lives
1279        // in the repairer's reproduction test, which owns the slow path.
1280        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    /// A refused load yields an error and nothing else. There is no partially
1294    /// built set to observe, by construction: the builder is consumed to
1295    /// produce a `Par2FileSet` and the error path never reaches that point.
1296    #[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}