Skip to main content

par2_rs/create/
source.rs

1use std::collections::HashSet;
2use std::fs::{self, File, Metadata};
3use std::io::{self, Read, Seek, SeekFrom};
4use std::path::{Component, Path, PathBuf};
5use std::sync::atomic::{AtomicU64, Ordering};
6
7use rayon::prelude::*;
8
9use crate::checksum::{self, FileHashState, Md5State, SliceChecksumState};
10use crate::error::{Par2Error, Result};
11use crate::md5_simd;
12use crate::path::translate_par2_name_to_relative;
13use crate::types::{
14    CancellationToken, FileId, MAX_FILES_PER_SET, MAX_SLICES_PER_FILE, MAX_TOTAL_INPUT_SLICES,
15    ProgressCallback, ProgressStage, SliceChecksum,
16};
17
18use super::encode::{ForwardSourceObserver, ForwardSourceProvider};
19
20const FIRST_HASH_BYTES: u64 = 16 * 1024;
21const READ_BUFFER_BYTES: usize = 256 * 1024;
22const MAX_PAR2_NAME_BYTES: usize = 100_000;
23/// Staging budget for one file's multi-buffer slice-hash batch.
24///
25/// Matches the verifier's and the repair scanner's equivalent budgets so the
26/// three hashing paths admit the same per-task working set. `plan.rs` accounts
27/// this exact number, so the two must move together.
28pub(crate) const CREATE_MD5_BATCH_MEMORY_BYTES: usize = 4 * 1024 * 1024;
29
30/// How many consecutive slices of one file to hash per multi-buffer MD5 call.
31///
32/// Consecutive slices are independent messages, so they lane directly. The
33/// width is the narrower of what the kernel offers ([`md5_simd::max_lanes`]:
34/// 8 on AVX2, 4 on NEON/SSE2/simd128, 1 scalar) and what the staging budget
35/// affords. A block size large enough to leave only one lane falls back to the
36/// streaming scan, which needs just one `READ_BUFFER_BYTES` chunk.
37pub(crate) fn create_md5_batch_lanes(block_size: usize) -> usize {
38    if block_size == 0 {
39        return 1;
40    }
41    (CREATE_MD5_BATCH_MEMORY_BYTES / block_size).clamp(1, md5_simd::max_lanes())
42}
43
44/// A validated explicit source file and the metadata needed by critical PAR2
45/// packets.
46///
47/// # What a planned source has, and what only a created one has
48///
49/// Planning needs a file's identity — path, PAR2 name, length, the 16 KiB
50/// digest, the [`FileId`] derived from them, and how many slices the file
51/// occupies. It does not need the file's *contents*: `hash_full` and the
52/// per-slice checksums are read by exactly one place, the FileDesc and IFSC
53/// bodies in `output.rs`, and those are only written when outputs are.
54///
55/// Planning fills only the identity, leaving `hash_full` zero
56/// and one zeroed [`SliceChecksum`] per slice — correctly *sized*, so every
57/// quantity derived from a source (packet lengths, the memory plan, the
58/// staged packet layout) is already final at planning time — and creation
59/// fills the content hashes from the encoder's input feed where possible,
60/// avoiding a separate hashing pass. Other paths read the source to compute
61/// the hashes before writing the final packets.
62///
63/// A source held by a [`Par2CreatePlan`](super::plan::Par2CreatePlan)
64/// therefore carries placeholder content hashes; the sources handed to
65/// packet building never do.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct CreationSource {
68    /// Canonical path read during creation.
69    pub path: PathBuf,
70    /// Safe path recorded in PAR2 packets, using `/` separators.
71    pub par2_name: String,
72    /// PAR2 file identifier.
73    pub file_id: FileId,
74    /// Source length in bytes.
75    pub file_length: u64,
76    /// MD5 of the complete source file. Zero until creation fills it; see the
77    /// type's note on planned versus created sources.
78    pub hash_full: [u8; 16],
79    /// MD5 of the first 16KiB, or the complete file when shorter.
80    pub hash_16k: [u8; 16],
81    /// Zero-padded per-slice CRC32 and MD5 pairs. One entry per slice from
82    /// planning onward; the entries are zero until creation fills them.
83    pub slice_checksums: Vec<SliceChecksum>,
84}
85
86/// The placeholder a planned source carries until creation hashes the bytes.
87const DEFERRED_SLICE_CHECKSUM: SliceChecksum = SliceChecksum {
88    crc32: 0,
89    md5: [0; 16],
90};
91
92impl CreationSource {
93    /// Number of source slices represented by this source.
94    pub fn slice_count(&self) -> u32 {
95        self.slice_checksums.len() as u32
96    }
97}
98
99#[derive(Debug, Clone)]
100pub(crate) struct InputLength {
101    pub length: u64,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105struct SourceFingerprint {
106    length: u64,
107    modified: Option<std::time::SystemTime>,
108    #[cfg(unix)]
109    device: u64,
110    #[cfg(unix)]
111    inode: u64,
112}
113
114/// Per-creator memo of the source scan, so one `Par2Creator` reads and hashes
115/// its inputs once instead of once per `build_plan` call.
116///
117/// `Par2Creator::create` rebuilds the plan from the current inputs and compares
118/// it with the caller's, which means the whole input set is otherwise scanned
119/// twice for one creation: once in `plan()` and once in `create()`. An entry is
120/// only reused when a fresh `stat` of the same canonical path still yields the
121/// identical [`SourceFingerprint`] (length, mtime, and on unix device+inode)
122/// *and* the same block size, so what changes is which read the hashes come
123/// from, not whether the inputs are re-validated. A file that moved, was
124/// replaced, was rewritten in place, or whose length changed misses the memo
125/// and is rehashed exactly as before.
126///
127/// Entries are removed as they are used: the memory plan admits two live copies
128/// of the source metadata (`source_metadata_bytes * 2`) for the create phase,
129/// and draining is what keeps that true while the rebuilt plan is assembled.
130pub(crate) struct SourceScanCache {
131    entries: std::sync::Mutex<Vec<CachedScan>>,
132}
133
134struct CachedScan {
135    fingerprint: SourceFingerprint,
136    block_size: u64,
137    source: CreationSource,
138}
139
140impl SourceScanCache {
141    pub(crate) fn new() -> Self {
142        Self {
143            entries: std::sync::Mutex::new(Vec::new()),
144        }
145    }
146
147    fn take(
148        &self,
149        path: &Path,
150        fingerprint: &SourceFingerprint,
151        block_size: u64,
152    ) -> Option<CreationSource> {
153        let mut entries = self
154            .entries
155            .lock()
156            .unwrap_or_else(std::sync::PoisonError::into_inner);
157        let index = entries.iter().position(|entry| {
158            entry.block_size == block_size
159                && entry.fingerprint == *fingerprint
160                && entry.source.path == path
161        })?;
162        Some(entries.swap_remove(index).source)
163    }
164
165    fn store(&self, fingerprint: SourceFingerprint, block_size: u64, source: &CreationSource) {
166        let mut entries = self
167            .entries
168            .lock()
169            .unwrap_or_else(std::sync::PoisonError::into_inner);
170        entries.retain(|entry| entry.source.path != source.path);
171        entries.push(CachedScan {
172            fingerprint,
173            block_size,
174            source: source.clone(),
175        });
176    }
177}
178
179impl SourceFingerprint {
180    fn from_metadata(metadata: &Metadata) -> Self {
181        #[cfg(unix)]
182        use std::os::unix::fs::MetadataExt;
183
184        Self {
185            length: metadata.len(),
186            modified: metadata.modified().ok(),
187            #[cfg(unix)]
188            device: metadata.dev(),
189            #[cfg(unix)]
190            inode: metadata.ino(),
191        }
192    }
193}
194
195/// Sources that will be protected, and the inputs that will not be.
196///
197/// A PAR2 set cannot describe a zero-length file: the format protects slices,
198/// an empty file has none, and its FileDescription packet would claim a slice
199/// range no recovery packet covers. The reference encoder resolves this by
200/// dropping such inputs from the set (`commandline.cpp`, "Ignore all 0 byte
201/// files") and telling the operator it did, on every noise level. Dropping them
202/// silently is the one part of that this crate used to leave out, which meant a
203/// set could protect fewer files than the caller listed with nothing to say so.
204pub(crate) struct CollectedSources {
205    pub(crate) sources: Vec<CreationSource>,
206    /// Inputs excluded because they are zero-length, in the order given, spelled
207    /// the way the caller spelled them rather than canonicalized — this is
208    /// destined for a human who is looking for the name they typed.
209    pub(crate) skipped_empty: Vec<PathBuf>,
210}
211
212/// Resolve and validate explicit source files in input order, reading each
213/// file's identity (length, 16 KiB digest, [`FileId`], slice count).
214///
215/// The whole-file digest and the per-slice checksums are deliberately NOT read
216/// here — see [`CreationSource`] for what a planned source carries and who
217/// fills the rest in.
218pub(crate) fn collect_sources(
219    base_path: &Path,
220    inputs: &[PathBuf],
221    block_size: u64,
222    cancellation: &CancellationToken,
223    progress: Option<&ProgressCallback>,
224    total_bytes: u64,
225    cache: Option<&SourceScanCache>,
226) -> Result<CollectedSources> {
227    if inputs.is_empty() {
228        return Err(Par2Error::InvalidCreationOptions {
229            reason: "at least one source file is required".to_string(),
230        });
231    }
232    let base = fs::canonicalize(base_path).map_err(Par2Error::Io)?;
233    let metadata = fs::metadata(&base).map_err(Par2Error::Io)?;
234    if !metadata.is_dir() {
235        return Err(Par2Error::UnsafeCreationSource {
236            path: base.display().to_string(),
237            reason: "base path is not a directory".to_string(),
238        });
239    }
240
241    let mut active_inputs = Vec::with_capacity(inputs.len());
242    let mut skipped_empty = Vec::new();
243    for input in inputs {
244        if cancellation.is_cancelled() {
245            return Err(Par2Error::Cancelled);
246        }
247        let path = resolve_input_path(&base, input)?;
248        let metadata = fs::metadata(&path).map_err(Par2Error::Io)?;
249        if !metadata.is_file() {
250            return Err(Par2Error::UnsafeCreationSource {
251                path: input.display().to_string(),
252                reason: "source is not a regular file".to_string(),
253            });
254        }
255        let relative = path
256            .strip_prefix(&base)
257            .map_err(|_| Par2Error::UnsafeCreationSource {
258                path: input.display().to_string(),
259                reason: "source is outside the base directory".to_string(),
260            })?;
261        validate_relative_path(relative, input)?;
262        if relative.to_str().is_none() {
263            return Err(Par2Error::UnsafeCreationSource {
264                path: input.display().to_string(),
265                reason: "source filename is not valid UTF-8".to_string(),
266            });
267        }
268        if metadata.len() > 0 {
269            active_inputs.push(input);
270        } else {
271            // Validated exactly like a protected input above — a zero-length
272            // file that is outside the base path or is not a regular file is
273            // still an error, not a skip — and only then set aside.
274            skipped_empty.push(input.clone());
275        }
276    }
277    if active_inputs.is_empty() {
278        return Err(Par2Error::InvalidCreationOptions {
279            reason: "at least one non-empty source file is required".to_string(),
280        });
281    }
282    validate_main_file_count(active_inputs.len())?;
283
284    let mut names = HashSet::with_capacity(active_inputs.len());
285    let mut ids = HashSet::with_capacity(active_inputs.len());
286    let bytes_processed = AtomicU64::new(0);
287    let file_total =
288        u32::try_from(active_inputs.len()).map_err(|_| Par2Error::ResourceLimitExceeded {
289            reason: "source file count exceeds the supported progress range".to_string(),
290        })?;
291    let mut total_slices = 0usize;
292
293    let hash_one = |(index, input): (usize, &&PathBuf)| -> Result<CreationSource> {
294        if cancellation.is_cancelled() {
295            return Err(Par2Error::Cancelled);
296        }
297        resolve_source_identity(
298            &base,
299            input,
300            block_size,
301            cancellation,
302            progress,
303            index as u32,
304            file_total,
305            &bytes_processed,
306            total_bytes,
307            cache,
308        )
309    };
310    // Hash one file per rayon task; each file's scan is independent. The
311    // shared byte counter is monotonic, but callbacks fire concurrently, so
312    // DELIVERY to the progress callback is not ordered. The sequential arm
313    // is the single-threaded-wasm path (no worker pool, matching the crate's
314    // other guards; `wasm32-wasip1-threads` probes `true` and hashes in
315    // parallel like native)
316    // and the WEAVER_PAR2_CREATE_THREADS=1 pre-banding escape hatch.
317    let scan_parallel = reedsolomon_rs::threading::parallel_enabled()
318        && active_inputs.len() > 1
319        && super::encode::configured_create_threads() != 1;
320    let scanned: Vec<CreationSource> = if scan_parallel {
321        // Collect every per-file Result, then surface the FIRST error by
322        // input order: rayon's Result collection reports an arbitrary
323        // racer's error, which would let a concurrent I/O error mask
324        // `Cancelled` (or make the reported path vary run to run). The
325        // deliberate cost is losing error short-circuiting — in-flight
326        // files hash to completion before the error surfaces.
327        active_inputs
328            .par_iter()
329            .enumerate()
330            .map(hash_one)
331            .collect::<Vec<Result<_>>>()
332            .into_iter()
333            .collect::<Result<Vec<_>>>()?
334    } else {
335        active_inputs
336            .iter()
337            .enumerate()
338            .map(hash_one)
339            .collect::<Result<Vec<_>>>()?
340    };
341    // The creation memory plan accounts this vector by LENGTH (plan.rs passes
342    // `sources.len()` into `estimate_source_metadata_bytes`), so a collect
343    // through a Result adapter cannot skew the estimate. The exact-capacity
344    // rebuild stays so the allocation itself matches what the plan admits.
345    let mut sources = Vec::with_capacity(active_inputs.len());
346    sources.extend(scanned);
347
348    for (source, input) in sources.iter().zip(active_inputs.iter()) {
349        if !names.insert(source.par2_name.clone()) {
350            return Err(Par2Error::UnsafeCreationSource {
351                path: input.display().to_string(),
352                reason: "duplicate relative PAR2 name".to_string(),
353            });
354        }
355        if !ids.insert(source.file_id) {
356            return Err(Par2Error::UnsafeCreationSource {
357                path: input.display().to_string(),
358                reason: "duplicate PAR2 file identifier".to_string(),
359            });
360        }
361        total_slices = total_slices
362            .checked_add(source.slice_checksums.len())
363            .ok_or_else(|| Par2Error::ResourceLimitExceeded {
364                reason: "total source slice count overflows".to_string(),
365            })?;
366        if total_slices > MAX_TOTAL_INPUT_SLICES {
367            return Err(Par2Error::ResourceLimitExceeded {
368                reason: format!(
369                    "total source slice count {total_slices} exceeds {MAX_TOTAL_INPUT_SLICES}"
370                ),
371            });
372        }
373    }
374    Ok(CollectedSources {
375        sources,
376        skipped_empty,
377    })
378}
379
380/// Resolve explicit inputs and collect their lengths before selecting a slice size.
381pub(crate) fn collect_input_lengths(
382    base_path: &Path,
383    inputs: &[PathBuf],
384    cancellation: &CancellationToken,
385) -> Result<Vec<InputLength>> {
386    if inputs.is_empty() {
387        return Err(Par2Error::InvalidCreationOptions {
388            reason: "at least one source file is required".to_string(),
389        });
390    }
391    let base = fs::canonicalize(base_path).map_err(Par2Error::Io)?;
392    let base_metadata = fs::metadata(&base).map_err(Par2Error::Io)?;
393    if !base_metadata.is_dir() {
394        return Err(Par2Error::UnsafeCreationSource {
395            path: base.display().to_string(),
396            reason: "base path is not a directory".to_string(),
397        });
398    }
399
400    let mut lengths = Vec::with_capacity(inputs.len());
401    for input in inputs {
402        if cancellation.is_cancelled() {
403            return Err(Par2Error::Cancelled);
404        }
405        let path = resolve_input_path(&base, input)?;
406        let metadata = fs::metadata(&path).map_err(Par2Error::Io)?;
407        if !metadata.is_file() {
408            return Err(Par2Error::UnsafeCreationSource {
409                path: input.display().to_string(),
410                reason: "source is not a regular file".to_string(),
411            });
412        }
413        let relative = path
414            .strip_prefix(&base)
415            .map_err(|_| Par2Error::UnsafeCreationSource {
416                path: input.display().to_string(),
417                reason: "source is outside the base directory".to_string(),
418            })?;
419        validate_relative_path(relative, input)?;
420        if relative.to_str().is_none() {
421            return Err(Par2Error::UnsafeCreationSource {
422                path: input.display().to_string(),
423                reason: "source filename is not valid UTF-8".to_string(),
424            });
425        }
426        if metadata.len() > 0 {
427            lengths.push(InputLength {
428                length: metadata.len(),
429            });
430        }
431    }
432    validate_main_file_count(lengths.len())?;
433    Ok(lengths)
434}
435
436fn validate_main_file_count(file_count: usize) -> Result<()> {
437    if file_count > MAX_FILES_PER_SET {
438        return Err(Par2Error::ResourceLimitExceeded {
439            reason: format!(
440                "source file count {file_count} exceeds the Main packet limit of {MAX_FILES_PER_SET}"
441            ),
442        });
443    }
444    Ok(())
445}
446
447/// Seek/read source slices for one forward-encoding pass.
448pub(crate) struct DiskSourceProvider<'a> {
449    sources: &'a [CreationSource],
450    files: Vec<File>,
451    fingerprints: Vec<SourceFingerprint>,
452    slice_starts: Vec<usize>,
453    slice_size: usize,
454    cancellation: &'a CancellationToken,
455}
456
457impl<'a> DiskSourceProvider<'a> {
458    pub(crate) fn open(
459        sources: &'a [CreationSource],
460        slice_size: usize,
461        cancellation: &'a CancellationToken,
462    ) -> Result<Self> {
463        if slice_size == 0 {
464            return Err(Par2Error::ResourceLimitExceeded {
465                reason: "source provider slice size is zero".to_string(),
466            });
467        }
468        let mut files = Vec::with_capacity(sources.len());
469        let mut fingerprints = Vec::with_capacity(sources.len());
470        let mut slice_starts = Vec::with_capacity(sources.len());
471        let mut next_start = 0usize;
472        for source in sources {
473            if cancellation.is_cancelled() {
474                return Err(Par2Error::Cancelled);
475            }
476            let metadata = fs::metadata(&source.path).map_err(Par2Error::Io)?;
477            if !metadata.is_file() || metadata.len() != source.file_length {
478                return Err(Par2Error::CreationSourceChanged {
479                    path: source.path.display().to_string(),
480                });
481            }
482            let slice_count = source.slice_checksums.len();
483            slice_starts.push(next_start);
484            next_start = next_start.checked_add(slice_count).ok_or_else(|| {
485                Par2Error::ResourceLimitExceeded {
486                    reason: "source slice provider index overflows".to_string(),
487                }
488            })?;
489            fingerprints.push(SourceFingerprint::from_metadata(&metadata));
490            files.push(File::open(&source.path).map_err(Par2Error::Io)?);
491        }
492        Ok(Self {
493            sources,
494            files,
495            fingerprints,
496            slice_starts,
497            slice_size,
498            cancellation,
499        })
500    }
501
502    pub(crate) fn verify_unchanged(&self) -> Result<()> {
503        for (source, fingerprint) in self.sources.iter().zip(&self.fingerprints) {
504            let metadata = fs::metadata(&source.path).map_err(Par2Error::Io)?;
505            if SourceFingerprint::from_metadata(&metadata) != *fingerprint {
506                return Err(Par2Error::CreationSourceChanged {
507                    path: source.path.display().to_string(),
508                });
509            }
510        }
511        Ok(())
512    }
513
514    fn source_location(&self, source_index: usize) -> Result<(usize, usize)> {
515        let file_index = self
516            .slice_starts
517            .partition_point(|&start| start <= source_index)
518            .checked_sub(1)
519            .ok_or_else(|| Par2Error::CreationSourceChanged {
520                path: "source slice index is out of range".to_string(),
521            })?;
522        let local_index = source_index
523            .checked_sub(self.slice_starts[file_index])
524            .ok_or_else(|| Par2Error::ResourceLimitExceeded {
525                reason: "source slice index underflows".to_string(),
526            })?;
527        if local_index >= self.sources[file_index].slice_checksums.len() {
528            return Err(Par2Error::CreationSourceChanged {
529                path: self.sources[file_index].path.display().to_string(),
530            });
531        }
532        Ok((file_index, local_index))
533    }
534}
535
536impl ForwardSourceProvider for DiskSourceProvider<'_> {
537    fn source_count(&self) -> usize {
538        self.sources
539            .iter()
540            .map(|source| source.slice_checksums.len())
541            .sum()
542    }
543
544    fn source_slice_len(&self, source_index: usize) -> Result<usize> {
545        let (file_index, local_index) = self.source_location(source_index)?;
546        let offset = (local_index as u64)
547            .checked_mul(self.slice_size as u64)
548            .ok_or_else(|| Par2Error::ResourceLimitExceeded {
549                reason: "source slice offset overflows".to_string(),
550            })?;
551        usize::try_from(
552            self.sources[file_index]
553                .file_length
554                .saturating_sub(offset)
555                .min(self.slice_size as u64),
556        )
557        .map_err(|_| Par2Error::ResourceLimitExceeded {
558            reason: "source slice length exceeds addressable memory".to_string(),
559        })
560    }
561
562    fn read_source_chunk(
563        &mut self,
564        source_index: usize,
565        offset: usize,
566        destination: &mut [u8],
567    ) -> Result<usize> {
568        if self.cancellation.is_cancelled() {
569            return Err(Par2Error::Cancelled);
570        }
571        let (file_index, local_index) = self.source_location(source_index)?;
572        let slice_len = self.source_slice_len(source_index)?;
573        let start = offset.min(slice_len);
574        let take = destination.len().min(slice_len.saturating_sub(start));
575        if take == 0 {
576            return Ok(0);
577        }
578        let slice_offset = (local_index as u64)
579            .checked_mul(self.slice_size as u64)
580            .and_then(|value| value.checked_add(start as u64))
581            .ok_or_else(|| Par2Error::ResourceLimitExceeded {
582                reason: "source read offset overflows".to_string(),
583            })?;
584        self.files[file_index]
585            .seek(SeekFrom::Start(slice_offset))
586            .map_err(Par2Error::Io)?;
587        self.files[file_index]
588            .read_exact(&mut destination[..take])
589            .map_err(|error| {
590                if error.kind() == io::ErrorKind::UnexpectedEof {
591                    Par2Error::CreationSourceChanged {
592                        path: self.sources[file_index].path.display().to_string(),
593                    }
594                } else {
595                    Par2Error::Io(error)
596                }
597            })?;
598        Ok(take)
599    }
600}
601
602#[allow(clippy::too_many_arguments)]
603fn resolve_source_identity(
604    base: &Path,
605    input: &Path,
606    block_size: u64,
607    cancellation: &CancellationToken,
608    progress: Option<&ProgressCallback>,
609    file_index: u32,
610    file_total: u32,
611    bytes_processed: &AtomicU64,
612    total_bytes: u64,
613    cache: Option<&SourceScanCache>,
614) -> Result<CreationSource> {
615    if block_size == 0 || !block_size.is_multiple_of(4) {
616        return Err(Par2Error::InvalidCreationOptions {
617            reason: format!("source block size {block_size} is not a positive multiple of four"),
618        });
619    }
620    let path = resolve_input_path(base, input)?;
621    let metadata = fs::metadata(&path).map_err(Par2Error::Io)?;
622    if !metadata.is_file() {
623        return Err(Par2Error::UnsafeCreationSource {
624            path: input.display().to_string(),
625            reason: "source is not a regular file".to_string(),
626        });
627    }
628    let fingerprint = SourceFingerprint::from_metadata(&metadata);
629    let relative = path
630        .strip_prefix(base)
631        .map_err(|_| Par2Error::UnsafeCreationSource {
632            path: input.display().to_string(),
633            reason: "source is outside the base directory".to_string(),
634        })?;
635    validate_relative_path(relative, input)?;
636    let relative_name = relative
637        .to_str()
638        .ok_or_else(|| Par2Error::UnsafeCreationSource {
639            path: input.display().to_string(),
640            reason: "source filename is not valid UTF-8".to_string(),
641        })?;
642    let par2_name = relative_name.replace('\\', "/");
643    let par2_name = translate_par2_name_to_relative(&par2_name).map_err(|error| {
644        Par2Error::UnsafeCreationSource {
645            path: input.display().to_string(),
646            reason: error.to_string(),
647        }
648    })?;
649    if par2_name.is_empty()
650        || par2_name.len() > MAX_PAR2_NAME_BYTES
651        || par2_name.as_bytes().contains(&0)
652    {
653        return Err(Par2Error::UnsafeCreationSource {
654            path: input.display().to_string(),
655            reason: "PAR2 name is empty, contains NUL, or exceeds 100000 bytes".to_string(),
656        });
657    }
658
659    // Memo hit: this exact path, at this exact fingerprint and block size, was
660    // already read and hashed by this creator, and the `stat` above is the
661    // guard that says so. The bytes are not read again; progress is still
662    // reported one slice at a time, with the same byte steps a real scan of
663    // this file would produce, so the callback stream a caller sees does not
664    // depend on which read the hashes came from.
665    if let Some(source) = cache.and_then(|cache| cache.take(&path, &fingerprint, block_size)) {
666        debug_assert_eq!(source.par2_name, par2_name);
667        report_scan_progress(
668            ScanProgress {
669                progress,
670                file_index,
671                file_total,
672                bytes_processed,
673                total_bytes,
674            },
675            fingerprint.length,
676            block_size,
677            source.slice_checksums.len(),
678            cancellation,
679        )?;
680        return Ok(source);
681    }
682
683    let mut file = File::open(&path).map_err(Par2Error::Io)?;
684    let slice_count = if fingerprint.length == 0 {
685        0
686    } else {
687        fingerprint
688            .length
689            .checked_add(block_size - 1)
690            .ok_or_else(|| Par2Error::ResourceLimitExceeded {
691                reason: "source slice count overflows".to_string(),
692            })?
693            / block_size
694    };
695    if slice_count > MAX_SLICES_PER_FILE as u64 {
696        return Err(Par2Error::ResourceLimitExceeded {
697            reason: format!("source slice count {slice_count} exceeds {MAX_SLICES_PER_FILE}"),
698        });
699    }
700    let slice_count_usize =
701        usize::try_from(slice_count).map_err(|_| Par2Error::ResourceLimitExceeded {
702            reason: "source slice count exceeds addressable memory".to_string(),
703        })?;
704
705    // Identity only: the first 16 KiB (or the whole file when shorter) is
706    // what the FileId is derived from, and the FileId is what planning needs.
707    // The rest of the file is read exactly once, by the encoder, and hashed
708    // there — see `CreationSource`.
709    let head_len = usize::try_from(FIRST_HASH_BYTES.min(fingerprint.length)).map_err(|_| {
710        Par2Error::ResourceLimitExceeded {
711            reason: "source head length exceeds addressable memory".to_string(),
712        }
713    })?;
714    let mut head = vec![0u8; head_len];
715    read_exact_or_changed(&mut file, &mut head, input)?;
716    let mut first_hash = Md5State::new();
717    first_hash.update(&head);
718    drop(head);
719
720    let after = fs::metadata(&path).map_err(Par2Error::Io)?;
721    if SourceFingerprint::from_metadata(&after) != fingerprint {
722        return Err(Par2Error::CreationSourceChanged {
723            path: input.display().to_string(),
724        });
725    }
726
727    let hash_16k = first_hash.finalize();
728    let mut file_id_hash = Md5State::new();
729    file_id_hash.update(&hash_16k);
730    file_id_hash.update(&fingerprint.length.to_le_bytes());
731    file_id_hash.update(par2_name.as_bytes());
732    let file_id = FileId::from_bytes(file_id_hash.finalize());
733    // The same per-slice byte steps a content scan of this file would emit, so
734    // the callback stream a caller sees does not depend on which pass the
735    // hashes come from (the memo-hit arm above reports identically).
736    report_scan_progress(
737        ScanProgress {
738            progress,
739            file_index,
740            file_total,
741            bytes_processed,
742            total_bytes,
743        },
744        fingerprint.length,
745        block_size,
746        slice_count_usize,
747        cancellation,
748    )?;
749
750    let source = CreationSource {
751        path,
752        par2_name,
753        file_id,
754        file_length: fingerprint.length,
755        hash_full: [0; 16],
756        hash_16k,
757        slice_checksums: vec![DEFERRED_SLICE_CHECKSUM; slice_count_usize],
758    };
759    if let Some(cache) = cache {
760        cache.store(fingerprint, block_size, &source);
761    }
762    Ok(source)
763}
764
765fn resolve_input_path(base: &Path, input: &Path) -> Result<PathBuf> {
766    if input.is_absolute() {
767        return fs::canonicalize(input).map_err(Par2Error::Io);
768    }
769
770    fs::canonicalize(base.join(input)).map_err(Par2Error::Io)
771}
772
773fn validate_relative_path(relative: &Path, input: &Path) -> Result<()> {
774    if relative.as_os_str().is_empty()
775        || relative.components().any(|component| {
776            matches!(
777                component,
778                Component::ParentDir | Component::RootDir | Component::Prefix(_)
779            )
780        })
781    {
782        return Err(Par2Error::UnsafeCreationSource {
783            path: input.display().to_string(),
784            reason: "source does not have a safe relative path".to_string(),
785        });
786    }
787    Ok(())
788}
789
790/// The progress-reporting context of one source-scan task.
791struct ScanProgress<'a> {
792    progress: Option<&'a ProgressCallback>,
793    file_index: u32,
794    file_total: u32,
795    bytes_processed: &'a AtomicU64,
796    total_bytes: u64,
797}
798
799/// Emit one source-scan progress step per slice of one file.
800fn report_scan_progress(
801    context: ScanProgress<'_>,
802    file_length: u64,
803    block_size: u64,
804    slice_count: usize,
805    cancellation: &CancellationToken,
806) -> Result<()> {
807    let mut remaining = file_length;
808    for _ in 0..slice_count {
809        if cancellation.is_cancelled() {
810            return Err(Par2Error::Cancelled);
811        }
812        let step = remaining.min(block_size);
813        remaining -= step;
814        let processed_total = context
815            .bytes_processed
816            .fetch_add(step, Ordering::Relaxed)
817            .saturating_add(step);
818        report_progress(
819            context.progress,
820            context.file_index,
821            context.file_total,
822            processed_total,
823            context.total_bytes,
824        );
825    }
826    Ok(())
827}
828
829/// The content hashes of one source file: what [`CreationSource`] leaves
830/// deferred until creation.
831pub(crate) struct SourceContentHashes {
832    pub(crate) hash_full: [u8; 16],
833    pub(crate) hash_16k: [u8; 16],
834    pub(crate) slice_checksums: Vec<SliceChecksum>,
835}
836
837/// Read one source file end to end and produce its content hashes.
838///
839/// This is the fallback: it runs only when the encoder's feed cannot serve the
840/// hashes (a non-CPU backend, a pass with no recovery blocks, or a stripe
841/// schedule that visits a file's bytes out of file order). The fast path is
842/// [`FusedSourceHasher`], which sees the same bytes for free.
843fn hash_source_contents(
844    path: &Path,
845    input: &Path,
846    file_length: u64,
847    block_size: u64,
848    slice_count: usize,
849    cancellation: &CancellationToken,
850) -> Result<SourceContentHashes> {
851    let block_size_usize =
852        usize::try_from(block_size).map_err(|_| Par2Error::ResourceLimitExceeded {
853            reason: format!("source block size {block_size} exceeds addressable memory"),
854        })?;
855    let mut file = File::open(path).map_err(Par2Error::Io)?;
856    let mut full_hash = FileHashState::new();
857    let mut first_hash = Md5State::new();
858    let mut first_bytes = 0u64;
859    let mut checksums = Vec::with_capacity(slice_count);
860
861    // Per-slice length: every slice is a full block except the file's last,
862    // which is short and zero-padded to the block size for checksum purposes.
863    let slice_len = |slice_index: usize| -> Result<usize> {
864        let offset = (slice_index as u64)
865            .checked_mul(block_size)
866            .ok_or_else(|| Par2Error::ResourceLimitExceeded {
867                reason: "source slice offset overflows".to_string(),
868            })?;
869        usize::try_from(file_length.saturating_sub(offset).min(block_size)).map_err(|_| {
870            Par2Error::ResourceLimitExceeded {
871                reason: "source slice length exceeds addressable memory".to_string(),
872            }
873        })
874    };
875
876    let lanes = create_md5_batch_lanes(block_size_usize);
877    if lanes >= 2 {
878        // Batched scan. Consecutive slices are independent MD5 messages, so a
879        // batch of them goes through the multi-buffer kernel in one pass. The
880        // whole-file MD5 stays a single serial stream over the same bytes: it
881        // is a different message and cannot be laned within one file (see the
882        // module note on why it is also not laned *across* files).
883        let mut batch = vec![0u8; lanes * block_size_usize];
884        let mut digests = vec![[0u8; 16]; lanes];
885        let mut lens = vec![0usize; lanes];
886        let mut crcs = vec![0u32; lanes];
887        let mut slice_index = 0usize;
888
889        while slice_index < slice_count {
890            let batch_slices = lanes.min(slice_count - slice_index);
891
892            for lane in 0..batch_slices {
893                if cancellation.is_cancelled() {
894                    return Err(Par2Error::Cancelled);
895                }
896                let actual_len = slice_len(slice_index + lane)?;
897                let start = lane * block_size_usize;
898                let slice = &mut batch[start..start + actual_len];
899                read_exact_or_changed(&mut file, slice, input)?;
900                lens[lane] = actual_len;
901
902                // Both single-stream digests run here, while this slice is
903                // still in the cache the read just filled, instead of after a
904                // second whole-batch walk. Only the file's final slice can be
905                // short, so lane order within a batch is file order and the
906                // serial stream sees exactly the bytes -- and the byte order --
907                // that one `batch[..batch_bytes]` absorb produced.
908                let slice = &batch[start..start + actual_len];
909                absorb_file_stream(&mut full_hash, &mut first_hash, &mut first_bytes, slice);
910                crcs[lane] = checksum::crc32_padded(slice, block_size);
911            }
912
913            let inputs = (0..batch_slices)
914                .map(|lane| {
915                    let start = lane * block_size_usize;
916                    &batch[start..start + lens[lane]]
917                })
918                .collect::<Vec<_>>();
919            md5_simd::md5_multi_into(&inputs, Some(block_size), &mut digests[..batch_slices]);
920
921            for lane in 0..batch_slices {
922                checksums.push(SliceChecksum {
923                    crc32: crcs[lane],
924                    md5: digests[lane],
925                });
926            }
927
928            slice_index += batch_slices;
929        }
930    } else {
931        // Streaming scan: one slice at a time through a single read buffer.
932        // Selected when one block already fills the staging budget, so there
933        // is no second lane to fill anyway.
934        let mut buffer = vec![0u8; READ_BUFFER_BYTES.min(block_size_usize.max(1))];
935        for slice_index in 0..slice_count {
936            if cancellation.is_cancelled() {
937                return Err(Par2Error::Cancelled);
938            }
939            let actual_len = slice_len(slice_index)?;
940            let mut remaining = actual_len;
941            let mut slice_hash = SliceChecksumState::new();
942            while remaining > 0 {
943                if cancellation.is_cancelled() {
944                    return Err(Par2Error::Cancelled);
945                }
946                let take = remaining.min(buffer.len());
947                read_exact_or_changed(&mut file, &mut buffer[..take], input)?;
948                let chunk = &buffer[..take];
949                slice_hash.update(chunk);
950                absorb_file_stream(&mut full_hash, &mut first_hash, &mut first_bytes, chunk);
951                remaining -= take;
952            }
953            let pad_to = ((actual_len as u64) < block_size).then_some(block_size);
954            let (crc32, md5) = slice_hash.finalize(pad_to);
955            checksums.push(SliceChecksum { crc32, md5 });
956        }
957    }
958
959    if full_hash.bytes_fed() != file_length {
960        return Err(Par2Error::CreationSourceChanged {
961            path: input.display().to_string(),
962        });
963    }
964    Ok(SourceContentHashes {
965        hash_full: full_hash.finalize(),
966        hash_16k: first_hash.finalize(),
967        slice_checksums: checksums,
968    })
969}
970
971/// Fill in the deferred content hashes of already-identified sources by
972/// reading them.
973///
974/// Creation's fallback when the encoder feed cannot serve them; see
975/// [`CreationSource`]. Every file is re-validated against the identity the
976/// plan was built on, so a file that changed between planning and creation is
977/// rejected here rather than silently written into a packet.
978pub(crate) fn hydrate_source_hashes(
979    sources: &mut [CreationSource],
980    block_size: u64,
981    cancellation: &CancellationToken,
982) -> Result<()> {
983    let hash_one = |source: &mut CreationSource| -> Result<()> {
984        if cancellation.is_cancelled() {
985            return Err(Par2Error::Cancelled);
986        }
987        let metadata = fs::metadata(&source.path).map_err(Par2Error::Io)?;
988        if !metadata.is_file() || metadata.len() != source.file_length {
989            return Err(Par2Error::CreationSourceChanged {
990                path: source.path.display().to_string(),
991            });
992        }
993        let hashes = hash_source_contents(
994            &source.path,
995            &source.path,
996            source.file_length,
997            block_size,
998            source.slice_checksums.len(),
999            cancellation,
1000        )?;
1001        if hashes.hash_16k != source.hash_16k {
1002            return Err(Par2Error::CreationSourceChanged {
1003                path: source.path.display().to_string(),
1004            });
1005        }
1006        source.hash_full = hashes.hash_full;
1007        source.slice_checksums = hashes.slice_checksums;
1008        Ok(())
1009    };
1010    // Same split, and the same first-error-by-input-order rule, as the
1011    // identity scan above.
1012    let scan_parallel = reedsolomon_rs::threading::parallel_enabled()
1013        && sources.len() > 1
1014        && super::encode::configured_create_threads() != 1;
1015    if scan_parallel {
1016        sources
1017            .par_iter_mut()
1018            .map(hash_one)
1019            .collect::<Vec<Result<()>>>()
1020            .into_iter()
1021            .collect::<Result<Vec<()>>>()?;
1022    } else {
1023        for source in sources.iter_mut() {
1024            hash_one(source)?;
1025        }
1026    }
1027    Ok(())
1028}
1029
1030/// The deferred content hashes of a whole recovery set, ready to be applied.
1031pub(crate) struct FusedSourceHashes {
1032    hash_full: Vec<[u8; 16]>,
1033    slice_checksums: Vec<Vec<SliceChecksum>>,
1034}
1035
1036impl FusedSourceHashes {
1037    /// Move the hashes onto the sources they were computed from.
1038    pub(crate) fn apply(self, sources: &mut [CreationSource]) -> Result<()> {
1039        if self.hash_full.len() != sources.len() || self.slice_checksums.len() != sources.len() {
1040            return Err(Par2Error::InvalidCreationOptions {
1041                reason: "fused source hashes do not cover the recovery set".to_string(),
1042            });
1043        }
1044        for ((source, hash_full), checksums) in sources
1045            .iter_mut()
1046            .zip(self.hash_full)
1047            .zip(self.slice_checksums)
1048        {
1049            if checksums.len() != source.slice_checksums.len() {
1050                return Err(Par2Error::CreationSourceChanged {
1051                    path: source.path.display().to_string(),
1052                });
1053            }
1054            source.hash_full = hash_full;
1055            source.slice_checksums = checksums;
1056        }
1057        Ok(())
1058    }
1059}
1060
1061/// Drives a recovery set's deferred content hashes from the encoder's own
1062/// source feed, so the bytes are hashed while they are still in the cache the
1063/// arithmetic just pulled them into and are never read a second time.
1064///
1065/// Correct only while the feed is single-stripe: the whole-file MD5 is one
1066/// serial message over a file's bytes in file order, and a stripe-major feed
1067/// does not deliver them that way. The caller decides; this type refuses an
1068/// out-of-order feed rather than producing a wrong digest, and cross-checks
1069/// each file's length and 16 KiB digest against the identity the plan was
1070/// built on, so a source that changed between planning and creation is
1071/// rejected instead of silently written into a packet.
1072pub(crate) struct FusedSourceHasher<'a> {
1073    sources: &'a [CreationSource],
1074    /// Exclusive end of each file's slice range in encoder source order.
1075    slice_ends: Vec<usize>,
1076    block_size: u64,
1077    next_source_index: usize,
1078    file_index: usize,
1079    full_hash: FileHashState,
1080    first_hash: Md5State,
1081    first_bytes: u64,
1082    hash_full: Vec<[u8; 16]>,
1083    slice_checksums: Vec<Vec<SliceChecksum>>,
1084    digests: Vec<[u8; 16]>,
1085}
1086
1087impl<'a> FusedSourceHasher<'a> {
1088    pub(crate) fn new(sources: &'a [CreationSource], block_size: u64) -> Result<Self> {
1089        if block_size == 0 {
1090            return Err(Par2Error::InvalidCreationOptions {
1091                reason: "fused source hashing needs a positive block size".to_string(),
1092            });
1093        }
1094        let mut slice_ends = Vec::with_capacity(sources.len());
1095        let mut end = 0usize;
1096        for source in sources {
1097            end = end
1098                .checked_add(source.slice_checksums.len())
1099                .ok_or_else(|| Par2Error::ResourceLimitExceeded {
1100                    reason: "source slice index overflows".to_string(),
1101                })?;
1102            slice_ends.push(end);
1103        }
1104        Ok(Self {
1105            sources,
1106            slice_ends,
1107            block_size,
1108            next_source_index: 0,
1109            file_index: 0,
1110            full_hash: FileHashState::new(),
1111            first_hash: Md5State::new(),
1112            first_bytes: 0,
1113            hash_full: Vec::with_capacity(sources.len()),
1114            slice_checksums: sources
1115                .iter()
1116                .map(|source| Vec::with_capacity(source.slice_checksums.len()))
1117                .collect(),
1118            digests: Vec::new(),
1119        })
1120    }
1121
1122    fn finish_file(&mut self) -> Result<()> {
1123        let source =
1124            self.sources
1125                .get(self.file_index)
1126                .ok_or_else(|| Par2Error::InvalidCreationOptions {
1127                    reason: "fused source hashing ran past the recovery set".to_string(),
1128                })?;
1129        if self.full_hash.bytes_fed() != source.file_length {
1130            return Err(Par2Error::CreationSourceChanged {
1131                path: source.path.display().to_string(),
1132            });
1133        }
1134        let hash_16k = std::mem::replace(&mut self.first_hash, Md5State::new()).finalize();
1135        if hash_16k != source.hash_16k {
1136            return Err(Par2Error::CreationSourceChanged {
1137                path: source.path.display().to_string(),
1138            });
1139        }
1140        self.hash_full
1141            .push(std::mem::take(&mut self.full_hash).finalize());
1142        self.first_bytes = 0;
1143        self.file_index += 1;
1144        Ok(())
1145    }
1146
1147    /// Finalize every file and hand back the hashes.
1148    pub(crate) fn finish(mut self) -> Result<FusedSourceHashes> {
1149        while self.file_index < self.sources.len() {
1150            self.finish_file()?;
1151        }
1152        if self.next_source_index != self.slice_ends.last().copied().unwrap_or(0) {
1153            return Err(Par2Error::InvalidCreationOptions {
1154                reason: "fused source hashing did not see every source slice".to_string(),
1155            });
1156        }
1157        Ok(FusedSourceHashes {
1158            hash_full: self.hash_full,
1159            slice_checksums: self.slice_checksums,
1160        })
1161    }
1162}
1163
1164impl ForwardSourceObserver for FusedSourceHasher<'_> {
1165    fn observe_slices(&mut self, first_source_index: usize, slices: &[&[u8]]) -> Result<()> {
1166        if first_source_index != self.next_source_index {
1167            return Err(Par2Error::InvalidCreationOptions {
1168                reason: "fused source hashing needs the feed in source order".to_string(),
1169            });
1170        }
1171        if slices.len() > self.digests.len() {
1172            self.digests.resize(slices.len(), [0u8; 16]);
1173        }
1174        // The whole run is one multi-buffer pass: consecutive slices are
1175        // independent, zero-padded messages, which is exactly what the kernel
1176        // lanes. The whole-file stream below cannot be laned and stays serial.
1177        md5_simd::md5_multi_into(
1178            slices,
1179            Some(self.block_size),
1180            &mut self.digests[..slices.len()],
1181        );
1182        for (offset, bytes) in slices.iter().enumerate() {
1183            let index = first_source_index + offset;
1184            while self
1185                .slice_ends
1186                .get(self.file_index)
1187                .is_some_and(|&end| index >= end)
1188            {
1189                self.finish_file()?;
1190            }
1191            if self.file_index >= self.sources.len() {
1192                return Err(Par2Error::InvalidCreationOptions {
1193                    reason: "fused source hashing ran past the recovery set".to_string(),
1194                });
1195            }
1196            absorb_file_stream(
1197                &mut self.full_hash,
1198                &mut self.first_hash,
1199                &mut self.first_bytes,
1200                bytes,
1201            );
1202            let crc32 = checksum::crc32_padded(bytes, self.block_size);
1203            self.slice_checksums[self.file_index].push(SliceChecksum {
1204                crc32,
1205                md5: self.digests[offset],
1206            });
1207        }
1208        self.next_source_index = first_source_index + slices.len();
1209        Ok(())
1210    }
1211}
1212
1213/// Feed one contiguous, in-file-order run of source bytes to the two
1214/// whole-file digests.
1215///
1216/// Both are single serial MD5 streams over the entire file, so neither can be
1217/// laned: multi-buffer widens a batch of independent messages, not one message.
1218/// They stay scalar (the aws-lc backend when built with `crypto-aws-lc`) while
1219/// the per-slice digests beside them go through the SIMD kernel.
1220fn absorb_file_stream(
1221    full_hash: &mut FileHashState,
1222    first_hash: &mut Md5State,
1223    first_bytes: &mut u64,
1224    chunk: &[u8],
1225) {
1226    full_hash.update(chunk);
1227    if *first_bytes < FIRST_HASH_BYTES {
1228        let take = (FIRST_HASH_BYTES - *first_bytes).min(chunk.len() as u64) as usize;
1229        first_hash.update(&chunk[..take]);
1230        *first_bytes += take as u64;
1231    }
1232}
1233
1234fn read_exact_or_changed(file: &mut File, buffer: &mut [u8], input: &Path) -> Result<()> {
1235    file.read_exact(buffer).map_err(|error| {
1236        if error.kind() == io::ErrorKind::UnexpectedEof {
1237            Par2Error::CreationSourceChanged {
1238                path: input.display().to_string(),
1239            }
1240        } else {
1241            Par2Error::Io(error)
1242        }
1243    })
1244}
1245
1246fn report_progress(
1247    progress: Option<&ProgressCallback>,
1248    current: u32,
1249    total: u32,
1250    bytes_processed: u64,
1251    total_bytes: u64,
1252) {
1253    if let Some(progress) = progress {
1254        progress(crate::types::ProgressUpdate {
1255            stage: ProgressStage::Creating,
1256            current,
1257            total,
1258            bytes_processed,
1259            total_bytes: Some(total_bytes),
1260            phase: crate::types::ProgressPhase::SourceScan,
1261        });
1262    }
1263}
1264
1265#[cfg(test)]
1266mod tests {
1267    use super::*;
1268
1269    fn planned_source(
1270        par2_name: &str,
1271        file_length: u64,
1272        block_size: u64,
1273        head: &[u8],
1274    ) -> CreationSource {
1275        let mut first = Md5State::new();
1276        first.update(head);
1277        let hash_16k = first.finalize();
1278        let mut file_id_hash = Md5State::new();
1279        file_id_hash.update(&hash_16k);
1280        file_id_hash.update(&file_length.to_le_bytes());
1281        file_id_hash.update(par2_name.as_bytes());
1282        let slice_count = usize::try_from(file_length.div_ceil(block_size)).unwrap();
1283        CreationSource {
1284            path: PathBuf::from(par2_name),
1285            par2_name: par2_name.to_string(),
1286            file_id: FileId::from_bytes(file_id_hash.finalize()),
1287            file_length,
1288            hash_full: [0; 16],
1289            hash_16k,
1290            slice_checksums: vec![DEFERRED_SLICE_CHECKSUM; slice_count],
1291        }
1292    }
1293
1294    /// The whole-file digest is a serial message over a file's bytes in file
1295    /// order, so a feed that skips or reorders sources must be refused rather
1296    /// than producing a digest of the wrong byte sequence.
1297    #[test]
1298    fn the_fused_hasher_refuses_a_feed_that_is_not_in_source_order() {
1299        let payload = [7u8; 16];
1300        let sources = [planned_source("a.bin", 16, 8, &payload)];
1301        let mut hasher = FusedSourceHasher::new(&sources, 8).unwrap();
1302        assert!(matches!(
1303            hasher.observe_slices(1, &[&payload[8..]]),
1304            Err(Par2Error::InvalidCreationOptions { .. })
1305        ));
1306    }
1307
1308    /// The feed's bytes are cross-checked against the identity the plan was
1309    /// built on, so a source that changed between planning and creation is
1310    /// rejected instead of being written into a packet.
1311    #[test]
1312    fn the_fused_hasher_rejects_bytes_that_differ_from_the_planned_identity() {
1313        let payload = [7u8; 16];
1314        let sources = [planned_source("a.bin", 16, 8, &payload)];
1315        let mut hasher = FusedSourceHasher::new(&sources, 8).unwrap();
1316        let changed = [9u8; 16];
1317        hasher
1318            .observe_slices(0, &[&changed[..8], &changed[8..]])
1319            .unwrap();
1320        assert!(matches!(
1321            hasher.finish(),
1322            Err(Par2Error::CreationSourceChanged { .. })
1323        ));
1324    }
1325
1326    /// An unchanged feed produces exactly the digests a separate read does.
1327    #[test]
1328    fn the_fused_hasher_matches_a_direct_read_of_the_same_bytes() {
1329        let payload: Vec<u8> = (0..20u8).collect();
1330        let sources = [planned_source("a.bin", payload.len() as u64, 8, &payload)];
1331        let mut hasher = FusedSourceHasher::new(&sources, 8).unwrap();
1332        hasher
1333            .observe_slices(0, &[&payload[0..8], &payload[8..16], &payload[16..20]])
1334            .unwrap();
1335        let mut hydrated = sources.to_vec();
1336        hasher.finish().unwrap().apply(&mut hydrated).unwrap();
1337        assert_eq!(hydrated[0].hash_full, checksum::md5(&payload));
1338        assert_eq!(hydrated[0].slice_checksums.len(), 3);
1339        assert_eq!(
1340            hydrated[0].slice_checksums[2].md5,
1341            md5_simd::md5_multi(&[&payload[16..20]], Some(8))[0]
1342        );
1343        assert_eq!(
1344            hydrated[0].slice_checksums[2].crc32,
1345            checksum::crc32_padded(&payload[16..20], 8)
1346        );
1347    }
1348
1349    #[test]
1350    fn main_file_count_boundary_matches_parser_limit() {
1351        assert!(validate_main_file_count(MAX_FILES_PER_SET).is_ok());
1352        assert!(matches!(
1353            validate_main_file_count(MAX_FILES_PER_SET + 1),
1354            Err(Par2Error::ResourceLimitExceeded { .. })
1355        ));
1356    }
1357}