Skip to main content

par2_rs/create/
plan.rs

1use std::fs;
2use std::mem::size_of;
3use std::path::{Path, PathBuf};
4
5use crate::checksum::md5;
6use crate::error::{Par2Error, Result};
7use crate::types::{FileId, RecoveryExponent, RecoverySetId, SliceChecksum};
8
9use super::encode::{ForwardKernel, estimate_forward_memory};
10use super::metal::estimate_processing_memory;
11use super::options::{BlockSizing, CreationBackend, Par2CreatorOptions, RecoveryAmount};
12use super::output::{
13    TargetSnapshot, capture_target_snapshot, estimate_critical_packet_bytes,
14    estimate_packet_build_workspace_bytes, estimate_transaction_workspace_bytes,
15    estimate_validation_workspace_bytes,
16};
17use super::source::{CreationSource, InputLength, collect_input_lengths, collect_sources};
18use super::volume::{RecoveryVolumePlan, allocate_volumes};
19
20const AUTO_SOURCE_SLICE_TARGET: u64 = 2_000;
21const MAX_RECOVERY_EXPONENT: u32 = 65_535;
22const MEMORY_FLOOR_BYTES: usize = 256 * 1024 * 1024;
23const MEMORY_32_BIT_CAP_BYTES: usize = 1024 * 1024 * 1024;
24pub(crate) fn default_memory_limit() -> usize {
25    memory_limit_for(physical_memory_bytes(), usize::BITS)
26}
27
28fn memory_limit_for(physical_memory: Option<u64>, address_bits: u32) -> usize {
29    let mut limit = physical_memory
30        .and_then(|bytes| usize::try_from(bytes / 8).ok())
31        .unwrap_or(MEMORY_FLOOR_BYTES)
32        .max(MEMORY_FLOOR_BYTES);
33    if address_bits < 64 {
34        limit = limit.min(MEMORY_32_BIT_CAP_BYTES);
35    }
36    limit
37}
38
39pub(crate) fn controller_overhead_blocks(source_blocks: u32) -> usize {
40    2 + 24usize.min(source_blocks as usize + 1)
41}
42
43#[cfg(unix)]
44fn physical_memory_bytes() -> Option<u64> {
45    let pages = unsafe { libc::sysconf(libc::_SC_PHYS_PAGES) };
46    let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
47    if pages <= 0 || page_size <= 0 {
48        return None;
49    }
50    (pages as u64).checked_mul(page_size as u64)
51}
52
53#[cfg(target_os = "windows")]
54#[repr(C)]
55struct WindowsMemoryStatusEx {
56    length: u32,
57    memory_load: u32,
58    total_phys: u64,
59    available_phys: u64,
60    total_page_file: u64,
61    available_page_file: u64,
62    total_virtual: u64,
63    available_virtual: u64,
64    available_extended_virtual: u64,
65}
66
67#[cfg(target_os = "windows")]
68unsafe extern "system" {
69    fn GlobalMemoryStatusEx(status: *mut WindowsMemoryStatusEx) -> i32;
70}
71
72#[cfg(target_os = "windows")]
73fn physical_memory_bytes() -> Option<u64> {
74    let mut status = WindowsMemoryStatusEx {
75        length: std::mem::size_of::<WindowsMemoryStatusEx>() as u32,
76        memory_load: 0,
77        total_phys: 0,
78        available_phys: 0,
79        total_page_file: 0,
80        available_page_file: 0,
81        total_virtual: 0,
82        available_virtual: 0,
83        available_extended_virtual: 0,
84    };
85    // SAFETY: the Windows API writes exactly the documented structure into a
86    // valid, size-initialized mutable buffer, and does not retain the pointer.
87    let success = unsafe { GlobalMemoryStatusEx(&mut status) } != 0;
88    success.then_some(status.total_phys)
89}
90
91#[cfg(not(any(unix, target_os = "windows")))]
92fn physical_memory_bytes() -> Option<u64> {
93    None
94}
95
96/// The memory quantities used by one creation pass.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub struct Par2MemoryPlan {
99    /// Retained source descriptions, hashes, names, and per-slice checksums.
100    /// This does not include source file contents.
101    pub source_metadata_bytes: usize,
102    /// Temporary hashing buffers and source-index collections.
103    pub source_hash_workspace_bytes: usize,
104    /// Critical packet bytes retained while staged outputs are assembled.
105    pub critical_packet_bytes: usize,
106    /// Temporary Main packet file-ID body retained while critical packets are built.
107    pub main_file_id_workspace_bytes: usize,
108    /// Largest temporary padded FileDesc or IFSC body, including its Vec controller.
109    /// Main packet body workspace is accounted separately above.
110    pub packet_build_workspace_bytes: usize,
111    /// Transaction and provider bookkeeping retained beside critical packets.
112    pub transaction_workspace_bytes: usize,
113    /// Scanner, parsed-packet, and file-backed recovery-hash workspace used
114    /// while one staged volume is validated.
115    pub validation_workspace_bytes: usize,
116    /// Processing-buffer budget passed to the forward encoder.
117    pub processing_buffer_limit_bytes: usize,
118    /// Conservative peak working-set bound for the forward processing buffers.
119    pub processing_peak_bytes: usize,
120    /// Conservative peak bound for the complete creation operation.
121    pub total_creation_peak_bytes: usize,
122    /// Source constants and one active kernel's factor preparation storage.
123    /// Each accumulation band holds its own ~2 KiB of transient kernel
124    /// temporaries; those are deliberately excluded so this value never
125    /// scales with recovery-row or thread count.
126    pub factor_workspace_bytes: usize,
127    /// Peak executable-code and JIT build bookkeeping storage.
128    pub jit_workspace_bytes: usize,
129    /// Stripe staging, transfer, and recovery-output buffers.
130    pub stripe_buffer_bytes: usize,
131    /// Controller buffer units used by the creation memory calculation.
132    pub controller_overhead_blocks: usize,
133}
134
135/// Fully validated creation inputs and deterministic output allocation.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct Par2CreatePlan {
138    /// Canonical base directory used for source resolution and packet names.
139    pub base_path: PathBuf,
140    /// Canonical output stem without the final par2 suffix.
141    pub output_stem: PathBuf,
142    /// Main critical-packet output path.
143    pub main_path: PathBuf,
144    /// Recovery volume allocations, in increasing exponent order.
145    pub volumes: Vec<RecoveryVolumePlan>,
146    /// Recovery volume sizing policy used for the allocation.
147    pub volume_scheme: super::options::VolumeScheme,
148    /// Recovery volume paths, matching the volume allocations.
149    pub volume_paths: Vec<PathBuf>,
150    /// All output paths, with the main file first.
151    pub output_paths: Vec<PathBuf>,
152    /// Target states authorized by the validated planning pass.
153    pub(crate) target_snapshots: Vec<TargetSnapshot>,
154    /// Sources sorted by PAR2 file identifier, which is also encoder input order.
155    pub sources: Vec<CreationSource>,
156    /// Source slice size in bytes.
157    pub slice_size: u64,
158    /// Total number of source slices across all files.
159    pub source_slice_count: u32,
160    /// Number of recovery slices.
161    pub recovery_count: u32,
162    /// Exponent assigned to the first recovery slice.
163    pub first_exponent: RecoveryExponent,
164    /// Recovery exponents in output order.
165    pub recovery_exponents: Vec<RecoveryExponent>,
166    /// Recovery-set identifier from the Main packet body.
167    pub recovery_set_id: RecoverySetId,
168    /// Forward arithmetic path selected when this plan was built.
169    pub forward_kernel: ForwardKernel,
170    /// Creation backend policy requested for this plan.
171    pub backend: CreationBackend,
172    /// Memory accounting for this plan.
173    pub memory: Par2MemoryPlan,
174    /// Whether the caller requested a write-free operation.
175    pub dry_run: bool,
176    /// Inputs excluded from the set because they are zero-length, in input
177    /// order, as the caller spelled them.
178    ///
179    /// A PAR2 set cannot describe an empty file (the format protects slices
180    /// and an empty file has none), so these inputs are not in `sources`, get
181    /// no packets, and are invisible to verify and repair. The reference
182    /// encoder makes the same exclusion and reports it unconditionally
183    /// ("Skipping 0 byte file"); callers that surface plans to a human should
184    /// do the same with this list, because a set that silently protects fewer
185    /// files than were listed reads as protection it does not provide.
186    pub skipped_empty: Vec<PathBuf>,
187}
188
189impl Par2CreatePlan {
190    /// Return the number of critical source files in the set.
191    pub fn file_count(&self) -> usize {
192        self.sources.len()
193    }
194
195    /// Return the number of recovery volume files.
196    pub fn volume_count(&self) -> usize {
197        self.volumes.len()
198    }
199
200    pub(crate) fn validate_integrity(&self) -> Result<()> {
201        if self.sources.is_empty()
202            || self.output_paths.len() != self.volume_paths.len() + 1
203            || self.output_paths.first() != Some(&self.main_path)
204            || self.volumes.len() != self.volume_paths.len()
205            || self.target_snapshots.len() != self.output_paths.len()
206            || self.recovery_exponents.len() != self.recovery_count as usize
207        {
208            return Err(Par2Error::InvalidCreationOptions {
209                reason: "creation plan structure is inconsistent".to_string(),
210            });
211        }
212        if self.slice_size == 0 || !self.slice_size.is_multiple_of(4) {
213            return Err(Par2Error::InvalidCreationOptions {
214                reason: "creation plan has an invalid slice size".to_string(),
215            });
216        }
217        if self.first_exponent > 32_768 {
218            return Err(Par2Error::InvalidCreationOptions {
219                reason: "creation plan first exponent is out of range".to_string(),
220            });
221        }
222        let expected_end = self
223            .first_exponent
224            .checked_add(self.recovery_count)
225            .filter(|end| *end < 65_536)
226            .ok_or_else(|| Par2Error::InvalidCreationOptions {
227                reason: "creation plan recovery exponent range is out of range".to_string(),
228            })?;
229        if self
230            .sources
231            .windows(2)
232            .any(|pair| pair[0].file_id >= pair[1].file_id)
233        {
234            return Err(Par2Error::InvalidCreationOptions {
235                reason: "creation plan sources are not strictly sorted".to_string(),
236            });
237        }
238        let source_slice_count = self.sources.iter().try_fold(0u32, |total, source| {
239            total.checked_add(source.slice_count()).ok_or_else(|| {
240                Par2Error::InvalidCreationOptions {
241                    reason: "creation plan source slice count overflows".to_string(),
242                }
243            })
244        })?;
245        if source_slice_count != self.source_slice_count {
246            return Err(Par2Error::InvalidCreationOptions {
247                reason: "creation plan source slice count differs from sources".to_string(),
248            });
249        }
250        let cpu_memory = estimate_forward_memory(
251            self.slice_size,
252            self.source_slice_count as usize,
253            self.recovery_count as usize,
254            self.memory.processing_buffer_limit_bytes,
255            self.forward_kernel,
256        )?;
257        let forward_memory = estimate_processing_memory(
258            self.backend,
259            usize::try_from(self.slice_size).map_err(|_| Par2Error::ResourceLimitExceeded {
260                reason: "slice size exceeds addressable memory".to_string(),
261            })?,
262            self.source_slice_count as usize,
263            self.recovery_count as usize,
264            self.memory.processing_buffer_limit_bytes,
265            cpu_memory,
266        )?;
267        let expected_memory = memory_plan_for(
268            &self.sources,
269            self.sources.len(),
270            self.source_slice_count,
271            self.slice_size,
272            MemoryPlanPaths {
273                base_path: &self.base_path,
274                output_stem: &self.output_stem,
275                main_path: &self.main_path,
276                output_paths: &self.output_paths,
277            },
278            &self.volumes,
279            self.memory.processing_buffer_limit_bytes,
280            forward_memory,
281        )?;
282        if self.memory != expected_memory {
283            return Err(Par2Error::InvalidCreationOptions {
284                reason: "creation plan memory estimate differs from sources".to_string(),
285            });
286        }
287        let output_parent =
288            self.main_path
289                .parent()
290                .ok_or_else(|| Par2Error::InvalidCreationOptions {
291                    reason: "creation plan main path has no parent".to_string(),
292                })?;
293        if self.output_stem.parent() != Some(output_parent)
294            || self.output_stem.file_name().is_none()
295            || self.main_path.file_stem() != self.output_stem.file_name()
296            || self
297                .main_path
298                .extension()
299                .and_then(|extension| extension.to_str())
300                .is_none_or(|extension| !extension.eq_ignore_ascii_case("par2"))
301            || self
302                .volume_paths
303                .iter()
304                .zip(&self.volumes)
305                .any(|(path, volume)| path != &output_parent.join(&volume.filename))
306        {
307            return Err(Par2Error::InvalidCreationOptions {
308                reason: "creation plan output paths do not match the naming contract".to_string(),
309            });
310        }
311        let mut main_body = Vec::with_capacity(12 + self.sources.len() * 16);
312        main_body.extend_from_slice(&self.slice_size.to_le_bytes());
313        main_body.extend_from_slice(&(self.sources.len() as u32).to_le_bytes());
314        for source in &self.sources {
315            main_body.extend_from_slice(source.file_id.as_bytes());
316        }
317        if RecoverySetId::from_bytes(md5(&main_body)) != self.recovery_set_id {
318            return Err(Par2Error::InvalidCreationOptions {
319                reason: "creation plan recovery-set identifier differs from Main packet"
320                    .to_string(),
321            });
322        }
323        let mut exponent = self.first_exponent;
324        for (index, volume) in self.volumes.iter().enumerate() {
325            if self.output_paths[index + 1] != self.volume_paths[index]
326                || volume.first_exponent != exponent
327                || volume.recovery_count == 0
328            {
329                return Err(Par2Error::InvalidCreationOptions {
330                    reason: "creation plan volume allocation is inconsistent".to_string(),
331                });
332            }
333            exponent = exponent.checked_add(volume.recovery_count).ok_or_else(|| {
334                Par2Error::InvalidCreationOptions {
335                    reason: "creation plan recovery exponent range overflows".to_string(),
336                }
337            })?;
338        }
339        if exponent != expected_end {
340            return Err(Par2Error::InvalidCreationOptions {
341                reason: "creation plan recovery allocation does not sum to recovery count"
342                    .to_string(),
343            });
344        }
345        for (offset, exponent) in self.recovery_exponents.iter().enumerate() {
346            let expected = self.first_exponent.checked_add(offset as u32);
347            if expected != Some(*exponent) {
348                return Err(Par2Error::InvalidCreationOptions {
349                    reason: "creation plan recovery exponents are not contiguous".to_string(),
350                });
351            }
352        }
353        Ok(())
354    }
355}
356
357struct MemoryPlanPaths<'a> {
358    base_path: &'a Path,
359    output_stem: &'a Path,
360    main_path: &'a Path,
361    output_paths: &'a [PathBuf],
362}
363
364#[allow(clippy::too_many_arguments)]
365fn memory_plan_for(
366    sources: &[CreationSource],
367    input_count: usize,
368    source_slice_count: u32,
369    block_size: u64,
370    paths: MemoryPlanPaths<'_>,
371    volumes: &[RecoveryVolumePlan],
372    processing_buffer_limit_bytes: usize,
373    forward_memory: super::encode::ForwardMemoryEstimate,
374) -> Result<Par2MemoryPlan> {
375    if processing_buffer_limit_bytes == 0 {
376        return Err(Par2Error::ResourceLimitExceeded {
377            reason: "memory limit must be greater than zero".to_string(),
378        });
379    }
380    let source_metadata_bytes = estimate_source_metadata_bytes(sources, input_count)?;
381    let source_hash_workspace_bytes = estimate_source_hash_workspace(input_count, block_size)?;
382    let critical_packet_bytes = estimate_critical_packet_bytes(sources)?;
383    let main_file_id_workspace_bytes = estimate_main_file_id_workspace_bytes(sources)?;
384    let packet_build_workspace_bytes = estimate_packet_build_workspace_bytes(sources)?;
385    let transaction_workspace_bytes = estimate_transaction_workspace_bytes(
386        paths.base_path,
387        paths.output_stem,
388        paths.main_path,
389        paths.output_paths,
390        volumes,
391        sources,
392        volumes
393            .iter()
394            .try_fold(0u32, |total, volume| {
395                total.checked_add(volume.recovery_count)
396            })
397            .ok_or_else(|| Par2Error::ResourceLimitExceeded {
398                reason: "recovery volume count estimate overflows".to_string(),
399            })?,
400    )?;
401    let validation_workspace_bytes = estimate_validation_workspace_bytes(
402        sources,
403        paths.output_paths,
404        volumes,
405        critical_packet_bytes,
406    )?;
407    let plan_phase = checked_memory_add(
408        source_metadata_bytes,
409        source_hash_workspace_bytes,
410        "source creation estimate overflows",
411    )?;
412    let create_phase = [
413        source_hash_workspace_bytes,
414        checked_memory_mul(
415            source_metadata_bytes,
416            2,
417            "source creation estimate overflows",
418        )?,
419        critical_packet_bytes,
420        main_file_id_workspace_bytes,
421        packet_build_workspace_bytes,
422        transaction_workspace_bytes,
423        forward_memory.processing_peak_bytes,
424    ]
425    .into_iter()
426    .try_fold(0usize, |total, bytes| {
427        checked_memory_add(total, bytes, "creation peak estimate overflows")
428    })?;
429    let validation_phase = [
430        checked_memory_mul(
431            source_metadata_bytes,
432            2,
433            "validation source metadata estimate overflows",
434        )?,
435        critical_packet_bytes,
436        main_file_id_workspace_bytes,
437        packet_build_workspace_bytes,
438        transaction_workspace_bytes,
439        validation_workspace_bytes,
440    ]
441    .into_iter()
442    .try_fold(0usize, |total, bytes| {
443        checked_memory_add(total, bytes, "validation peak estimate overflows")
444    })?;
445
446    Ok(Par2MemoryPlan {
447        source_metadata_bytes,
448        source_hash_workspace_bytes,
449        critical_packet_bytes,
450        main_file_id_workspace_bytes,
451        packet_build_workspace_bytes,
452        transaction_workspace_bytes,
453        validation_workspace_bytes,
454        processing_buffer_limit_bytes,
455        processing_peak_bytes: forward_memory.processing_peak_bytes,
456        total_creation_peak_bytes: plan_phase.max(create_phase).max(validation_phase),
457        factor_workspace_bytes: forward_memory.factor_workspace_bytes,
458        jit_workspace_bytes: forward_memory.jit_workspace_bytes,
459        stripe_buffer_bytes: forward_memory.stripe_buffer_bytes,
460        controller_overhead_blocks: controller_overhead_blocks(source_slice_count),
461    })
462}
463
464fn estimate_source_metadata_bytes(sources: &[CreationSource], capacity: usize) -> Result<usize> {
465    let mut total = checked_memory_mul(
466        capacity,
467        size_of::<CreationSource>(),
468        "source metadata estimate overflows",
469    )?;
470    for source in sources {
471        total = checked_memory_add(
472            total,
473            source
474                .path
475                .as_os_str()
476                .len()
477                .checked_add(64)
478                .ok_or_else(|| Par2Error::ResourceLimitExceeded {
479                    reason: "source path estimate overflows".to_string(),
480                })?,
481            "source path estimate overflows",
482        )?;
483        total = checked_memory_add(
484            total,
485            source.par2_name.len().checked_add(64).ok_or_else(|| {
486                Par2Error::ResourceLimitExceeded {
487                    reason: "source name estimate overflows".to_string(),
488                }
489            })?,
490            "source name estimate overflows",
491        )?;
492        total = checked_memory_add(
493            total,
494            checked_memory_mul(
495                source.slice_checksums.len(),
496                size_of::<SliceChecksum>(),
497                "source checksum estimate overflows",
498            )?,
499            "source metadata estimate overflows",
500        )?;
501    }
502    Ok(total)
503}
504
505fn estimate_source_hash_workspace(input_count: usize, block_size: u64) -> Result<usize> {
506    const READ_BUFFER_BYTES: usize = 256 * 1024;
507    const HASH_SET_ENTRY_RESERVE_BYTES: usize = 128;
508    let input_lengths = checked_memory_mul(
509        input_count,
510        size_of::<InputLength>(),
511        "source length estimate overflows",
512    )?;
513    let hash_sets = checked_memory_mul(
514        checked_memory_mul(
515            input_count,
516            HASH_SET_ENTRY_RESERVE_BYTES,
517            "source index estimate overflows",
518        )?,
519        2,
520        "source index estimate overflows",
521    )?;
522    // Source hashing runs one file per rayon task, each with its own read
523    // buffer; the gate mirrors the parallel scan's split in source.rs and
524    // the +1 covers the calling thread. Process-stable thread count only.
525    let threads = super::encode::configured_create_threads();
526    let concurrent_reads = if threads == 1 || input_count <= 1 {
527        1
528    } else {
529        input_count.min(threads.saturating_add(1))
530    };
531    // A task either stages a batch of slices for the multi-buffer slice-hash
532    // kernel or streams one slice through a single read buffer, whichever
533    // `create_md5_batch_lanes` selected for this block size. Mirrors the split
534    // in `source.rs` exactly; the two must move together or the plan's
535    // self-consistency check in `Par2CreatePlan` fails.
536    // `READ_BUFFER_BYTES` stays a floor rather than the exact figure: the
537    // streaming arm allocates only `min(READ_BUFFER_BYTES, block_size)`, so
538    // this estimate has always been an upper bound for small blocks, and
539    // tightening it here would move a reported number for no benefit.
540    let block_size_usize = usize::try_from(block_size).unwrap_or(usize::MAX);
541    let lanes = super::source::create_md5_batch_lanes(block_size_usize);
542    let per_task_bytes = if lanes >= 2 {
543        checked_memory_mul(
544            lanes,
545            block_size_usize,
546            "source hash batch estimate overflows",
547        )?
548        .max(READ_BUFFER_BYTES)
549    } else {
550        READ_BUFFER_BYTES
551    };
552    let read_buffers = checked_memory_mul(
553        per_task_bytes,
554        concurrent_reads,
555        "concurrent source read buffer estimate overflows",
556    )?;
557    [read_buffers, input_lengths, hash_sets]
558        .into_iter()
559        .try_fold(0usize, |total, bytes| {
560            checked_memory_add(total, bytes, "source hashing estimate overflows")
561        })
562}
563
564fn estimate_main_file_id_workspace_bytes(sources: &[CreationSource]) -> Result<usize> {
565    checked_memory_add(
566        size_of::<Vec<u8>>(),
567        checked_memory_add(
568            12,
569            checked_memory_mul(
570                sources.len(),
571                size_of::<FileId>(),
572                "Main packet file-ID estimate overflows",
573            )?,
574            "Main packet file-ID estimate overflows",
575        )?,
576        "Main packet file-ID estimate overflows",
577    )
578}
579
580fn checked_memory_add(left: usize, right: usize, reason: &'static str) -> Result<usize> {
581    left.checked_add(right)
582        .ok_or_else(|| Par2Error::ResourceLimitExceeded {
583            reason: reason.to_string(),
584        })
585}
586
587fn checked_memory_mul(left: usize, right: usize, reason: &'static str) -> Result<usize> {
588    left.checked_mul(right)
589        .ok_or_else(|| Par2Error::ResourceLimitExceeded {
590            reason: reason.to_string(),
591        })
592}
593
594/// Build a creation plan, optionally reusing the creator's source-scan memo.
595///
596/// `Par2Creator` passes the same memo to `plan()` and to `create()`, so the
597/// canonical rebuild inside `create()` re-validates every input by `stat` but
598/// reads and hashes only what actually changed since planning. `None` is the
599/// unmemoized behavior: every input is read and hashed.
600pub(crate) fn build_plan_with_cache(
601    options: &Par2CreatorOptions,
602    cache: Option<&super::source::SourceScanCache>,
603) -> Result<Par2CreatePlan> {
604    if options.cancellation.is_cancelled() {
605        return Err(Par2Error::Cancelled);
606    }
607    let output = options
608        .output
609        .as_ref()
610        .ok_or_else(|| Par2Error::InvalidCreationOptions {
611            reason: "an output path or stem is required".to_string(),
612        })?;
613    let (output_parent, output_stem, main_path, stem_name) = normalize_output(output)?;
614    let base_path = match &options.base_path {
615        Some(path) => canonical_source_directory(path)?,
616        None => output_parent.clone(),
617    };
618    let input_lengths = collect_input_lengths(&base_path, &options.inputs, &options.cancellation)?;
619    let total_bytes = input_lengths.iter().try_fold(0u64, |total, input| {
620        total
621            .checked_add(input.length)
622            .ok_or_else(|| Par2Error::ResourceLimitExceeded {
623                reason: "source byte count overflows".to_string(),
624            })
625    })?;
626    let block_size = choose_block_size(&input_lengths, options.block_sizing)?;
627    let collected = collect_sources(
628        &base_path,
629        &options.inputs,
630        block_size,
631        &options.cancellation,
632        options.progress.as_ref(),
633        total_bytes,
634        cache,
635    )?;
636    let skipped_empty = collected.skipped_empty;
637    let mut sources = collected.sources;
638    sources.sort_by_key(|source| source.file_id);
639    if options.cancellation.is_cancelled() {
640        return Err(Par2Error::Cancelled);
641    }
642
643    let source_slice_count = sources.iter().try_fold(0u32, |total, source| {
644        total
645            .checked_add(source.slice_count())
646            .ok_or_else(|| Par2Error::ResourceLimitExceeded {
647                reason: "source slice count overflows u32".to_string(),
648            })
649    })?;
650    let recovery_count = choose_recovery_count(
651        source_slice_count,
652        options.recovery_amount,
653        options.first_exponent,
654    )?;
655    validate_exponent_range(options.first_exponent, recovery_count)?;
656
657    let forward_memory_limit = options.memory_limit.unwrap_or_else(default_memory_limit);
658    if forward_memory_limit == 0 {
659        return Err(Par2Error::ResourceLimitExceeded {
660            reason: "memory limit must be greater than zero".to_string(),
661        });
662    }
663    let cpu_memory = estimate_forward_memory(
664        block_size,
665        source_slice_count as usize,
666        recovery_count as usize,
667        forward_memory_limit,
668        options.forward_kernel,
669    )?;
670    let forward_memory = estimate_processing_memory(
671        options.backend,
672        usize::try_from(block_size).map_err(|_| Par2Error::ResourceLimitExceeded {
673            reason: "slice size exceeds addressable memory".to_string(),
674        })?,
675        source_slice_count as usize,
676        recovery_count as usize,
677        forward_memory_limit,
678        cpu_memory,
679    )?;
680    let volumes = allocate_volumes(
681        options.first_exponent,
682        recovery_count,
683        options.volume_count,
684        options.volume_scheme,
685        &stem_name,
686        sources
687            .iter()
688            .map(|source| source.file_length)
689            .max()
690            .unwrap_or(0),
691        block_size,
692    )?;
693    let volume_paths = volumes
694        .iter()
695        .map(|volume| output_parent.join(&volume.filename))
696        .collect::<Vec<_>>();
697    let mut output_paths = Vec::with_capacity(volume_paths.len() + 1);
698    output_paths.push(main_path.clone());
699    output_paths.extend(volume_paths.iter().cloned());
700    let target_snapshots = validate_output_targets(&output_paths, &sources, options.overwrite)?;
701
702    let mut main_body = Vec::with_capacity(12 + sources.len() * 16);
703    main_body.extend_from_slice(&block_size.to_le_bytes());
704    main_body.extend_from_slice(&(sources.len() as u32).to_le_bytes());
705    for source in &sources {
706        main_body.extend_from_slice(source.file_id.as_bytes());
707    }
708    let recovery_set_id = RecoverySetId::from_bytes(md5(&main_body));
709    let recovery_exponents = (0..recovery_count)
710        .map(|offset| options.first_exponent + offset)
711        .collect();
712    let memory = memory_plan_for(
713        &sources,
714        input_lengths.len(),
715        source_slice_count,
716        block_size,
717        MemoryPlanPaths {
718            base_path: &base_path,
719            output_stem: &output_stem,
720            main_path: &main_path,
721            output_paths: &output_paths,
722        },
723        &volumes,
724        forward_memory_limit,
725        forward_memory,
726    )?;
727
728    Ok(Par2CreatePlan {
729        base_path,
730        output_stem,
731        main_path,
732        volumes,
733        volume_scheme: options.volume_scheme,
734        volume_paths,
735        output_paths,
736        target_snapshots,
737        sources,
738        slice_size: block_size,
739        source_slice_count,
740        recovery_count,
741        first_exponent: options.first_exponent,
742        recovery_exponents,
743        recovery_set_id,
744        forward_kernel: options.forward_kernel,
745        backend: options.backend,
746        memory,
747        dry_run: options.dry_run,
748        skipped_empty,
749    })
750}
751
752fn canonical_directory(path: &Path, label: &str) -> Result<PathBuf> {
753    let canonical = fs::canonicalize(path).map_err(|error| Par2Error::UnsafeCreationOutput {
754        path: path.display().to_string(),
755        reason: format!("{label} cannot be resolved: {error}"),
756    })?;
757    if !fs::metadata(&canonical).map_err(Par2Error::Io)?.is_dir() {
758        return Err(Par2Error::UnsafeCreationOutput {
759            path: path.display().to_string(),
760            reason: format!("{label} is not a directory"),
761        });
762    }
763    Ok(canonical)
764}
765
766fn canonical_source_directory(path: &Path) -> Result<PathBuf> {
767    let canonical = fs::canonicalize(path).map_err(|error| Par2Error::UnsafeCreationSource {
768        path: path.display().to_string(),
769        reason: format!("base path cannot be resolved: {error}"),
770    })?;
771    if !fs::metadata(&canonical).map_err(Par2Error::Io)?.is_dir() {
772        return Err(Par2Error::UnsafeCreationSource {
773            path: path.display().to_string(),
774            reason: "base path is not a directory".to_string(),
775        });
776    }
777    Ok(canonical)
778}
779
780fn normalize_output(output: &Path) -> Result<(PathBuf, PathBuf, PathBuf, String)> {
781    let file_name = output
782        .file_name()
783        .and_then(|name| name.to_str())
784        .ok_or_else(|| Par2Error::UnsafeCreationOutput {
785            path: output.display().to_string(),
786            reason: "output must have a valid UTF-8 filename".to_string(),
787        })?;
788    if file_name.is_empty() || file_name == "." || file_name == ".." || file_name.contains('\0') {
789        return Err(Par2Error::UnsafeCreationOutput {
790            path: output.display().to_string(),
791            reason: "output filename is empty or unsafe".to_string(),
792        });
793    }
794    let parent = output
795        .parent()
796        .filter(|path| !path.as_os_str().is_empty())
797        .unwrap_or_else(|| Path::new("."));
798    let parent = canonical_directory(parent, "output directory")?;
799    let (stem_name, main_name) = match file_name.rsplit_once('.') {
800        Some((stem, extension)) if extension.eq_ignore_ascii_case("par2") => {
801            if stem.is_empty() {
802                return Err(Par2Error::UnsafeCreationOutput {
803                    path: output.display().to_string(),
804                    reason: "output stem is empty".to_string(),
805                });
806            }
807            (stem.to_string(), file_name.to_string())
808        }
809        _ => (file_name.to_string(), format!("{file_name}.par2")),
810    };
811    let stem_path = parent.join(&stem_name);
812    let main_path = parent.join(main_name);
813    Ok((parent, stem_path, main_path, stem_name))
814}
815
816pub(crate) fn validate_output_targets(
817    output_paths: &[PathBuf],
818    sources: &[CreationSource],
819    overwrite: bool,
820) -> Result<Vec<TargetSnapshot>> {
821    let mut snapshots = Vec::with_capacity(output_paths.len());
822    for (index, target) in output_paths.iter().enumerate() {
823        if output_paths[..index].iter().any(|other| other == target) {
824            return Err(Par2Error::UnsafeCreationOutput {
825                path: target.display().to_string(),
826                reason: "output paths are not unique".to_string(),
827            });
828        }
829        if sources.iter().any(|source| source.path == *target) {
830            return Err(Par2Error::UnsafeCreationOutput {
831                path: target.display().to_string(),
832                reason: "output would replace an explicit source file".to_string(),
833            });
834        }
835        let snapshot = capture_target_snapshot(target).map_err(Par2Error::Io)?;
836        match snapshot {
837            TargetSnapshot::Directory => {
838                return Err(Par2Error::UnsafeCreationOutput {
839                    path: target.display().to_string(),
840                    reason: "output path is a directory".to_string(),
841                });
842            }
843            TargetSnapshot::Symlink => {
844                return Err(Par2Error::UnsafeCreationOutput {
845                    path: target.display().to_string(),
846                    reason: "output path is a symlink".to_string(),
847                });
848            }
849            TargetSnapshot::Special => {
850                return Err(Par2Error::UnsafeCreationOutput {
851                    path: target.display().to_string(),
852                    reason: "output path is not a regular file".to_string(),
853                });
854            }
855            TargetSnapshot::File(_) if !overwrite => {
856                return Err(Par2Error::CreationOutputExists {
857                    path: target.display().to_string(),
858                });
859            }
860            TargetSnapshot::Absent | TargetSnapshot::File(_) => {}
861        }
862        snapshots.push(snapshot);
863    }
864    Ok(snapshots)
865}
866
867fn choose_block_size(lengths: &[InputLength], sizing: BlockSizing) -> Result<u64> {
868    match sizing {
869        BlockSizing::Bytes(bytes) => validate_block_size(bytes),
870        BlockSizing::Count(count) => {
871            if count == 0 {
872                return Err(Par2Error::InvalidCreationOptions {
873                    reason: "block count must be greater than zero".to_string(),
874                });
875            }
876            if (count as usize) < lengths.len() {
877                return Err(Par2Error::InvalidCreationOptions {
878                    reason: format!(
879                        "block count {count} is smaller than the source file count {}",
880                        lengths.len()
881                    ),
882                });
883            }
884            if count as usize == lengths.len() {
885                return largest_rounded_block_size(lengths);
886            }
887            smallest_block_for_count(lengths, count as u64)
888        }
889        BlockSizing::Auto => automatic_block_size(lengths),
890    }
891}
892
893fn automatic_block_size(lengths: &[InputLength]) -> Result<u64> {
894    let total_bytes = lengths.iter().try_fold(0u64, |total, input| {
895        total
896            .checked_add(input.length)
897            .ok_or_else(|| Par2Error::ResourceLimitExceeded {
898                reason: "source byte count overflows".to_string(),
899            })
900    })?;
901    let target = total_bytes
902        .checked_add(AUTO_SOURCE_SLICE_TARGET - 1)
903        .ok_or_else(|| Par2Error::ResourceLimitExceeded {
904            reason: "automatic block-size rounding overflows".to_string(),
905        })?
906        / AUTO_SOURCE_SLICE_TARGET;
907    let minimum =
908        total_bytes
909            .checked_add(32_768 - 1)
910            .ok_or_else(|| Par2Error::ResourceLimitExceeded {
911                reason: "automatic block-size limit rounding overflows".to_string(),
912            })?
913            / 32_768;
914    round_block_size(target.max(minimum).max(4))
915}
916
917fn largest_rounded_block_size(lengths: &[InputLength]) -> Result<u64> {
918    let largest = lengths.iter().map(|input| input.length).max().unwrap_or(0);
919    round_block_size(largest.max(4))
920}
921
922fn round_block_size(bytes: u64) -> Result<u64> {
923    bytes
924        .checked_add(3)
925        .map(|value| value / 4 * 4)
926        .ok_or_else(|| Par2Error::ResourceLimitExceeded {
927            reason: "block size rounding overflows".to_string(),
928        })
929}
930
931fn validate_block_size(block_size: u64) -> Result<u64> {
932    if block_size == 0 || !block_size.is_multiple_of(4) {
933        return Err(Par2Error::InvalidCreationOptions {
934            reason: format!("block size {block_size} is not a positive multiple of four"),
935        });
936    }
937    Ok(block_size)
938}
939
940fn smallest_block_for_count(lengths: &[InputLength], target: u64) -> Result<u64> {
941    let maximum = lengths.iter().try_fold(4u64, |maximum, input| {
942        let rounded =
943            input
944                .length
945                .checked_add(3)
946                .ok_or_else(|| Par2Error::ResourceLimitExceeded {
947                    reason: "source length rounding overflows".to_string(),
948                })?
949                / 4
950                * 4;
951        Ok::<u64, Par2Error>(maximum.max(rounded))
952    })?;
953    let mut low = 4u64;
954    let mut high = maximum;
955    while low < high {
956        let low_units = low / 4;
957        let high_units = high / 4;
958        let mid = (low_units + (high_units - low_units) / 2) * 4;
959        if count_for_block(lengths, mid)? <= target {
960            high = mid;
961        } else {
962            low = mid + 4;
963        }
964    }
965    Ok(low)
966}
967
968fn count_for_block(lengths: &[InputLength], block_size: u64) -> Result<u64> {
969    lengths.iter().try_fold(0u64, |total, input| {
970        let count = if input.length == 0 {
971            0
972        } else {
973            (input.length - 1) / block_size + 1
974        };
975        total
976            .checked_add(count)
977            .ok_or_else(|| Par2Error::ResourceLimitExceeded {
978                reason: "source slice count overflows".to_string(),
979            })
980    })
981}
982
983fn choose_recovery_count(
984    source_slice_count: u32,
985    amount: RecoveryAmount,
986    first_exponent: RecoveryExponent,
987) -> Result<u32> {
988    match amount {
989        RecoveryAmount::Count(count) => {
990            if count > 32_768 {
991                return Err(Par2Error::InvalidCreationOptions {
992                    reason: "explicit recovery count cannot exceed 32768".to_string(),
993                });
994            }
995            Ok(count)
996        }
997        RecoveryAmount::Percent(percent) => {
998            let scaled = (source_slice_count as u64)
999                .checked_mul(percent as u64)
1000                .and_then(|value| value.checked_add(50))
1001                .ok_or_else(|| Par2Error::ResourceLimitExceeded {
1002                    reason: "percentage recovery count overflows".to_string(),
1003                })?;
1004            let mut count = scaled / 100;
1005            if percent > 0 && count == 0 {
1006                count = 1;
1007            }
1008            let count = u32::try_from(count).map_err(|_| Par2Error::InvalidCreationOptions {
1009                reason: "percentage recovery count exceeds the exponent range".to_string(),
1010            })?;
1011            validate_exponent_range(first_exponent, count)?;
1012            Ok(count)
1013        }
1014    }
1015}
1016
1017fn validate_exponent_range(first_exponent: RecoveryExponent, count: u32) -> Result<()> {
1018    if first_exponent > 32_768 {
1019        return Err(Par2Error::InvalidCreationOptions {
1020            reason: "first recovery exponent cannot exceed 32768".to_string(),
1021        });
1022    }
1023    let end =
1024        first_exponent
1025            .checked_add(count)
1026            .ok_or_else(|| Par2Error::InvalidCreationOptions {
1027                reason: "recovery exponent range overflows".to_string(),
1028            })?;
1029    if end > MAX_RECOVERY_EXPONENT {
1030        return Err(Par2Error::InvalidCreationOptions {
1031            reason: "first recovery exponent plus count must be less than 65536".to_string(),
1032        });
1033    }
1034    Ok(())
1035}
1036
1037#[cfg(test)]
1038mod tests {
1039    use super::*;
1040
1041    #[test]
1042    fn memory_default_policy_has_floor_and_32_bit_cap() {
1043        assert_eq!(memory_limit_for(None, 64), MEMORY_FLOOR_BYTES,);
1044        assert_eq!(
1045            memory_limit_for(Some((MEMORY_FLOOR_BYTES as u64) * 16), 64),
1046            MEMORY_FLOOR_BYTES * 2,
1047        );
1048        assert_eq!(
1049            memory_limit_for(Some(u64::MAX), 32),
1050            MEMORY_32_BIT_CAP_BYTES,
1051        );
1052    }
1053
1054    #[cfg(target_os = "windows")]
1055    #[test]
1056    fn windows_global_memory_status_reports_physical_memory() {
1057        assert!(physical_memory_bytes().is_some_and(|bytes| bytes > 0));
1058    }
1059}