Skip to main content

par2_rs/create/
output.rs

1#[cfg(any(target_os = "linux", target_os = "macos"))]
2use std::ffi::CString;
3use std::fs::{self, File, OpenOptions};
4use std::io::{Read, Seek, SeekFrom, Write};
5#[cfg(windows)]
6use std::mem::MaybeUninit;
7use std::mem::size_of;
8#[cfg(any(target_os = "linux", target_os = "macos"))]
9use std::os::unix::ffi::OsStrExt;
10use std::path::{Path, PathBuf};
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use crate::checksum::Md5State;
14use crate::error::{Par2Error, Result};
15use crate::packet::encode::{encode_header, encode_packet, start_streamed_hash};
16use crate::packet::header::{HEADER_SIZE, PacketHeader, TYPE_CREATOR};
17use crate::packet::{Packet, scan_packets_from_path_with_set_ids_cancellable};
18use crate::types::{CancellationToken, FileId, RecoveryExponent, RecoverySetId};
19
20use super::encode::{EncodeAttempt, ForwardEncoder, ForwardEncoderOptions, ForwardRecoverySink};
21use super::metal::{SelectedBackend, selected_policy};
22use super::options::{CreationBackend, Par2CreatorOptions};
23use super::plan::Par2CreatePlan;
24use super::source::{CreationSource, DiskSourceProvider};
25use super::volume::RecoveryVolumePlan;
26
27const CREATOR_ID: &[u8] = b"par2-rs";
28static ZERO_WRITE_BUFFER: [u8; 64 * 1024] = [0; 64 * 1024];
29
30#[cfg(test)]
31static CANCEL_AFTER_VALIDATION_SCAN: std::sync::atomic::AtomicBool =
32    std::sync::atomic::AtomicBool::new(false);
33
34#[cfg(test)]
35fn validation_checkpoint(cancellation: &CancellationToken) {
36    use std::sync::atomic::Ordering;
37
38    if CANCEL_AFTER_VALIDATION_SCAN.swap(false, Ordering::Relaxed) {
39        cancellation.cancel();
40    }
41}
42
43#[cfg(not(test))]
44fn validation_checkpoint(_: &CancellationToken) {}
45
46pub(crate) fn estimate_critical_packet_bytes(sources: &[CreationSource]) -> Result<usize> {
47    let main_body = 12usize
48        .checked_add(sources.len().checked_mul(16).ok_or_else(|| {
49            Par2Error::ResourceLimitExceeded {
50                reason: "Main packet memory estimate overflows".to_string(),
51            }
52        })?)
53        .ok_or_else(|| Par2Error::ResourceLimitExceeded {
54            reason: "Main packet memory estimate overflows".to_string(),
55        })?;
56    let mut total = encoded_packet_len(main_body)?;
57    for source in sources {
58        let description_body = 56usize.checked_add(source.par2_name.len()).ok_or_else(|| {
59            Par2Error::ResourceLimitExceeded {
60                reason: "FileDesc memory estimate overflows".to_string(),
61            }
62        })?;
63        let checksum_body = 16usize
64            .checked_add(
65                source
66                    .slice_checksums
67                    .len()
68                    .checked_mul(20)
69                    .ok_or_else(|| Par2Error::ResourceLimitExceeded {
70                        reason: "IFSC memory estimate overflows".to_string(),
71                    })?,
72            )
73            .ok_or_else(|| Par2Error::ResourceLimitExceeded {
74                reason: "IFSC memory estimate overflows".to_string(),
75            })?;
76        total = checked_memory_add(
77            total,
78            encoded_packet_len(description_body)?,
79            "critical packet memory estimate overflows",
80        )?;
81        total = checked_memory_add(
82            total,
83            encoded_packet_len(checksum_body)?,
84            "critical packet memory estimate overflows",
85        )?;
86    }
87    Ok(total)
88}
89
90pub(crate) fn estimate_packet_build_workspace_bytes(sources: &[CreationSource]) -> Result<usize> {
91    let mut largest_body = 0usize;
92    for source in sources {
93        let description_body = 56usize
94            .checked_add(source.par2_name.len())
95            .and_then(|length| length.checked_add(3))
96            .ok_or_else(|| Par2Error::ResourceLimitExceeded {
97                reason: "FileDesc packet workspace estimate overflows".to_string(),
98            })?;
99        let checksum_body = 16usize
100            .checked_add(
101                source
102                    .slice_checksums
103                    .len()
104                    .checked_mul(20)
105                    .ok_or_else(|| Par2Error::ResourceLimitExceeded {
106                        reason: "IFSC packet workspace estimate overflows".to_string(),
107                    })?,
108            )
109            .and_then(|length| length.checked_add(3))
110            .ok_or_else(|| Par2Error::ResourceLimitExceeded {
111                reason: "IFSC packet workspace estimate overflows".to_string(),
112            })?;
113        largest_body = largest_body.max(description_body).max(checksum_body);
114    }
115    checked_memory_add(
116        size_of::<Vec<u8>>(),
117        largest_body,
118        "critical packet workspace estimate overflows",
119    )
120}
121
122pub(crate) fn estimate_transaction_workspace_bytes(
123    base_path: &Path,
124    output_stem: &Path,
125    main_path: &Path,
126    output_paths: &[PathBuf],
127    volumes: &[RecoveryVolumePlan],
128    sources: &[CreationSource],
129    recovery_count: u32,
130) -> Result<usize> {
131    let critical_count = 1usize
132        .checked_add(sources.len().checked_mul(2).ok_or_else(|| {
133            Par2Error::ResourceLimitExceeded {
134                reason: "critical packet controller estimate overflows".to_string(),
135            }
136        })?)
137        .ok_or_else(|| Par2Error::ResourceLimitExceeded {
138            reason: "critical packet controller estimate overflows".to_string(),
139        })?;
140    let mut total = checked_memory_product(
141        critical_count,
142        size_of::<(ExpectedPacket, Vec<u8>)>(),
143        "critical packet controller estimate overflows",
144    )?;
145    total = checked_memory_add(
146        total,
147        encoded_packet_len(CREATOR_ID.len())?,
148        "creation transaction estimate overflows",
149    )?;
150    total = checked_memory_add(
151        total,
152        checked_memory_product(
153            output_paths.len(),
154            size_of::<VolumeState>(),
155            "staged volume estimate overflows",
156        )?,
157        "creation transaction estimate overflows",
158    )?;
159    total = checked_memory_add(
160        total,
161        checked_memory_product(
162            recovery_count as usize,
163            size_of::<RecoveryLocation>(),
164            "recovery location estimate overflows",
165        )?,
166        "creation transaction estimate overflows",
167    )?;
168
169    for (index, volume) in volumes.iter().enumerate() {
170        let expected_count = expected_packet_count(volume.recovery_count, critical_count)?;
171        let expected_capacity = allocation_capacity_upper_bound(expected_count)?;
172        let slot_capacity = allocation_capacity_upper_bound(volume.recovery_count as usize)?;
173        total = checked_memory_add(
174            total,
175            checked_memory_product(
176                expected_capacity,
177                size_of::<ExpectedPacket>(),
178                "expected packet estimate overflows",
179            )?,
180            "creation transaction estimate overflows",
181        )?;
182        total = checked_memory_add(
183            total,
184            checked_memory_product(
185                slot_capacity,
186                size_of::<RecoverySlot>(),
187                "recovery slot estimate overflows",
188            )?,
189            "creation transaction estimate overflows",
190        )?;
191        let path = output_paths
192            .get(index)
193            .ok_or_else(|| Par2Error::InvalidCreationOptions {
194                reason: "creation plan volume paths are inconsistent".to_string(),
195            })?;
196        total = checked_memory_add(
197            total,
198            path_memory_bytes(path).checked_add(128).ok_or_else(|| {
199                Par2Error::ResourceLimitExceeded {
200                    reason: "staged path estimate overflows".to_string(),
201                }
202            })?,
203            "creation transaction estimate overflows",
204        )?;
205        total = checked_memory_add(
206            total,
207            volume.filename.len().checked_add(64).ok_or_else(|| {
208                Par2Error::ResourceLimitExceeded {
209                    reason: "volume filename estimate overflows".to_string(),
210                }
211            })?,
212            "creation transaction estimate overflows",
213        )?;
214    }
215
216    for bytes in [
217        checked_memory_product(sources.len(), 128, "source provider estimate overflows")?,
218        checked_memory_product(
219            recovery_count as usize,
220            size_of::<RecoveryExponent>(),
221            "encoder exponent estimate overflows",
222        )?,
223        checked_memory_product(
224            volumes.len(),
225            size_of::<RecoveryVolumePlan>(),
226            "volume plan estimate overflows",
227        )?,
228        checked_memory_product(
229            output_paths.len(),
230            size_of::<PathBuf>(),
231            "output path estimate overflows",
232        )?,
233    ] {
234        total = checked_memory_add(total, bytes, "creation transaction estimate overflows")?;
235    }
236    for path in output_paths {
237        total = checked_memory_add(
238            total,
239            path_memory_bytes(path).checked_add(128).ok_or_else(|| {
240                Par2Error::ResourceLimitExceeded {
241                    reason: "output path estimate overflows".to_string(),
242                }
243            })?,
244            "output path estimate overflows",
245        )?;
246    }
247    for path in [base_path, output_stem, main_path] {
248        total = checked_memory_add(
249            total,
250            path_memory_bytes(path).checked_add(128).ok_or_else(|| {
251                Par2Error::ResourceLimitExceeded {
252                    reason: "creation path estimate overflows".to_string(),
253                }
254            })?,
255            "creation path estimate overflows",
256        )?;
257    }
258    let cloned_path_capacity = allocation_capacity_upper_bound(output_paths.len())?;
259    for (count, element_size) in [
260        (cloned_path_capacity, size_of::<PathBuf>()),
261        (cloned_path_capacity, size_of::<TargetSnapshot>()),
262        (cloned_path_capacity, size_of::<BackupEntry>()),
263        (cloned_path_capacity, size_of::<InstalledTarget>()),
264    ] {
265        total = checked_memory_add(
266            total,
267            checked_memory_product(
268                count,
269                element_size,
270                "transaction metadata estimate overflows",
271            )?,
272            "creation transaction estimate overflows",
273        )?;
274    }
275    for path in output_paths {
276        let path_copy_bytes = path_memory_bytes(path).checked_add(256).ok_or_else(|| {
277            Par2Error::ResourceLimitExceeded {
278                reason: "transaction path capacity estimate overflows".to_string(),
279            }
280        })?;
281        total = checked_memory_add(
282            total,
283            checked_memory_product(
284                6,
285                path_copy_bytes,
286                "transaction path capacity estimate overflows",
287            )?,
288            "creation transaction estimate overflows",
289        )?;
290    }
291    total = checked_memory_add(
292        total,
293        size_of::<StagedOutputs>(),
294        "creation transaction estimate overflows",
295    )?;
296    Ok(total)
297}
298
299pub(crate) fn estimate_validation_workspace_bytes(
300    sources: &[CreationSource],
301    output_paths: &[PathBuf],
302    volumes: &[RecoveryVolumePlan],
303    critical_packet_bytes: usize,
304) -> Result<usize> {
305    const SCANNER_BUFFER_BYTES: usize = 256 * 1024;
306    const RECOVERY_HASH_BUFFER_BYTES: usize = 256 * 1024;
307    const MAX_CREATOR_BODY_BYTES: usize = 100_000;
308
309    let critical_count = 1usize
310        .checked_add(sources.len().checked_mul(2).ok_or_else(|| {
311            Par2Error::ResourceLimitExceeded {
312                reason: "validation critical packet count overflows".to_string(),
313            }
314        })?)
315        .ok_or_else(|| Par2Error::ResourceLimitExceeded {
316            reason: "validation critical packet count overflows".to_string(),
317        })?;
318    let max_recovery_count = volumes
319        .iter()
320        .map(|volume| volume.recovery_count)
321        .max()
322        .unwrap_or(0);
323    let critical_copies = usize::try_from(bit_length(max_recovery_count).max(1)).map_err(|_| {
324        Par2Error::ResourceLimitExceeded {
325            reason: "validation critical copy count overflows".to_string(),
326        }
327    })?;
328    let max_packet_count = expected_packet_count(max_recovery_count, critical_count)?;
329    let parsed_critical_bytes = checked_memory_product(
330        critical_packet_bytes,
331        critical_copies,
332        "validation parsed critical packet estimate overflows",
333    )?;
334    let parsed_packet_slots = checked_memory_product(
335        allocation_capacity_upper_bound(max_packet_count)?,
336        size_of::<crate::packet::ScannedPacket>(),
337        "validation parsed packet controller estimate overflows",
338    )?;
339    let recovery_path_bytes = output_paths
340        .iter()
341        .map(|path| path_memory_bytes(path).saturating_add(128))
342        .max()
343        .unwrap_or(128);
344    let parsed_recovery_paths = checked_memory_product(
345        max_packet_count,
346        recovery_path_bytes,
347        "validation recovery path estimate overflows",
348    )?;
349    let per_volume = [
350        parsed_critical_bytes,
351        parsed_packet_slots,
352        parsed_recovery_paths,
353        size_of::<Vec<crate::packet::ScannedPacket>>(),
354        MAX_CREATOR_BODY_BYTES
355            .checked_add(size_of::<crate::packet::Packet>())
356            .ok_or_else(|| Par2Error::ResourceLimitExceeded {
357                reason: "validation Creator packet estimate overflows".to_string(),
358            })?,
359        SCANNER_BUFFER_BYTES,
360        RECOVERY_HASH_BUFFER_BYTES,
361    ]
362    .into_iter()
363    .try_fold(0usize, |total, bytes| {
364        checked_memory_add(total, bytes, "validation workspace estimate overflows")
365    })?;
366    // Validation runs volumes concurrently (StagedOutputs::validate), each
367    // holding its own full workspace, so the peak scales with the concurrent
368    // volume count. StagedOutputs holds one staged volume per OUTPUT PATH
369    // (the index .par2 plus the recovery volumes; `volumes` counts recovery
370    // volumes only), so the gate mirrors validate()'s parallel/sequential
371    // split over that collection, and the +1 covers the calling thread
372    // participating in the pool. Uses the process-stable thread count, never
373    // rayon's pool-relative view.
374    let staged_volumes = output_paths.len();
375    let threads = super::encode::configured_create_threads();
376    let concurrent_volumes = if threads == 1 || staged_volumes <= 1 {
377        1
378    } else {
379        staged_volumes.min(threads.saturating_add(1))
380    };
381    checked_memory_product(
382        per_volume,
383        concurrent_volumes,
384        "concurrent validation workspace estimate overflows",
385    )
386}
387
388fn expected_packet_count(recovery_count: u32, critical_count: usize) -> Result<usize> {
389    let recovery_count_usize = recovery_count as usize;
390    if recovery_count == 0 {
391        return critical_count
392            .checked_add(1)
393            .ok_or_else(|| Par2Error::ResourceLimitExceeded {
394                reason: "expected packet estimate overflows".to_string(),
395            });
396    }
397    let copies = bit_length(recovery_count) as usize;
398    let mut pending = 0u64;
399    let mut total =
400        recovery_count_usize
401            .checked_add(1)
402            .ok_or_else(|| Par2Error::ResourceLimitExceeded {
403                reason: "expected packet estimate overflows".to_string(),
404            })?;
405    for _ in 0..recovery_count {
406        pending = pending
407            .checked_add(
408                (copies as u64)
409                    .checked_mul(critical_count as u64)
410                    .ok_or_else(|| Par2Error::ResourceLimitExceeded {
411                        reason: "expected packet estimate overflows".to_string(),
412                    })?,
413            )
414            .ok_or_else(|| Par2Error::ResourceLimitExceeded {
415                reason: "expected packet estimate overflows".to_string(),
416            })?;
417        while pending >= recovery_count as u64 {
418            total = total
419                .checked_add(1)
420                .ok_or_else(|| Par2Error::ResourceLimitExceeded {
421                    reason: "expected packet estimate overflows".to_string(),
422                })?;
423            pending -= recovery_count as u64;
424        }
425    }
426    Ok(total)
427}
428
429fn allocation_capacity_upper_bound(length: usize) -> Result<usize> {
430    if length == 0 {
431        Ok(0)
432    } else {
433        length
434            .checked_next_power_of_two()
435            .ok_or_else(|| Par2Error::ResourceLimitExceeded {
436                reason: "transaction allocation estimate overflows".to_string(),
437            })
438    }
439}
440
441fn checked_memory_product(left: usize, right: usize, reason: &'static str) -> Result<usize> {
442    left.checked_mul(right)
443        .ok_or_else(|| Par2Error::ResourceLimitExceeded {
444            reason: reason.to_string(),
445        })
446}
447
448fn checked_memory_add(left: usize, right: usize, reason: &'static str) -> Result<usize> {
449    left.checked_add(right)
450        .ok_or_else(|| Par2Error::ResourceLimitExceeded {
451            reason: reason.to_string(),
452        })
453}
454
455fn encoded_packet_len(body_len: usize) -> Result<usize> {
456    let padded = body_len
457        .checked_add(3)
458        .map(|length| length / 4 * 4)
459        .ok_or_else(|| Par2Error::ResourceLimitExceeded {
460            reason: "packet memory estimate overflows".to_string(),
461        })?;
462    HEADER_SIZE
463        .checked_add(padded)
464        .ok_or_else(|| Par2Error::ResourceLimitExceeded {
465            reason: "packet memory estimate overflows".to_string(),
466        })
467}
468
469fn path_memory_bytes(path: &Path) -> usize {
470    path.as_os_str().len()
471}
472
473/// Files produced by a successful creation transaction.
474#[derive(Debug, Clone)]
475pub struct Par2CreateOutcome {
476    /// Recovery-set identifier shared by all packets.
477    pub recovery_set_id: RecoverySetId,
478    /// Main output path.
479    pub main_path: PathBuf,
480    /// Recovery volume paths.
481    pub volume_paths: Vec<PathBuf>,
482    /// All committed output paths, with the main file first.
483    pub output_paths: Vec<PathBuf>,
484    /// Total number of source slices.
485    pub source_slice_count: u32,
486    /// Total number of recovery slices.
487    pub recovery_count: u32,
488    /// Total bytes committed across all output files.
489    pub bytes_written: u64,
490    /// True when no output files were written.
491    pub dry_run: bool,
492    /// Backend policy requested by the creator.
493    pub requested_backend: CreationBackend,
494    /// Backend selected after preflight.
495    pub selected_backend: CreationBackend,
496}
497
498#[derive(Clone)]
499enum ExpectedPacket {
500    Main,
501    FileDescription(FileId),
502    InputFileSliceChecksum(FileId),
503    Recovery(RecoveryExponent),
504    Creator,
505}
506
507struct RecoverySlot {
508    exponent: RecoveryExponent,
509    header_offset: u64,
510    data_offset: u64,
511    bytes_written: u64,
512    hasher: Md5State,
513}
514
515struct VolumeState {
516    stage_path: PathBuf,
517    target_path: PathBuf,
518    file: File,
519    expected: Vec<ExpectedPacket>,
520    recovery_slots: Vec<RecoverySlot>,
521}
522
523#[derive(Clone, Copy)]
524struct RecoveryLocation {
525    volume_index: usize,
526    slot_index: usize,
527}
528
529/// Where one copy of one critical packet was staged.
530///
531/// Critical packets are laid out before the recovery data is encoded, but
532/// their bodies carry the source content hashes, which the encoder's own feed
533/// produces (see `CreationSource`). The layout does not depend on the hash
534/// VALUES — a planned source already carries a correctly sized placeholder for
535/// every one of them — so the packets are staged at their final size and
536/// rewritten in place once the encode has produced the real bytes.
537#[derive(Clone, Copy)]
538struct CriticalLocation {
539    volume_index: usize,
540    offset: u64,
541    critical_index: usize,
542    len: usize,
543}
544
545struct StagedOutputs {
546    volumes: Vec<VolumeState>,
547    locations: Vec<RecoveryLocation>,
548    /// Every staged copy of every critical packet, in write order.
549    critical_offsets: Vec<CriticalLocation>,
550    planned_targets: Vec<TargetSnapshot>,
551    /// One pin per planned target that already existed when the transaction
552    /// started, `None` where the path was absent.
553    ///
554    /// The planning snapshot is compared again at commit time, and that
555    /// comparison is forgeable by inode reuse exactly like the rollback one: a
556    /// delete-and-replace between planning and commit reproduces
557    /// `(device, inode)` and the birth time on a recycling filesystem. Pinning
558    /// the planned object is what makes "unchanged since planning" mean the
559    /// same object rather than the same numbers.
560    planned_pins: Vec<Option<InodePin>>,
561    committed: bool,
562}
563
564/// A retained open handle on a file this transaction owns.
565///
566/// # Why a handle and not a fingerprint
567///
568/// `(device, inode)` is forgeable by inode reuse: ext4 and overlayfs hand a
569/// just-freed inode to the very next creation, so a foreign file written over a
570/// removed target collides with the removed target's identity. Measured, not
571/// assumed: on overlayfs six delete+recreate rounds returned inode 100104 every
572/// time.
573///
574/// Timestamps cannot break the tie either. Birth time is the only field that
575/// survives the transaction's own renames — `ctime` moves on rename, and the
576/// post-rename comparison in [`quarantine_owned_target_with_hook`] is exactly
577/// where the check has to hold — but Linux stamps file times from a coarse
578/// clock, so a delete+recreate inside one tick reproduces the birth time as
579/// well. Measured: consecutive creations on overlayfs differed by 1-3 ms, and a
580/// `remove_file` immediately followed by a `write` lands well inside that.
581///
582/// An open handle settles it. The kernel cannot recycle an inode that is still
583/// referenced, so while this handle lives no other file can carry our inode
584/// number, and `(device, inode)` becomes unforgeable. The handle follows the
585/// file through renames, which is what makes it usable on both sides of the
586/// quarantine rename.
587///
588/// # Windows: the same machinery, a different forgery
589///
590/// NTFS does not hand back a whole file id the way ext4 hands back an inode
591/// number. `GetFileInformationByHandle`'s 64-bit file index is a 48-bit MFT
592/// record number plus a 16-bit sequence number that the volume bumps every time
593/// the record is recycled, so a delete-and-recreate produces a *different*
594/// index: measured on NTFS, 0 of 20 rounds repeated it (the record number
595/// repeated all 20 times; the sequence number moved every time).
596///
597/// The hazard transposes anyway, from the other direction. NTFS **file
598/// tunneling** replays a removed file's creation time onto a file recreated
599/// with the same name in the same directory inside the tunnel window — measured
600/// 10 of 10 same-directory rounds, and 0 of 10 when the recreate landed in a
601/// different directory. Every comparison site in this module is a same-name,
602/// same-directory site, which is precisely the case tunneling covers. So on
603/// Windows the timestamp is not merely coincidentally forgeable the way a
604/// coarse-clock birth time is on Linux; the filesystem forges it deliberately,
605/// every time, exactly here.
606///
607/// A retained handle settles it on Windows for the same reason it does on Unix:
608/// while this handle lives the MFT record cannot be recycled, so no other file
609/// can present our `(volume, index)`, and the handle follows the file through
610/// the transaction's renames. The share mode Rust opens with permits the
611/// deletes and renames the transaction performs on its own files — verified,
612/// not assumed: unlink-while-pinned, recreate-while-pinned, publish-unlink of an
613/// adopted stage handle, and quarantine rename under a pin all succeed.
614///
615/// # Deliberately stronger than the reference tools
616///
617/// Neither reference implementation has any of this. par2cmdline repairs purely
618/// by name, and unrar creates with `O_CREAT|O_TRUNC` after an exists-prompt —
619/// both would happily write through an inode swap. This machinery exists to
620/// serve the never-lose-data invariant, not to match oracle shape, so do not
621/// "simplify" it back toward the reference behavior.
622#[derive(Debug)]
623struct InodePin {
624    #[cfg(any(unix, windows))]
625    handle: File,
626}
627
628impl InodePin {
629    /// Pin the file at `path` by holding an open descriptor on it.
630    ///
631    /// `O_RDONLY` rather than `O_PATH`: `fstat` works on both, but `O_PATH`
632    /// is Linux-only, and the pin has to behave the same on macOS, the BSDs and
633    /// wasi-adjacent Unixes. A read handle costs the same one descriptor.
634    ///
635    /// The same call serves Windows: `File::open` there asks for read access
636    /// under the full sharing mode, which is what keeps the transaction's own
637    /// unlinks and renames working while the record stays pinned.
638    #[cfg(any(unix, windows))]
639    fn open(path: &Path, set_size: usize) -> Result<Self> {
640        match File::open(path) {
641            Ok(handle) => Ok(Self { handle }),
642            Err(error) => Err(open_pin_error(path, set_size, &error)),
643        }
644    }
645
646    /// Adopt an already-open handle, which is what the staged outputs do: the
647    /// stage file is published with `hard_link`, so the write handle already
648    /// references the very inode the published target names.
649    ///
650    /// Identical on Windows, where a hard link is likewise a second name for
651    /// one MFT record: the retained stage handle reports the published target's
652    /// `(volume, index)` before and after the stage name is unlinked.
653    #[cfg(any(unix, windows))]
654    fn adopt(handle: File) -> Self {
655        Self { handle }
656    }
657
658    /// Is `path` still the object this pin holds?
659    ///
660    /// `fstat` on the pinned descriptor against `lstat` on the path. While the
661    /// pin lives its inode cannot be reused, so agreement here means the same
662    /// object and not merely the same numbers.
663    #[cfg(unix)]
664    fn still_at(&self, path: &Path) -> std::io::Result<bool> {
665        use std::os::unix::fs::MetadataExt;
666
667        let pinned = self.handle.metadata()?;
668        match fs::symlink_metadata(path) {
669            Ok(current) => Ok(current.is_file()
670                && current.dev() == pinned.dev()
671                && current.ino() == pinned.ino()),
672            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
673            Err(error) => Err(error),
674        }
675    }
676
677    /// Is `path` still the object this pin holds?
678    ///
679    /// `GetFileInformationByHandle` on the retained handle against a fresh
680    /// resolution of `path`, routed through [`target_file_identity`] so this
681    /// check inherits the same regular-file and reparse-point guards every
682    /// other identity read in this module uses, and so a comparison-site fix
683    /// can never apply to one of the two and not the other. A path that is gone
684    /// resolves to `None` and answers `false`, mirroring the Unix arm's
685    /// `NotFound` case.
686    ///
687    /// While the pin lives the MFT record cannot be recycled, so agreement here
688    /// means the same object and not merely the same numbers — which is the
689    /// whole point, because the creation time carried alongside the index is
690    /// the field NTFS tunneling replays onto a same-name replacement.
691    #[cfg(windows)]
692    fn still_at(&self, path: &Path) -> std::io::Result<bool> {
693        let pinned = FileIdentity::from_file(&self.handle)?;
694        Ok(target_file_identity(path)? == Some(pinned))
695    }
696
697    /// Targets with neither `unix` nor `windows` file identity keep the previous
698    /// behavior explicitly rather than by accident.
699    ///
700    /// wasip1 has no `RLIMIT_NOFILE` and no stable descriptor-identity story, so
701    /// it keeps the `(device, inode)` check it already had and is not pinned.
702    /// This arm is the honest statement of that: it cannot answer the question,
703    /// so the recorded [`FileIdentity`] remains the only authority there.
704    #[cfg(not(any(unix, windows)))]
705    fn open(_path: &Path, _set_size: usize) -> Result<Self> {
706        Ok(Self {})
707    }
708
709    /// The handle is dropped here rather than retained: without a
710    /// record-reference guarantee there is nothing for it to pin, and holding it
711    /// would only keep a descriptor open for no benefit.
712    #[cfg(not(any(unix, windows)))]
713    fn adopt(_handle: File) -> Self {
714        Self {}
715    }
716
717    #[cfg(not(any(unix, windows)))]
718    fn still_at(&self, _path: &Path) -> std::io::Result<bool> {
719        Ok(true)
720    }
721}
722
723/// EMFILE and friends are a loud refusal: a transaction that cannot pin every
724/// file it owns has no way to tell its own outputs from a foreign file that
725/// took their inode, and a partially pinned set is that bug wearing a
726/// different hat.
727#[cfg(any(unix, windows))]
728fn open_pin_error(path: &Path, set_size: usize, error: &std::io::Error) -> Par2Error {
729    let limit = open_file_limit_description();
730    Par2Error::CreationValidation {
731        path: path.display().to_string(),
732        reason: format!(
733            "cannot pin output for the transaction ({error}); \
734             the transaction owns {set_size} files and needs one open descriptor per file, {limit}"
735        ),
736    }
737}
738
739#[cfg(unix)]
740fn open_file_limit_description() -> String {
741    match open_file_limits() {
742        Some((soft, hard)) => {
743            format!("RLIMIT_NOFILE soft={soft} hard={hard}")
744        }
745        None => "RLIMIT_NOFILE could not be read".to_string(),
746    }
747}
748
749/// Windows has no `RLIMIT_NOFILE` analogue worth quoting: handles are bounded by
750/// a per-process quota far above any recovery-volume count and by kernel pool,
751/// neither of which reads back as a soft/hard pair. Saying so beats inventing a
752/// number, and it points the reader at what a refusal here actually is.
753#[cfg(windows)]
754fn open_file_limit_description() -> String {
755    "and Windows imposes no per-process descriptor limit at this scale, so this is a \
756     sharing or access denial rather than exhaustion"
757        .to_string()
758}
759
760#[cfg(unix)]
761fn open_file_limits() -> Option<(u64, u64)> {
762    // SAFETY: `getrlimit` fills a caller-provided `rlimit` and reports failure
763    // through its return value; the struct is zeroed POD.
764    unsafe {
765        let mut limit: libc::rlimit = std::mem::zeroed();
766        if libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) != 0 {
767            return None;
768        }
769        Some((limit.rlim_cur as u64, limit.rlim_max as u64))
770    }
771}
772
773/// Raise the soft descriptor limit toward the hard limit, once per process.
774///
775/// Standard practice for a process that holds one descriptor per owned file:
776/// the soft limit is a courtesy default (1024 on most Linux distributions, 256
777/// on macOS) while the hard limit is the real ceiling, and raising the soft
778/// limit needs no privilege. Failure is deliberately silent — the transaction
779/// still refuses loudly if a pin cannot be opened, which is the check that
780/// matters; this only widens the headroom before that happens.
781#[cfg(unix)]
782fn raise_open_file_limit() {
783    static RAISED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
784    RAISED.get_or_init(|| {
785        // SAFETY: both calls take a caller-provided `rlimit` and report failure
786        // through their return value.
787        unsafe {
788            let mut limit: libc::rlimit = std::mem::zeroed();
789            if libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) != 0 {
790                return;
791            }
792            if limit.rlim_cur >= limit.rlim_max {
793                return;
794            }
795            // `rlim_cur = rlim_max` is the obvious move and it is wrong on
796            // macOS, where the hard limit reads back as RLIM_INFINITY while the
797            // kernel still refuses anything above `kern.maxfilesperproc`
798            // (EINVAL, and no raise at all). Walk a ladder down from the hard
799            // limit instead and keep the first value the kernel accepts.
800            let hard = limit.rlim_max;
801            let start = limit.rlim_cur;
802            for candidate in [hard, 262_144, 65_536, 10_240, 4_096, 1_024] {
803                if candidate <= start {
804                    break;
805                }
806                let candidate = candidate.min(hard);
807                let mut attempt = limit;
808                attempt.rlim_cur = candidate;
809                if libc::setrlimit(libc::RLIMIT_NOFILE, &attempt) == 0 {
810                    return;
811                }
812            }
813        }
814    });
815}
816
817/// Nothing to raise off Unix. Windows pins handles rather than descriptors and
818/// its per-process quota is orders of magnitude above any output set this
819/// transaction can produce; wasip1 pins nothing at all.
820#[cfg(not(unix))]
821fn raise_open_file_limit() {}
822
823struct InstalledTarget {
824    path: PathBuf,
825    identity: Option<FileIdentity>,
826    /// The authority for "is this still our file". `None` only for targets
827    /// this transaction never managed to pin, which cannot happen on the
828    /// publish path -- a published target is a hard link to the staged inode
829    /// the transaction still holds open.
830    pin: Option<InodePin>,
831}
832
833struct BackupEntry {
834    target: PathBuf,
835    backup: PathBuf,
836    namespace: PathBuf,
837    identity: FileIdentity,
838    /// Pinned before the backup rename, so the restore can prove it is moving
839    /// back the same object it moved aside.
840    pin: InodePin,
841}
842
843#[derive(Debug)]
844enum PublishError {
845    NotInstalled(std::io::Error),
846    Installed {
847        identity: FileIdentity,
848        error: std::io::Error,
849    },
850}
851
852impl PublishError {
853    #[cfg(test)]
854    fn into_io_error(self) -> std::io::Error {
855        match self {
856            Self::NotInstalled(error) | Self::Installed { error, .. } => error,
857        }
858    }
859}
860
861#[derive(Debug, PartialEq, Eq)]
862enum RollbackTarget {
863    Missing,
864    OwnedQuarantined(PathBuf),
865    Unchanged,
866}
867
868impl RollbackTarget {
869    fn can_restore_backup(&self) -> bool {
870        matches!(self, Self::Missing | Self::OwnedQuarantined(_))
871    }
872}
873
874#[derive(Clone, Copy, Debug, PartialEq, Eq)]
875pub(crate) enum FileIdentity {
876    #[cfg(unix)]
877    Unix {
878        device: u64,
879        inode: u64,
880        // (device, inode) alone is forgeable by immediate inode reuse: ext4
881        // hands a just-freed inode to the next creation, so a foreign file
882        // written over a removed target can collide. Birth time
883        // disambiguates recreation while surviving the transaction's own
884        // renames (ctime would not). None where the filesystem cannot
885        // report it, which degrades to the previous (device, inode) check.
886        birth: Option<std::time::SystemTime>,
887    },
888    #[cfg(windows)]
889    Windows {
890        volume: u32,
891        index: u64,
892        // The counterpart of `birth`, added for the same reason and with the
893        // same honest limit. `GetFileInformationByHandle` reports the creation
894        // time in the very call that reports the index, so it costs nothing to
895        // carry, and it discriminates a replacement the index pair alone would
896        // not — but only outside NTFS file tunneling, which replays a removed
897        // file's creation time onto a same-name, same-directory recreate
898        // (measured 10/10 same-directory, 0/10 cross-directory). Every
899        // comparison site here is same-name and same-directory, so this field
900        // hardens the no-pin fallback and never stands in for the pin.
901        //
902        // Kept as raw FILETIME ticks (100 ns since 1601-01-01) rather than
903        // converted to `SystemTime`: the only operation performed on it is
904        // equality, and the conversion would add arithmetic that can only lose.
905        // `None` where the volume reports no creation time at all (a zero
906        // FILETIME), which degrades to the previous (volume, index) check
907        // exactly as `birth` degrades on a filesystem without a birth time.
908        birth: Option<u64>,
909    },
910    // wasip1 is not `unix`, and `std::os::wasi::fs::MetadataExt` (the stdlib
911    // route to `dev`/`ino`) is still unstable behind `wasi_ext`, which this
912    // crate's pinned stable toolchain cannot use. Without an identity the
913    // publish step hard-errors with "staged output identity is unavailable",
914    // which is what made PAR2 creation impossible on wasm. `libc` — already a
915    // dependency — exposes `stat` on wasip1 and reports both fields, so the
916    // same (device, inode) guarantee holds here. wasi's `filestat` has no
917    // birth time, so `birth` is the documented degraded `None` case.
918    #[cfg(target_os = "wasi")]
919    Wasi {
920        device: u64,
921        inode: u64,
922        birth: Option<std::time::SystemTime>,
923    },
924}
925
926#[derive(Clone, Copy, Debug, PartialEq, Eq)]
927pub(crate) enum TargetSnapshot {
928    Absent,
929    File(FileIdentity),
930    Directory,
931    Symlink,
932    Special,
933}
934
935#[cfg(windows)]
936#[repr(C)]
937struct WindowsFileTime {
938    low_date_time: u32,
939    high_date_time: u32,
940}
941
942// The layout must match `BY_HANDLE_FILE_INFORMATION` exactly, so the fields this
943// module does not read are still declared. Only `creation_time`,
944// `volume_serial_number` and the two `file_index_*` halves are consumed.
945#[cfg(windows)]
946#[repr(C)]
947#[allow(dead_code)]
948struct WindowsByHandleFileInformation {
949    file_attributes: u32,
950    creation_time: WindowsFileTime,
951    last_access_time: WindowsFileTime,
952    last_write_time: WindowsFileTime,
953    volume_serial_number: u32,
954    file_size_high: u32,
955    file_size_low: u32,
956    number_of_links: u32,
957    file_index_high: u32,
958    file_index_low: u32,
959}
960
961#[cfg(windows)]
962#[allow(non_snake_case)]
963unsafe extern "system" {
964    fn GetFileInformationByHandle(
965        file: std::os::windows::io::RawHandle,
966        information: *mut WindowsByHandleFileInformation,
967    ) -> i32;
968}
969
970impl FileIdentity {
971    /// wasip1 identity via `libc::lstat`, mirroring the `unix` arm's
972    /// `symlink_metadata` (lstat) semantics so a symlink is never followed.
973    ///
974    /// The caller has already established through `symlink_metadata` that the
975    /// path is a regular file; this second call only retrieves the two fields
976    /// stable Rust will not hand over on this target.
977    #[cfg(target_os = "wasi")]
978    fn from_wasi_path(path: &Path, metadata: &fs::Metadata) -> Option<Self> {
979        let raw = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()).ok()?;
980        // SAFETY: `raw` is a valid NUL-terminated C string that outlives the
981        // call, and `stat` is zero-initialized POD that `lstat` fully writes on
982        // success. Failure is handled by returning `None`.
983        let stat = unsafe {
984            let mut stat: libc::stat = std::mem::zeroed();
985            if libc::lstat(raw.as_ptr(), &mut stat) != 0 {
986                return None;
987            }
988            stat
989        };
990        Some(Self::Wasi {
991            device: stat.st_dev as u64,
992            inode: stat.st_ino as u64,
993            birth: metadata.created().ok(),
994        })
995    }
996
997    #[cfg(unix)]
998    fn from_metadata(metadata: &fs::Metadata) -> Option<Self> {
999        use std::os::unix::fs::MetadataExt;
1000
1001        Some(Self::Unix {
1002            device: metadata.dev(),
1003            inode: metadata.ino(),
1004            birth: metadata.created().ok(),
1005        })
1006    }
1007
1008    #[cfg(windows)]
1009    fn from_file(file: &File) -> std::io::Result<Self> {
1010        use std::os::windows::io::AsRawHandle;
1011
1012        let mut information = MaybeUninit::<WindowsByHandleFileInformation>::uninit();
1013        let succeeded =
1014            unsafe { GetFileInformationByHandle(file.as_raw_handle(), information.as_mut_ptr()) }
1015                != 0;
1016        if !succeeded {
1017            return Err(std::io::Error::last_os_error());
1018        }
1019        let information = unsafe { information.assume_init() };
1020        let birth = (u64::from(information.creation_time.high_date_time) << 32)
1021            | u64::from(information.creation_time.low_date_time);
1022        Ok(Self::Windows {
1023            volume: information.volume_serial_number,
1024            index: (u64::from(information.file_index_high) << 32)
1025                | u64::from(information.file_index_low),
1026            // A zero FILETIME is how a volume says it has no creation time to
1027            // report; the documented degraded case, not a valid timestamp.
1028            birth: (birth != 0).then_some(birth),
1029        })
1030    }
1031}
1032
1033impl StagedOutputs {
1034    fn create(
1035        plan: &Par2CreatePlan,
1036        critical: &[(ExpectedPacket, Vec<u8>)],
1037        creator: &[u8],
1038        cancellation: &CancellationToken,
1039    ) -> Result<Self> {
1040        // One descriptor per owned file is held for the whole transaction (see
1041        // `InodePin`), so widen the soft limit toward the hard one before the
1042        // first stage file is created.
1043        raise_open_file_limit();
1044        // Pinned before anything is staged: a target that vanishes between here
1045        // and the commit check must not be able to hand its inode to a
1046        // replacement that then reads as "unchanged since planning".
1047        let planned_pins = plan
1048            .output_paths
1049            .iter()
1050            .zip(plan.target_snapshots.iter())
1051            .map(|(target, snapshot)| match snapshot {
1052                TargetSnapshot::Absent => Ok(None),
1053                _ => InodePin::open(target, plan.output_paths.len()).map(Some),
1054            })
1055            .collect::<Result<Vec<_>>>();
1056        let mut volumes: Vec<VolumeState> = Vec::with_capacity(plan.output_paths.len());
1057        for (index, target) in plan.output_paths.iter().enumerate() {
1058            if cancellation.is_cancelled() {
1059                cleanup_stage_files(volumes);
1060                return Err(Par2Error::Cancelled);
1061            }
1062            let (stage_path, file) = match create_stage_file(target, index) {
1063                Ok(result) => result,
1064                Err(error) => {
1065                    cleanup_stage_files(volumes);
1066                    return Err(error);
1067                }
1068            };
1069            volumes.push(VolumeState {
1070                stage_path,
1071                target_path: target.clone(),
1072                file,
1073                expected: Vec::new(),
1074                recovery_slots: Vec::new(),
1075            });
1076        }
1077        let mut staged = Self {
1078            volumes,
1079            locations: Vec::with_capacity(plan.recovery_count as usize),
1080            critical_offsets: Vec::new(),
1081            planned_targets: plan.target_snapshots.clone(),
1082            planned_pins: planned_pins?,
1083            committed: false,
1084        };
1085
1086        for critical_index in 0..critical.len() {
1087            check_cancel(cancellation)?;
1088            append_critical_packet(&mut staged, 0, critical_index, critical)?;
1089        }
1090        check_cancel(cancellation)?;
1091        append_packet(&mut staged.volumes[0], ExpectedPacket::Creator, creator)?;
1092
1093        for (volume_offset, volume_plan) in plan.volumes.iter().enumerate() {
1094            check_cancel(cancellation)?;
1095            let volume_index = volume_offset + 1;
1096            if volume_plan.recovery_count == 0 {
1097                for critical_index in 0..critical.len() {
1098                    check_cancel(cancellation)?;
1099                    append_critical_packet(&mut staged, volume_index, critical_index, critical)?;
1100                }
1101            } else {
1102                let copies = bit_length(volume_plan.recovery_count) as u64;
1103                let critical_count = critical.len() as u64;
1104                let mut packet_count = 0u64;
1105                let mut next_critical = 0usize;
1106                for offset in 0..volume_plan.recovery_count {
1107                    check_cancel(cancellation)?;
1108                    let exponent = volume_plan.first_exponent + offset;
1109                    let global_index = staged.locations.len();
1110                    append_recovery_slot(
1111                        &mut staged,
1112                        volume_index,
1113                        global_index,
1114                        exponent,
1115                        plan,
1116                        cancellation,
1117                    )?;
1118                    packet_count = packet_count
1119                        .checked_add(copies.checked_mul(critical_count).ok_or_else(|| {
1120                            Par2Error::ResourceLimitExceeded {
1121                                reason: "critical packet interleaving count overflows".to_string(),
1122                            }
1123                        })?)
1124                        .ok_or_else(|| Par2Error::ResourceLimitExceeded {
1125                            reason: "critical packet interleaving count overflows".to_string(),
1126                        })?;
1127                    while packet_count >= volume_plan.recovery_count as u64 {
1128                        check_cancel(cancellation)?;
1129                        append_critical_packet(&mut staged, volume_index, next_critical, critical)?;
1130                        next_critical = (next_critical + 1) % critical.len();
1131                        packet_count -= volume_plan.recovery_count as u64;
1132                    }
1133                }
1134            }
1135            check_cancel(cancellation)?;
1136            append_packet(
1137                &mut staged.volumes[volume_index],
1138                ExpectedPacket::Creator,
1139                creator,
1140            )?;
1141        }
1142        Ok(staged)
1143    }
1144
1145    /// Replace every staged critical packet with the one built from the
1146    /// hydrated sources.
1147    ///
1148    /// Same packets, same order, same offsets: only the bodies' hash bytes and
1149    /// the headers' packet MD5s were unknown when the layout was written. The
1150    /// length equality is checked rather than assumed, because a length change
1151    /// would mean the layout itself was built from something other than the
1152    /// sources being written, and that must be an error, not a silent overlap.
1153    fn rewrite_critical_packets(
1154        &mut self,
1155        critical: &[(ExpectedPacket, Vec<u8>)],
1156        cancellation: &CancellationToken,
1157    ) -> Result<()> {
1158        for location in &self.critical_offsets {
1159            check_cancel(cancellation)?;
1160            let (_, packet) = critical.get(location.critical_index).ok_or_else(|| {
1161                Par2Error::CreationValidation {
1162                    path: self.volumes[location.volume_index]
1163                        .stage_path
1164                        .display()
1165                        .to_string(),
1166                    reason: "critical packet set changed after staging".to_string(),
1167                }
1168            })?;
1169            let volume = &mut self.volumes[location.volume_index];
1170            if packet.len() != location.len {
1171                return Err(Par2Error::CreationValidation {
1172                    path: volume.stage_path.display().to_string(),
1173                    reason: "critical packet length changed after staging".to_string(),
1174                });
1175            }
1176            volume
1177                .file
1178                .seek(SeekFrom::Start(location.offset))
1179                .map_err(Par2Error::Io)?;
1180            volume.file.write_all(packet).map_err(Par2Error::Io)?;
1181        }
1182        for volume in &mut self.volumes {
1183            volume.file.flush().map_err(Par2Error::Io)?;
1184        }
1185        Ok(())
1186    }
1187
1188    fn finish_recovery_headers(
1189        &mut self,
1190        slice_size: usize,
1191        set_id: RecoverySetId,
1192        cancellation: &CancellationToken,
1193    ) -> Result<()> {
1194        for volume in &mut self.volumes {
1195            for slot in &mut volume.recovery_slots {
1196                check_cancel(cancellation)?;
1197                if slot.bytes_written != slice_size as u64 {
1198                    return Err(Par2Error::CreationValidation {
1199                        path: volume.stage_path.display().to_string(),
1200                        reason: format!(
1201                            "recovery exponent {} received {} of {} bytes",
1202                            slot.exponent, slot.bytes_written, slice_size
1203                        ),
1204                    });
1205                }
1206                let packet_length = (HEADER_SIZE as u64)
1207                    .checked_add(4)
1208                    .and_then(|length| length.checked_add(slice_size as u64))
1209                    .ok_or_else(|| Par2Error::ResourceLimitExceeded {
1210                        reason: "recovery packet length overflows".to_string(),
1211                    })?;
1212                let hash = std::mem::replace(&mut slot.hasher, Md5State::new()).finalize();
1213                let header = encode_header(
1214                    crate::packet::header::TYPE_RECOVERY,
1215                    set_id,
1216                    packet_length,
1217                    hash,
1218                )?;
1219                volume.file.seek(SeekFrom::Start(slot.header_offset))?;
1220                volume.file.write_all(&header)?;
1221            }
1222            volume.file.flush()?;
1223            volume.file.sync_all()?;
1224        }
1225        Ok(())
1226    }
1227
1228    fn validate(
1229        &mut self,
1230        plan: &Par2CreatePlan,
1231        sources: &[CreationSource],
1232        cancellation: &CancellationToken,
1233    ) -> Result<()> {
1234        // Volumes validate independently; running them in parallel overlaps
1235        // their fsyncs and re-read hashing, which dominates on network
1236        // storage. The sequential arm keeps the single-threaded-wasm /
1237        // pinned-single-thread behavior (`wasm32-wasip1-threads` probes `true`
1238        // and validates in parallel like native), and errors surface
1239        // first-by-volume-order in both arms
1240        // (rayon's own Result collection would report an arbitrary racer).
1241        let validate_parallel = reedsolomon_rs::threading::parallel_enabled()
1242            && self.volumes.len() > 1
1243            && super::encode::configured_create_threads() != 1;
1244        if !validate_parallel {
1245            for volume in &mut self.volumes {
1246                check_cancel(cancellation)?;
1247                volume.file.flush()?;
1248                volume.file.sync_all()?;
1249                validate_staged_volume(volume, plan, sources, cancellation)?;
1250            }
1251            return Ok(());
1252        }
1253        use rayon::prelude::*;
1254        self.volumes
1255            .par_iter_mut()
1256            .map(|volume| {
1257                check_cancel(cancellation)?;
1258                volume.file.flush()?;
1259                volume.file.sync_all()?;
1260                validate_staged_volume(volume, plan, sources, cancellation)
1261            })
1262            .collect::<Vec<Result<()>>>()
1263            .into_iter()
1264            .collect::<Result<Vec<()>>>()
1265            .map(|_| ())
1266    }
1267
1268    fn commit(self, overwrite: bool, cancellation: &CancellationToken) -> Result<()> {
1269        self.commit_with_publish_hook(overwrite, cancellation, |_, _| {})
1270    }
1271
1272    fn commit_with_publish_hook<F>(
1273        self,
1274        overwrite: bool,
1275        cancellation: &CancellationToken,
1276        before_publish: F,
1277    ) -> Result<()>
1278    where
1279        F: FnMut(&Path, &Path),
1280    {
1281        self.commit_with_transaction_hooks(overwrite, cancellation, |_, _| {}, before_publish)
1282    }
1283
1284    fn commit_with_transaction_hooks<F, G>(
1285        mut self,
1286        overwrite: bool,
1287        cancellation: &CancellationToken,
1288        mut before_backup_rename: F,
1289        mut before_publish: G,
1290    ) -> Result<()>
1291    where
1292        F: FnMut(&Path, &Path),
1293        G: FnMut(&Path, &Path),
1294    {
1295        for volume in &mut self.volumes {
1296            volume.file.flush()?;
1297            volume.file.sync_all()?;
1298        }
1299        let volumes = std::mem::take(&mut self.volumes);
1300        let stage_paths = volumes
1301            .iter()
1302            .map(|volume| volume.stage_path.clone())
1303            .collect::<Vec<_>>();
1304        let targets = volumes
1305            .iter()
1306            .map(|volume| volume.target_path.clone())
1307            .collect::<Vec<_>>();
1308        // The write handles are NOT dropped here any more. Publishing hard
1309        // links the stage onto the target, so each of these handles already
1310        // references the inode the published target names -- the pin the
1311        // rollback path needs, for free, with no second open.
1312        let mut stage_handles = volumes
1313            .into_iter()
1314            .map(|volume| Some(volume.file))
1315            .collect::<Vec<_>>();
1316        let planned_targets = std::mem::take(&mut self.planned_targets);
1317        let planned_pins = std::mem::take(&mut self.planned_pins);
1318
1319        let mut backups = Vec::<BackupEntry>::new();
1320        let mut installed = Vec::<InstalledTarget>::new();
1321        let result = (|| {
1322            if planned_targets.len() != targets.len() {
1323                return Err(Par2Error::InvalidCreationOptions {
1324                    reason: "creation target snapshot count is inconsistent".to_string(),
1325                });
1326            }
1327            if overwrite {
1328                for (index, target) in targets.iter().enumerate() {
1329                    if cancellation.is_cancelled() {
1330                        return Err(Par2Error::Cancelled);
1331                    }
1332                    let planned = planned_targets[index];
1333                    let current = capture_target_snapshot(target).map_err(Par2Error::Io)?;
1334                    // Numbers first, then the pin: the snapshot catches a
1335                    // type change (file -> directory), and the pin catches a
1336                    // same-numbers replacement the snapshot cannot see.
1337                    let pinned_object_intact =
1338                        match planned_pins.get(index).and_then(Option::as_ref) {
1339                            Some(pin) => pin.still_at(target).map_err(Par2Error::Io)?,
1340                            None => true,
1341                        };
1342                    if current != planned || !pinned_object_intact {
1343                        return Err(Par2Error::CreationValidation {
1344                            path: target.display().to_string(),
1345                            reason: "output target changed after planning".to_string(),
1346                        });
1347                    }
1348                    if matches!(planned, TargetSnapshot::Directory) {
1349                        return Err(Par2Error::UnsafeCreationOutput {
1350                            path: target.display().to_string(),
1351                            reason: "output path is a directory".to_string(),
1352                        });
1353                    }
1354                    if matches!(planned, TargetSnapshot::Absent) {
1355                        continue;
1356                    }
1357                    let TargetSnapshot::File(_) = planned else {
1358                        return Err(Par2Error::UnsafeCreationOutput {
1359                            path: target.display().to_string(),
1360                            reason: "output path is not a regular file".to_string(),
1361                        });
1362                    };
1363                    // Pinned before the rename, so the handle follows the file
1364                    // into the backup name and the restore can prove it is
1365                    // moving back the same object.
1366                    let pin = InodePin::open(target, targets.len())?;
1367                    let (namespace, backup) = reserve_backup_namespace(target, index)?;
1368                    before_backup_rename(target, &backup);
1369                    if let Err(error) = fs::rename(target, &backup) {
1370                        let _ = fs::remove_dir(&namespace);
1371                        return Err(Par2Error::Io(error));
1372                    }
1373                    let moved = capture_target_snapshot(&backup).map_err(Par2Error::Io)?;
1374                    if moved != planned {
1375                        match restore_moved_target(&backup, target, moved) {
1376                            Ok(true) => {
1377                                fs::remove_dir(&namespace).map_err(|error| {
1378                                    backup_recovery_error(
1379                                        target,
1380                                        &backup,
1381                                        format!("output backup cleanup failed: {error}"),
1382                                    )
1383                                })?;
1384                            }
1385                            Ok(false) => {
1386                                return Err(backup_recovery_error(
1387                                    target,
1388                                    &backup,
1389                                    "output restore found an occupied target".to_string(),
1390                                ));
1391                            }
1392                            Err(error) => {
1393                                return Err(backup_recovery_error(
1394                                    target,
1395                                    &backup,
1396                                    format!("output restore failed: {error}"),
1397                                ));
1398                            }
1399                        }
1400                        return Err(Par2Error::CreationValidation {
1401                            path: target.display().to_string(),
1402                            reason: "output target changed while reserving backup".to_string(),
1403                        });
1404                    }
1405                    let TargetSnapshot::File(identity) = moved else {
1406                        return Err(Par2Error::CreationValidation {
1407                            path: target.display().to_string(),
1408                            reason: "output identity is unavailable".to_string(),
1409                        });
1410                    };
1411                    backups.push(BackupEntry {
1412                        target: target.clone(),
1413                        backup,
1414                        namespace,
1415                        identity,
1416                        pin,
1417                    });
1418                }
1419            }
1420            for (index, (stage, target)) in stage_paths.iter().zip(targets.iter()).enumerate() {
1421                if cancellation.is_cancelled() {
1422                    return Err(Par2Error::Cancelled);
1423                }
1424                let pin = stage_handles
1425                    .get_mut(index)
1426                    .and_then(|slot| slot.take())
1427                    .map(InodePin::adopt);
1428                let identity = match publish_no_replace_with_tracking(stage, target, || {
1429                    before_publish(stage, target)
1430                }) {
1431                    Ok(identity) => identity,
1432                    Err(PublishError::Installed { identity, error }) => {
1433                        installed.push(InstalledTarget {
1434                            path: target.clone(),
1435                            identity: Some(identity),
1436                            pin,
1437                        });
1438                        return Err(Par2Error::Io(error));
1439                    }
1440                    Err(PublishError::NotInstalled(error)) => {
1441                        if error.kind() == std::io::ErrorKind::AlreadyExists {
1442                            return Err(Par2Error::CreationOutputExists {
1443                                path: target.display().to_string(),
1444                            });
1445                        }
1446                        return Err(Par2Error::Io(error));
1447                    }
1448                };
1449                installed.push(InstalledTarget {
1450                    path: target.clone(),
1451                    identity: Some(identity),
1452                    pin,
1453                });
1454            }
1455            sync_parent_directories(&targets)?;
1456            Ok(())
1457        })();
1458
1459        if let Err(error) = result {
1460            let mut restorable_targets = Vec::new();
1461            let mut rollback_error = None;
1462            for installed_target in installed.iter().rev() {
1463                match quarantine_owned_target(installed_target) {
1464                    Ok(rollback) => {
1465                        if rollback.can_restore_backup() {
1466                            restorable_targets.push(installed_target.path.clone());
1467                        }
1468                    }
1469                    Err(error) => {
1470                        if rollback_error.is_none() {
1471                            rollback_error = Some(error);
1472                        }
1473                    }
1474                }
1475            }
1476            if overwrite {
1477                for backup_entry in backups.iter().rev() {
1478                    if !installed
1479                        .iter()
1480                        .any(|installed_target| installed_target.path == backup_entry.target)
1481                        || restorable_targets
1482                            .iter()
1483                            .any(|restorable| restorable == &backup_entry.target)
1484                    {
1485                        // Restore without replacement. Rollback keeps the
1486                        // private backup recoverable because stable Rust has
1487                        // no cross-platform handle-bound unlink primitive.
1488                        // Pin first, recorded identity second: the pin cannot
1489                        // be fooled by a foreign file that inherited the
1490                        // backup's inode number.
1491                        let backup_is_ours = backup_entry
1492                            .pin
1493                            .still_at(&backup_entry.backup)
1494                            .unwrap_or(false);
1495                        let identity = target_file_identity(&backup_entry.backup);
1496                        if !backup_is_ours
1497                            || !matches!(identity, Ok(Some(identity)) if identity == backup_entry.identity)
1498                        {
1499                            if rollback_error.is_none() {
1500                                rollback_error = Some(match identity {
1501                                    Err(error) => Par2Error::Io(error),
1502                                    Ok(_) => Par2Error::CreationValidation {
1503                                        path: backup_entry.target.display().to_string(),
1504                                        reason: "backup identity changed during restore"
1505                                            .to_string(),
1506                                    },
1507                                });
1508                            }
1509                            continue;
1510                        }
1511                        match restore_moved_target(
1512                            &backup_entry.backup,
1513                            &backup_entry.target,
1514                            TargetSnapshot::File(backup_entry.identity),
1515                        ) {
1516                            Ok(true) => {}
1517                            Ok(false) => {
1518                                if rollback_error.is_none() {
1519                                    rollback_error = Some(backup_recovery_error(
1520                                        &backup_entry.target,
1521                                        &backup_entry.backup,
1522                                        "output restore lost a replacement race".to_string(),
1523                                    ));
1524                                }
1525                            }
1526                            Err(error) => {
1527                                if rollback_error.is_none() {
1528                                    rollback_error = Some(backup_recovery_error(
1529                                        &backup_entry.target,
1530                                        &backup_entry.backup,
1531                                        format!("output restore failed: {error}"),
1532                                    ));
1533                                }
1534                            }
1535                        }
1536                    }
1537                }
1538            }
1539            for backup_entry in &backups {
1540                if !backup_entry.backup.exists() {
1541                    let _ = fs::remove_dir(&backup_entry.namespace);
1542                }
1543            }
1544            for stage in &stage_paths {
1545                let _ = fs::remove_file(stage);
1546            }
1547            return Err(rollback_error.unwrap_or(error));
1548        }
1549        let mut cleanup_error = None;
1550        for backup_entry in backups {
1551            match target_file_identity(&backup_entry.backup) {
1552                Ok(Some(identity)) if identity == backup_entry.identity => {
1553                    if let Err(error) = fs::remove_file(&backup_entry.backup) {
1554                        if cleanup_error.is_none() {
1555                            cleanup_error = Some(backup_recovery_error(
1556                                &backup_entry.target,
1557                                &backup_entry.backup,
1558                                format!("output backup cleanup failed: {error}"),
1559                            ));
1560                        }
1561                    } else if let Err(error) = fs::remove_dir(&backup_entry.namespace)
1562                        && cleanup_error.is_none()
1563                    {
1564                        cleanup_error = Some(backup_recovery_error(
1565                            &backup_entry.target,
1566                            &backup_entry.namespace,
1567                            format!("output backup namespace cleanup failed: {error}"),
1568                        ));
1569                    }
1570                }
1571                Ok(_) => {
1572                    if cleanup_error.is_none() {
1573                        cleanup_error = Some(backup_recovery_error(
1574                            &backup_entry.target,
1575                            &backup_entry.backup,
1576                            "output backup changed before cleanup".to_string(),
1577                        ));
1578                    }
1579                }
1580                Err(error) => {
1581                    if cleanup_error.is_none() {
1582                        cleanup_error = Some(backup_recovery_error(
1583                            &backup_entry.target,
1584                            &backup_entry.backup,
1585                            format!("output backup cleanup could not be verified: {error}"),
1586                        ));
1587                    }
1588                }
1589            }
1590        }
1591        self.committed = true;
1592        cleanup_error.map_or(Ok(()), Err)
1593    }
1594
1595    fn bytes_written(&self) -> Result<u64> {
1596        self.volumes.iter().try_fold(0u64, |total, volume| {
1597            fs::metadata(&volume.stage_path)
1598                .map(|metadata| metadata.len())
1599                .or_else(|_| fs::metadata(&volume.target_path).map(|metadata| metadata.len()))
1600                .map_err(Par2Error::Io)
1601                .and_then(|length| {
1602                    total
1603                        .checked_add(length)
1604                        .ok_or_else(|| Par2Error::ResourceLimitExceeded {
1605                            reason: "output byte count overflows".to_string(),
1606                        })
1607                })
1608        })
1609    }
1610}
1611
1612impl Drop for StagedOutputs {
1613    fn drop(&mut self) {
1614        if self.committed {
1615            return;
1616        }
1617        cleanup_stage_files(std::mem::take(&mut self.volumes));
1618    }
1619}
1620
1621fn cleanup_stage_files(volumes: Vec<VolumeState>) {
1622    let stage_paths = volumes
1623        .iter()
1624        .map(|volume| volume.stage_path.clone())
1625        .collect::<Vec<_>>();
1626    drop(volumes);
1627    for stage_path in stage_paths {
1628        let _ = fs::remove_file(stage_path);
1629    }
1630}
1631
1632pub(crate) fn write_outputs(
1633    plan: &Par2CreatePlan,
1634    mut sources: Vec<CreationSource>,
1635    options: &Par2CreatorOptions,
1636    mut backend: SelectedBackend,
1637) -> Result<Par2CreateOutcome> {
1638    if options.cancellation.is_cancelled() {
1639        return Err(Par2Error::Cancelled);
1640    }
1641    // The transform arm checks its own arithmetic against the dense definition
1642    // once per band (see `super::transform`). A disagreement is not something
1643    // the staged volumes can be repaired from — the bytes already written are
1644    // the suspect ones — so the whole staging is discarded and the create runs
1645    // again with the arm switched off. It can happen at most once.
1646    let mut transform_policy = super::transform::policy_from_env();
1647    let mut hydrated = false;
1648    let mut staged = loop {
1649        // The plan's sources carry their identity but not their content hashes
1650        // (see `CreationSource`). The encoder's own feed can produce those for
1651        // free — it is about to read exactly these bytes — but only when it is
1652        // the CPU encoder, only when there are recovery blocks for it to
1653        // compute, and only when the pass is single-stripe, because a
1654        // stripe-major feed does not deliver a file's bytes in file order.
1655        // Anything else reads them here.
1656        let fuse_source_hashing = plan.recovery_count > 0
1657            && matches!(backend, SelectedBackend::Cpu)
1658            && super::encode::forward_stripe_count(
1659                plan.slice_size,
1660                plan.source_slice_count as usize,
1661                &plan.recovery_exponents,
1662                options
1663                    .memory_limit
1664                    .unwrap_or_else(super::plan::default_memory_limit),
1665                options.forward_kernel,
1666                transform_policy,
1667            )? == 1;
1668        if !fuse_source_hashing && !hydrated {
1669            super::source::hydrate_source_hashes(
1670                &mut sources,
1671                plan.slice_size,
1672                &options.cancellation,
1673            )?;
1674            hydrated = true;
1675        }
1676        let critical = build_critical_packets(plan, &sources)?;
1677        let creator = encode_creator_packet(plan.recovery_set_id)?;
1678        let mut staged = StagedOutputs::create(plan, &critical, &creator, &options.cancellation)?;
1679
1680        if plan.recovery_count > 0 {
1681            let slice_size =
1682                usize::try_from(plan.slice_size).map_err(|_| Par2Error::ResourceLimitExceeded {
1683                    reason: "slice size exceeds addressable memory".to_string(),
1684                })?;
1685            let mut provider =
1686                DiskSourceProvider::open(&sources, slice_size, &options.cancellation)?;
1687            let attempt = {
1688                let mut sink = RecoveryWriter {
1689                    outputs: &mut staged,
1690                    cancellation: &options.cancellation,
1691                    slice_size,
1692                };
1693                match &mut backend {
1694                    SelectedBackend::Cpu => {
1695                        let encoder =
1696                            ForwardEncoder::new(slice_size, plan.recovery_exponents.clone())?;
1697                        let encoder_options = ForwardEncoderOptions {
1698                            memory_limit: options.memory_limit,
1699                            cancel: Some(options.cancellation.clone()),
1700                            progress: options.progress.clone(),
1701                            kernel: options.forward_kernel,
1702                            transform: transform_policy,
1703                        };
1704                        if fuse_source_hashing {
1705                            let mut hasher =
1706                                super::source::FusedSourceHasher::new(&sources, plan.slice_size)?;
1707                            let attempt = encoder.encode_attempt(
1708                                &mut provider,
1709                                &encoder_options,
1710                                &mut sink,
1711                                Some(&mut hasher),
1712                            )?;
1713                            match attempt {
1714                                EncodeAttempt::Complete => (attempt, Some(hasher.finish()?)),
1715                                EncodeAttempt::TransformProbeMismatch => (attempt, None),
1716                            }
1717                        } else {
1718                            let attempt = encoder.encode_attempt(
1719                                &mut provider,
1720                                &encoder_options,
1721                                &mut sink,
1722                                None,
1723                            )?;
1724                            (attempt, None)
1725                        }
1726                    }
1727                    #[cfg(all(feature = "metal", target_os = "macos", target_arch = "aarch64"))]
1728                    SelectedBackend::Metal(state) => {
1729                        state.encode(
1730                            &mut provider,
1731                            &plan.recovery_exponents,
1732                            slice_size,
1733                            &options.cancellation,
1734                            options.progress.clone(),
1735                            &mut sink,
1736                        )?;
1737                        (EncodeAttempt::Complete, None)
1738                    }
1739                }
1740            };
1741            let (attempt, fused) = attempt;
1742            if attempt == EncodeAttempt::TransformProbeMismatch {
1743                drop(provider);
1744                drop(staged);
1745                tracing::warn!(
1746                    "PAR2 transform recovery arithmetic disagreed with the dense definition; \
1747                     recreating the recovery volumes from the dense encoder"
1748                );
1749                transform_policy = super::transform::TransformPolicy::Never;
1750                continue;
1751            }
1752            provider.verify_unchanged()?;
1753            if let Some(fused) = fused {
1754                fused.apply(&mut sources)?;
1755                // Same packets at the same offsets, now with the hashes the
1756                // encode produced; the staged layout never depended on their
1757                // values.
1758                let critical = build_critical_packets(plan, &sources)?;
1759                staged.rewrite_critical_packets(&critical, &options.cancellation)?;
1760            }
1761            staged.finish_recovery_headers(
1762                slice_size,
1763                plan.recovery_set_id,
1764                &options.cancellation,
1765            )?;
1766        }
1767        break staged;
1768    };
1769
1770    staged.validate(plan, &sources, &options.cancellation)?;
1771    let bytes_written = staged.bytes_written()?;
1772    staged.commit(options.overwrite, &options.cancellation)?;
1773    Ok(Par2CreateOutcome {
1774        recovery_set_id: plan.recovery_set_id,
1775        main_path: plan.main_path.clone(),
1776        volume_paths: plan.volume_paths.clone(),
1777        output_paths: plan.output_paths.clone(),
1778        source_slice_count: plan.source_slice_count,
1779        recovery_count: plan.recovery_count,
1780        bytes_written,
1781        dry_run: false,
1782        requested_backend: options.backend,
1783        selected_backend: selected_policy(&backend),
1784    })
1785}
1786
1787fn build_critical_packets(
1788    plan: &Par2CreatePlan,
1789    sources: &[CreationSource],
1790) -> Result<Vec<(ExpectedPacket, Vec<u8>)>> {
1791    let mut main_body = Vec::with_capacity(12 + sources.len() * 16);
1792    main_body.extend_from_slice(&plan.slice_size.to_le_bytes());
1793    main_body.extend_from_slice(&(sources.len() as u32).to_le_bytes());
1794    for source in sources {
1795        main_body.extend_from_slice(source.file_id.as_bytes());
1796    }
1797    let mut packets = Vec::with_capacity(1 + sources.len() * 2);
1798    packets.push((
1799        ExpectedPacket::Main,
1800        encode_packet(
1801            crate::packet::header::TYPE_MAIN,
1802            plan.recovery_set_id,
1803            &main_body,
1804        )?,
1805    ));
1806    for source in sources {
1807        let mut body = Vec::with_capacity(56 + source.par2_name.len() + 3);
1808        body.extend_from_slice(source.file_id.as_bytes());
1809        body.extend_from_slice(&source.hash_full);
1810        body.extend_from_slice(&source.hash_16k);
1811        body.extend_from_slice(&source.file_length.to_le_bytes());
1812        body.extend_from_slice(source.par2_name.as_bytes());
1813        pad_body(&mut body)?;
1814        packets.push((
1815            ExpectedPacket::FileDescription(source.file_id),
1816            encode_packet(
1817                crate::packet::header::TYPE_FILE_DESC,
1818                plan.recovery_set_id,
1819                &body,
1820            )?,
1821        ));
1822    }
1823    for source in sources {
1824        let mut body = Vec::with_capacity(16 + source.slice_checksums.len() * 20);
1825        body.extend_from_slice(source.file_id.as_bytes());
1826        for checksum in &source.slice_checksums {
1827            body.extend_from_slice(&checksum.md5);
1828            body.extend_from_slice(&checksum.crc32.to_le_bytes());
1829        }
1830        pad_body(&mut body)?;
1831        packets.push((
1832            ExpectedPacket::InputFileSliceChecksum(source.file_id),
1833            encode_packet(
1834                crate::packet::header::TYPE_IFSC,
1835                plan.recovery_set_id,
1836                &body,
1837            )?,
1838        ));
1839    }
1840    Ok(packets)
1841}
1842
1843fn encode_creator_packet(set_id: RecoverySetId) -> Result<Vec<u8>> {
1844    let mut body = CREATOR_ID.to_vec();
1845    pad_body(&mut body)?;
1846    encode_packet(TYPE_CREATOR, set_id, &body)
1847}
1848
1849/// Stage one copy of one critical packet and remember where it landed.
1850fn append_critical_packet(
1851    staged: &mut StagedOutputs,
1852    volume_index: usize,
1853    critical_index: usize,
1854    critical: &[(ExpectedPacket, Vec<u8>)],
1855) -> Result<()> {
1856    let (expected, packet) = &critical[critical_index];
1857    let volume =
1858        staged
1859            .volumes
1860            .get_mut(volume_index)
1861            .ok_or_else(|| Par2Error::CreationValidation {
1862                path: "critical packet output".to_string(),
1863                reason: "critical packet volume index is out of range".to_string(),
1864            })?;
1865    let offset = volume.file.stream_position().map_err(Par2Error::Io)?;
1866    append_packet(volume, expected.clone(), packet)?;
1867    staged.critical_offsets.push(CriticalLocation {
1868        volume_index,
1869        offset,
1870        critical_index,
1871        len: packet.len(),
1872    });
1873    Ok(())
1874}
1875
1876fn append_packet(volume: &mut VolumeState, expected: ExpectedPacket, packet: &[u8]) -> Result<()> {
1877    volume.file.write_all(packet).map_err(Par2Error::Io)?;
1878    volume.expected.push(expected);
1879    Ok(())
1880}
1881
1882fn append_recovery_slot(
1883    staged: &mut StagedOutputs,
1884    volume_index: usize,
1885    global_index: usize,
1886    exponent: RecoveryExponent,
1887    plan: &Par2CreatePlan,
1888    cancellation: &CancellationToken,
1889) -> Result<()> {
1890    check_cancel(cancellation)?;
1891    let slice_size =
1892        usize::try_from(plan.slice_size).map_err(|_| Par2Error::ResourceLimitExceeded {
1893            reason: "slice size exceeds addressable memory".to_string(),
1894        })?;
1895    let packet_length = (HEADER_SIZE as u64)
1896        .checked_add(4)
1897        .and_then(|length| length.checked_add(slice_size as u64))
1898        .ok_or_else(|| Par2Error::ResourceLimitExceeded {
1899            reason: "recovery packet length overflows".to_string(),
1900        })?;
1901    let volume =
1902        staged
1903            .volumes
1904            .get_mut(volume_index)
1905            .ok_or_else(|| Par2Error::CreationValidation {
1906                path: plan.output_paths.get(volume_index).map_or_else(
1907                    || "recovery output".to_string(),
1908                    |path| path.display().to_string(),
1909                ),
1910                reason: "recovery volume index is out of range".to_string(),
1911            })?;
1912    let header_offset = volume.file.stream_position().map_err(Par2Error::Io)?;
1913    let header = encode_header(
1914        crate::packet::header::TYPE_RECOVERY,
1915        plan.recovery_set_id,
1916        packet_length,
1917        [0; 16],
1918    )?;
1919    volume.file.write_all(&header).map_err(Par2Error::Io)?;
1920    volume
1921        .file
1922        .write_all(&exponent.to_le_bytes())
1923        .map_err(Par2Error::Io)?;
1924    let data_offset = volume.file.stream_position().map_err(Par2Error::Io)?;
1925    write_zeroes(&mut volume.file, slice_size, cancellation)?;
1926    check_cancel(cancellation)?;
1927    volume.expected.push(ExpectedPacket::Recovery(exponent));
1928    let slot_index = volume.recovery_slots.len();
1929    volume.recovery_slots.push(RecoverySlot {
1930        exponent,
1931        header_offset,
1932        data_offset,
1933        bytes_written: 0,
1934        hasher: {
1935            let mut hasher =
1936                start_streamed_hash(plan.recovery_set_id, crate::packet::header::TYPE_RECOVERY);
1937            hasher.update(&exponent.to_le_bytes());
1938            hasher
1939        },
1940    });
1941    if global_index != staged.locations.len() {
1942        return Err(Par2Error::CreationValidation {
1943            path: volume.stage_path.display().to_string(),
1944            reason: "recovery output order is inconsistent".to_string(),
1945        });
1946    }
1947    staged.locations.push(RecoveryLocation {
1948        volume_index,
1949        slot_index,
1950    });
1951    Ok(())
1952}
1953
1954fn validate_staged_volume(
1955    volume: &VolumeState,
1956    plan: &Par2CreatePlan,
1957    sources: &[CreationSource],
1958    cancellation: &CancellationToken,
1959) -> Result<()> {
1960    check_cancel(cancellation)?;
1961    let file_length = fs::metadata(&volume.stage_path)
1962        .map_err(Par2Error::Io)?
1963        .len();
1964    // We wrote this volume, so we know exactly how many packets it should hold.
1965    // Admit one extra so a volume with a stray packet fails with the packet
1966    // count mismatch below rather than a generic resource-limit message.
1967    let scan_ceiling = volume.expected.len().saturating_add(1);
1968    let scan_limits = crate::packet::PacketScanLimits::default()
1969        .with_max_retained_packets(scan_ceiling)
1970        .with_max_examined_packets(scan_ceiling as u64);
1971    let scanned = match scan_packets_from_path_with_set_ids_cancellable(
1972        &volume.stage_path,
1973        scan_limits,
1974        cancellation,
1975    ) {
1976        Ok(scanned) => scanned,
1977        Err(Par2Error::Cancelled) => return Err(Par2Error::Cancelled),
1978        Err(error) => {
1979            return Err(Par2Error::CreationValidation {
1980                path: volume.stage_path.display().to_string(),
1981                reason: error.to_string(),
1982            });
1983        }
1984    };
1985    validation_checkpoint(cancellation);
1986    check_cancel(cancellation)?;
1987    if scanned.len() != volume.expected.len() {
1988        return Err(validation_error(
1989            &volume.stage_path,
1990            format!(
1991                "parsed {} packets but staged {} packets",
1992                scanned.len(),
1993                volume.expected.len()
1994            ),
1995        ));
1996    }
1997    let mut file = File::open(&volume.stage_path).map_err(Par2Error::Io)?;
1998    let mut offset = 0u64;
1999    for (scanned, expected) in scanned.iter().zip(&volume.expected) {
2000        check_cancel(cancellation)?;
2001        if scanned.offset != offset || scanned.recovery_set_id != plan.recovery_set_id {
2002            return Err(validation_error(
2003                &volume.stage_path,
2004                "packet offsets or recovery-set identifiers are inconsistent".to_string(),
2005            ));
2006        }
2007        let mut header_bytes = [0u8; HEADER_SIZE];
2008        file.seek(SeekFrom::Start(offset)).map_err(Par2Error::Io)?;
2009        file.read_exact(&mut header_bytes).map_err(Par2Error::Io)?;
2010        let header = PacketHeader::parse(&header_bytes, offset)
2011            .map_err(|error| validation_error(&volume.stage_path, error.to_string()))?;
2012        offset = offset.checked_add(header.length).ok_or_else(|| {
2013            validation_error(&volume.stage_path, "packet offsets overflow".to_string())
2014        })?;
2015        validate_expected_packet(
2016            scanned,
2017            expected,
2018            plan,
2019            sources,
2020            &volume.stage_path,
2021            cancellation,
2022        )?;
2023    }
2024    check_cancel(cancellation)?;
2025    if offset != file_length {
2026        return Err(validation_error(
2027            &volume.stage_path,
2028            format!("packet stream ends at {offset}, file length is {file_length}"),
2029        ));
2030    }
2031    Ok(())
2032}
2033
2034fn validate_expected_packet(
2035    scanned: &crate::packet::ScannedPacket,
2036    expected: &ExpectedPacket,
2037    plan: &Par2CreatePlan,
2038    sources: &[CreationSource],
2039    path: &Path,
2040    cancellation: &CancellationToken,
2041) -> Result<()> {
2042    check_cancel(cancellation)?;
2043    if scanned.recovery_set_id != plan.recovery_set_id {
2044        return Err(validation_error(
2045            path,
2046            "packet recovery-set identifier differs from plan".to_string(),
2047        ));
2048    }
2049    match (expected, &scanned.packet) {
2050        (ExpectedPacket::Main, Packet::Main(packet)) => {
2051            let ids = sources
2052                .iter()
2053                .map(|source| source.file_id)
2054                .collect::<Vec<_>>();
2055            if packet.slice_size != plan.slice_size
2056                || packet.recovery_file_ids != ids
2057                || !packet.non_recovery_file_ids.is_empty()
2058            {
2059                return Err(validation_error(
2060                    path,
2061                    "Main packet content differs from plan".to_string(),
2062                ));
2063            }
2064        }
2065        (ExpectedPacket::FileDescription(file_id), Packet::FileDescription(packet)) => {
2066            let source = source_by_id(sources, *file_id).ok_or_else(|| {
2067                validation_error(
2068                    path,
2069                    "FileDesc packet references an unknown file".to_string(),
2070                )
2071            })?;
2072            if packet.file_id != source.file_id
2073                || packet.hash_full != source.hash_full
2074                || packet.hash_16k != source.hash_16k
2075                || packet.file_length != source.file_length
2076                || packet.par2_name != source.par2_name
2077            {
2078                return Err(validation_error(
2079                    path,
2080                    "FileDesc packet content differs from source".to_string(),
2081                ));
2082            }
2083        }
2084        (
2085            ExpectedPacket::InputFileSliceChecksum(file_id),
2086            Packet::InputFileSliceChecksum(packet),
2087        ) => {
2088            let source = source_by_id(sources, *file_id).ok_or_else(|| {
2089                validation_error(path, "IFSC packet references an unknown file".to_string())
2090            })?;
2091            if packet.file_id != source.file_id || packet.checksums != source.slice_checksums {
2092                return Err(validation_error(
2093                    path,
2094                    "IFSC packet content differs from source".to_string(),
2095                ));
2096            }
2097        }
2098        (ExpectedPacket::Recovery(exponent), Packet::RecoverySlice(packet)) => {
2099            if packet.exponent != *exponent || packet.data.len() != plan.slice_size as usize {
2100                return Err(validation_error(
2101                    path,
2102                    "recovery packet length or exponent differs from plan".to_string(),
2103                ));
2104            }
2105            let valid = match packet.data.validate_packet_hash_cancellable(
2106                plan.recovery_set_id.as_bytes(),
2107                *exponent,
2108                cancellation,
2109            ) {
2110                Ok(valid) => valid,
2111                Err(Par2Error::Cancelled) => return Err(Par2Error::Cancelled),
2112                Err(error) => return Err(validation_error(path, error.to_string())),
2113            };
2114            if !valid {
2115                return Err(validation_error(
2116                    path,
2117                    "recovery packet hash is invalid".to_string(),
2118                ));
2119            }
2120        }
2121        (ExpectedPacket::Creator, Packet::Creator(packet)) => {
2122            if packet.creator_id.as_bytes() != CREATOR_ID {
2123                return Err(validation_error(
2124                    path,
2125                    "Creator packet identifier differs from plan".to_string(),
2126                ));
2127            }
2128        }
2129        _ => {
2130            return Err(validation_error(
2131                path,
2132                "packet type differs from expected critical order".to_string(),
2133            ));
2134        }
2135    }
2136    Ok(())
2137}
2138
2139fn source_by_id(sources: &[CreationSource], file_id: FileId) -> Option<&CreationSource> {
2140    sources.iter().find(|source| source.file_id == file_id)
2141}
2142
2143struct RecoveryWriter<'a> {
2144    outputs: &'a mut StagedOutputs,
2145    cancellation: &'a CancellationToken,
2146    slice_size: usize,
2147}
2148
2149impl ForwardRecoverySink for RecoveryWriter<'_> {
2150    fn write_recovery_chunk(
2151        &mut self,
2152        output_index: usize,
2153        exponent: RecoveryExponent,
2154        offset: u64,
2155        data: &[u8],
2156    ) -> Result<()> {
2157        if self.cancellation.is_cancelled() {
2158            return Err(Par2Error::Cancelled);
2159        }
2160        if data.is_empty() {
2161            return Ok(());
2162        }
2163        if data.len() > self.slice_size
2164            || usize::try_from(offset)
2165                .ok()
2166                .and_then(|start| start.checked_add(data.len()))
2167                .is_none_or(|end| end > self.slice_size)
2168        {
2169            return Err(Par2Error::CreationValidation {
2170                path: "recovery output".to_string(),
2171                reason: "encoder supplied a chunk outside the slice".to_string(),
2172            });
2173        }
2174        let location = *self.outputs.locations.get(output_index).ok_or_else(|| {
2175            Par2Error::CreationValidation {
2176                path: "recovery output".to_string(),
2177                reason: "encoder supplied an unknown output index".to_string(),
2178            }
2179        })?;
2180        let volume = self
2181            .outputs
2182            .volumes
2183            .get_mut(location.volume_index)
2184            .ok_or_else(|| Par2Error::CreationValidation {
2185                path: "recovery output".to_string(),
2186                reason: "recovery volume index is out of range".to_string(),
2187            })?;
2188        let slot = volume
2189            .recovery_slots
2190            .get_mut(location.slot_index)
2191            .ok_or_else(|| Par2Error::CreationValidation {
2192                path: volume.stage_path.display().to_string(),
2193                reason: "recovery slot index is out of range".to_string(),
2194            })?;
2195        if slot.exponent != exponent || slot.bytes_written != offset {
2196            return Err(Par2Error::CreationValidation {
2197                path: volume.stage_path.display().to_string(),
2198                reason: "encoder recovery chunks are out of order".to_string(),
2199            });
2200        }
2201        let write_offset = slot.data_offset.checked_add(offset).ok_or_else(|| {
2202            Par2Error::ResourceLimitExceeded {
2203                reason: "recovery write offset overflows".to_string(),
2204            }
2205        })?;
2206        volume.file.seek(SeekFrom::Start(write_offset))?;
2207        volume.file.write_all(data)?;
2208        slot.hasher.update(data);
2209        slot.bytes_written = slot
2210            .bytes_written
2211            .checked_add(data.len() as u64)
2212            .ok_or_else(|| Par2Error::ResourceLimitExceeded {
2213                reason: "recovery byte count overflows".to_string(),
2214            })?;
2215        Ok(())
2216    }
2217}
2218
2219fn sync_parent_directories(paths: &[PathBuf]) -> Result<()> {
2220    #[cfg(unix)]
2221    {
2222        let mut synced = Vec::<PathBuf>::new();
2223        for path in paths {
2224            let parent = path.parent().unwrap_or_else(|| Path::new("."));
2225            if synced.iter().any(|previous| previous == parent) {
2226                continue;
2227            }
2228            File::open(parent).map_err(Par2Error::Io)?.sync_all()?;
2229            synced.push(parent.to_path_buf());
2230        }
2231    }
2232    #[cfg(not(unix))]
2233    {
2234        let _ = paths;
2235    }
2236    Ok(())
2237}
2238
2239fn target_file_identity(path: &Path) -> std::io::Result<Option<FileIdentity>> {
2240    let metadata = match fs::symlink_metadata(path) {
2241        Ok(metadata) => metadata,
2242        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2243        Err(error) => return Err(error),
2244    };
2245    if !metadata.is_file() {
2246        return Ok(None);
2247    }
2248    #[cfg(unix)]
2249    {
2250        Ok(FileIdentity::from_metadata(&metadata))
2251    }
2252    #[cfg(windows)]
2253    {
2254        match File::open(path) {
2255            Ok(file) => match fs::symlink_metadata(path) {
2256                Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => {
2257                    FileIdentity::from_file(&file).map(Some)
2258                }
2259                Ok(_) => Ok(None),
2260                Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
2261                Err(error) => Err(error),
2262            },
2263            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
2264            Err(error) => Err(error),
2265        }
2266    }
2267    #[cfg(target_os = "wasi")]
2268    {
2269        Ok(FileIdentity::from_wasi_path(path, &metadata))
2270    }
2271    #[cfg(not(any(unix, windows, target_os = "wasi")))]
2272    {
2273        let _ = path;
2274        Ok(None)
2275    }
2276}
2277
2278pub(crate) fn capture_target_snapshot(path: &Path) -> std::io::Result<TargetSnapshot> {
2279    match fs::symlink_metadata(path) {
2280        Ok(metadata) if metadata.file_type().is_symlink() => Ok(TargetSnapshot::Symlink),
2281        Ok(metadata) if metadata.is_dir() => Ok(TargetSnapshot::Directory),
2282        Ok(metadata) if metadata.is_file() => target_file_identity(path)?
2283            .map(TargetSnapshot::File)
2284            .ok_or_else(|| std::io::Error::other("output identity is unavailable")),
2285        Ok(_) => Ok(TargetSnapshot::Special),
2286        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(TargetSnapshot::Absent),
2287        Err(error) => Err(error),
2288    }
2289}
2290
2291fn quarantine_owned_target(target: &InstalledTarget) -> Result<RollbackTarget> {
2292    quarantine_owned_target_with_hook(target, || {})
2293}
2294
2295fn quarantine_owned_target_with_hook<F: FnOnce()>(
2296    target: &InstalledTarget,
2297    before_move: F,
2298) -> Result<RollbackTarget> {
2299    let Some(expected) = target.identity else {
2300        return Ok(RollbackTarget::Unchanged);
2301    };
2302    let (namespace, quarantine) = reserve_quarantine_namespace(&target.path, 0)?;
2303    // The pin is the authority; the recorded `FileIdentity` is kept for the
2304    // diagnostic and for platforms with no pinning. `still_at` cannot be fooled
2305    // by inode reuse, because the reused inode is the one this handle holds.
2306    let still_ours = match target.pin.as_ref() {
2307        Some(pin) => pin.still_at(&target.path).map_err(Par2Error::Io)?,
2308        None => matches!(
2309            target_file_identity(&target.path).map_err(Par2Error::Io)?,
2310            Some(identity) if identity == expected
2311        ),
2312    };
2313    if !still_ours {
2314        let _ = fs::remove_dir(&namespace);
2315        return match fs::symlink_metadata(&target.path) {
2316            // Gone entirely: nothing of ours to quarantine.
2317            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2318                Ok(RollbackTarget::Missing)
2319            }
2320            Err(error) => Err(Par2Error::Io(error)),
2321            // Something else occupies the path. Never touch it.
2322            Ok(_) => Err(Par2Error::CreationValidation {
2323                path: target.path.display().to_string(),
2324                reason: "installed output identity changed before rollback".to_string(),
2325            }),
2326        };
2327    }
2328    before_move();
2329    match fs::rename(&target.path, &quarantine) {
2330        Ok(()) => {
2331            // The same question on the far side of the rename, which is the
2332            // site no timestamp scheme could serve: `ctime` moves on rename and
2333            // birth time repeats inside one coarse clock tick. The pinned
2334            // handle followed the file, so it answers here unchanged.
2335            let moved_is_ours = match target.pin.as_ref() {
2336                Some(pin) => pin.still_at(&quarantine).map_err(Par2Error::Io)?,
2337                None => target_file_identity(&quarantine).map_err(Par2Error::Io)? == Some(expected),
2338            };
2339            if moved_is_ours {
2340                // The owned object remains recoverable in its private
2341                // namespace. Stable Rust has no cross-platform handle-bound
2342                // unlink primitive that can safely delete it after a race.
2343                Ok(RollbackTarget::OwnedQuarantined(namespace))
2344            } else {
2345                let moved = capture_target_snapshot(&quarantine).map_err(Par2Error::Io)?;
2346                match restore_moved_target(&quarantine, &target.path, moved) {
2347                    Ok(true) => {}
2348                    Ok(false) => {
2349                        return Err(backup_recovery_error(
2350                            &target.path,
2351                            &quarantine,
2352                            "installed output restore found an occupied target".to_string(),
2353                        ));
2354                    }
2355                    Err(error) => {
2356                        return Err(backup_recovery_error(
2357                            &target.path,
2358                            &quarantine,
2359                            format!("installed output restore failed: {error}"),
2360                        ));
2361                    }
2362                }
2363                fs::remove_dir(&namespace).map_err(|error| {
2364                    backup_recovery_error(
2365                        &target.path,
2366                        &quarantine,
2367                        format!("installed output quarantine cleanup failed: {error}"),
2368                    )
2369                })?;
2370                Err(Par2Error::CreationValidation {
2371                    path: target.path.display().to_string(),
2372                    reason: "installed output identity changed during rollback".to_string(),
2373                })
2374            }
2375        }
2376        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2377            let _ = fs::remove_dir(&namespace);
2378            Ok(RollbackTarget::Missing)
2379        }
2380        Err(error) => {
2381            let _ = fs::remove_dir(&namespace);
2382            Err(Par2Error::Io(error))
2383        }
2384    }
2385}
2386
2387fn backup_recovery_error(target: &Path, backup: &Path, reason: String) -> Par2Error {
2388    Par2Error::CreationValidation {
2389        path: target.display().to_string(),
2390        reason: format!("{reason}; recovery path: {}", backup.display()),
2391    }
2392}
2393
2394fn restore_moved_target(
2395    source: &Path,
2396    target: &Path,
2397    moved: TargetSnapshot,
2398) -> std::io::Result<bool> {
2399    if matches!(moved, TargetSnapshot::Absent) {
2400        return Ok(true);
2401    }
2402    match rename_no_replace(source, target) {
2403        Ok(()) => Ok(true),
2404        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
2405        Err(error) if no_replace_unavailable(&error) => {
2406            if matches!(moved, TargetSnapshot::Directory) {
2407                return Err(error);
2408            }
2409            match fs::hard_link(source, target) {
2410                Ok(()) => match fs::remove_file(source) {
2411                    Ok(()) => Ok(true),
2412                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(true),
2413                    Err(error) => Err(error),
2414                },
2415                Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
2416                Err(error) => Err(error),
2417            }
2418        }
2419        Err(error) => Err(error),
2420    }
2421}
2422
2423fn no_replace_unavailable(error: &std::io::Error) -> bool {
2424    matches!(
2425        error.kind(),
2426        std::io::ErrorKind::Unsupported | std::io::ErrorKind::InvalidInput
2427    )
2428}
2429
2430#[cfg(target_os = "linux")]
2431fn rename_no_replace(source: &Path, target: &Path) -> std::io::Result<()> {
2432    let source = CString::new(source.as_os_str().as_bytes())
2433        .map_err(|_| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
2434    let target = CString::new(target.as_os_str().as_bytes())
2435        .map_err(|_| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
2436    // Raw syscall rather than `libc::renameat2`: the libc crate declares the
2437    // wrapper only for the gnu flavor, so the wrapper breaks musl builds
2438    // (bitten twice by static bench bundles). `SYS_renameat2` is declared for
2439    // both; the kernel call is identical (Linux 3.15+), and the existing
2440    // ENOSYS/EINVAL fallback path continues to cover older kernels.
2441    let result = unsafe {
2442        libc::syscall(
2443            libc::SYS_renameat2,
2444            libc::AT_FDCWD,
2445            source.as_ptr(),
2446            libc::AT_FDCWD,
2447            target.as_ptr(),
2448            libc::RENAME_NOREPLACE,
2449        )
2450    };
2451    if result == 0 {
2452        Ok(())
2453    } else {
2454        Err(std::io::Error::last_os_error())
2455    }
2456}
2457
2458#[cfg(target_os = "macos")]
2459fn rename_no_replace(source: &Path, target: &Path) -> std::io::Result<()> {
2460    let source = CString::new(source.as_os_str().as_bytes())
2461        .map_err(|_| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
2462    let target = CString::new(target.as_os_str().as_bytes())
2463        .map_err(|_| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
2464    let result = unsafe {
2465        libc::renameatx_np(
2466            libc::AT_FDCWD,
2467            source.as_ptr(),
2468            libc::AT_FDCWD,
2469            target.as_ptr(),
2470            libc::RENAME_EXCL,
2471        )
2472    };
2473    if result == 0 {
2474        Ok(())
2475    } else {
2476        Err(std::io::Error::last_os_error())
2477    }
2478}
2479
2480#[cfg(windows)]
2481fn rename_no_replace(source: &Path, target: &Path) -> std::io::Result<()> {
2482    use std::os::windows::ffi::OsStrExt;
2483
2484    let source = source
2485        .as_os_str()
2486        .encode_wide()
2487        .chain(std::iter::once(0))
2488        .collect::<Vec<_>>();
2489    let target = target
2490        .as_os_str()
2491        .encode_wide()
2492        .chain(std::iter::once(0))
2493        .collect::<Vec<_>>();
2494    let result =
2495        unsafe { move_file_ex_w(source.as_ptr(), target.as_ptr(), MOVEFILE_WRITE_THROUGH) };
2496    if result != 0 {
2497        Ok(())
2498    } else {
2499        Err(std::io::Error::last_os_error())
2500    }
2501}
2502
2503#[cfg(windows)]
2504const MOVEFILE_WRITE_THROUGH: u32 = 0x0000_0008;
2505
2506#[cfg(windows)]
2507#[link(name = "kernel32")]
2508unsafe extern "system" {
2509    #[link_name = "MoveFileExW"]
2510    fn move_file_ex_w(source: *const u16, target: *const u16, flags: u32) -> i32;
2511}
2512
2513#[cfg(all(unix, not(any(target_os = "linux", target_os = "macos"))))]
2514fn rename_no_replace(_: &Path, _: &Path) -> std::io::Result<()> {
2515    Err(std::io::Error::from(std::io::ErrorKind::Unsupported))
2516}
2517
2518#[cfg(not(any(unix, windows)))]
2519fn rename_no_replace(_: &Path, _: &Path) -> std::io::Result<()> {
2520    Err(std::io::Error::from(std::io::ErrorKind::Unsupported))
2521}
2522
2523#[cfg(test)]
2524fn publish_no_replace(stage: &Path, target: &Path) -> std::io::Result<Option<FileIdentity>> {
2525    publish_no_replace_with_hook(stage, target, || {})
2526}
2527
2528#[cfg(test)]
2529fn publish_no_replace_with_hook<F: FnOnce()>(
2530    stage: &Path,
2531    target: &Path,
2532    before_publish: F,
2533) -> std::io::Result<Option<FileIdentity>> {
2534    publish_no_replace_with_tracking(stage, target, before_publish)
2535        .map(Some)
2536        .map_err(PublishError::into_io_error)
2537}
2538
2539fn publish_no_replace_with_tracking<F: FnOnce()>(
2540    stage: &Path,
2541    target: &Path,
2542    before_publish: F,
2543) -> std::result::Result<FileIdentity, PublishError> {
2544    publish_no_replace_with_tracking_hooks(stage, target, before_publish, || {})
2545}
2546
2547fn publish_no_replace_with_tracking_hooks<F: FnOnce(), G: FnOnce()>(
2548    stage: &Path,
2549    target: &Path,
2550    before_publish: F,
2551    after_link: G,
2552) -> std::result::Result<FileIdentity, PublishError> {
2553    let stage_identity = match target_file_identity(stage) {
2554        Ok(Some(identity)) => identity,
2555        Ok(None) => {
2556            return Err(PublishError::NotInstalled(std::io::Error::other(
2557                "staged output identity is unavailable",
2558            )));
2559        }
2560        Err(error) => return Err(PublishError::NotInstalled(error)),
2561    };
2562    before_publish();
2563    // The stage and target are deliberately in the same directory. A hard
2564    // link is an atomic create-without-replace operation on supported local
2565    // filesystems; unlike rename, it cannot clobber a target created after
2566    // planning. There is intentionally no replacing fallback.
2567    fs::hard_link(stage, target).map_err(PublishError::NotInstalled)?;
2568    after_link();
2569    if let Err(error) = fs::remove_file(stage) {
2570        return Err(PublishError::Installed {
2571            identity: stage_identity,
2572            error,
2573        });
2574    }
2575    Ok(stage_identity)
2576}
2577
2578/// A per-process token that keeps transaction scratch names distinct between
2579/// concurrent creators writing into the same directory.
2580///
2581/// Native keeps `std::process::id()` exactly as before — same value, same
2582/// filenames, no behavior change. wasm has no process ids: `std::process::id()`
2583/// is `unsupported` there and **panics** ("no pids on this platform"), which
2584/// made PAR2 creation abort on `wasm32-wasip1` the moment it tried to stage an
2585/// output. A process-lifetime token derived once from the startup clock stands
2586/// in.
2587///
2588/// Uniqueness never rested on this value alone in either case: the generated
2589/// name also carries a nanosecond stamp, the output index, and an attempt
2590/// counter, and the file/directory is created with `create_new` (or a failing
2591/// `create_dir`), so a collision retries the loop instead of clobbering.
2592fn process_token() -> u32 {
2593    #[cfg(not(target_family = "wasm"))]
2594    {
2595        std::process::id()
2596    }
2597    #[cfg(target_family = "wasm")]
2598    {
2599        static TOKEN: std::sync::OnceLock<u32> = std::sync::OnceLock::new();
2600        *TOKEN.get_or_init(|| {
2601            SystemTime::now()
2602                .duration_since(UNIX_EPOCH)
2603                .map(|duration| duration.subsec_nanos())
2604                .unwrap_or(0)
2605        })
2606    }
2607}
2608
2609fn create_stage_file(target: &Path, index: usize) -> Result<(PathBuf, File)> {
2610    let parent = target.parent().unwrap_or_else(|| Path::new("."));
2611    let stamp = SystemTime::now()
2612        .duration_since(UNIX_EPOCH)
2613        .map(|duration| duration.as_nanos())
2614        .unwrap_or(0);
2615    for attempt in 0..100u32 {
2616        let name = format!(
2617            ".par2-create-{}-{stamp}-{index}-{attempt}.tmp",
2618            process_token()
2619        );
2620        let path = parent.join(name);
2621        // Existing stage files are never reused; create_new makes crash
2622        // residue inert and preserves concurrent transactions.
2623        match OpenOptions::new()
2624            .create_new(true)
2625            .read(true)
2626            .write(true)
2627            .open(&path)
2628        {
2629            Ok(file) => return Ok((path, file)),
2630            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
2631            Err(error) => return Err(Par2Error::Io(error)),
2632        }
2633    }
2634    Err(Par2Error::ResourceLimitExceeded {
2635        reason: "could not allocate a unique staged output path".to_string(),
2636    })
2637}
2638
2639fn reserve_backup_namespace(target: &Path, index: usize) -> Result<(PathBuf, PathBuf)> {
2640    reserve_private_namespace(target, index, "backup")
2641}
2642
2643fn reserve_quarantine_namespace(target: &Path, index: usize) -> Result<(PathBuf, PathBuf)> {
2644    reserve_private_namespace(target, index, "quarantine")
2645}
2646
2647fn reserve_private_namespace(
2648    target: &Path,
2649    index: usize,
2650    kind: &str,
2651) -> Result<(PathBuf, PathBuf)> {
2652    let parent = target.parent().unwrap_or_else(|| Path::new("."));
2653    let target_name = target
2654        .file_name()
2655        .ok_or_else(|| Par2Error::UnsafeCreationOutput {
2656            path: target.display().to_string(),
2657            reason: "output path has no filename".to_string(),
2658        })?;
2659    let stamp = SystemTime::now()
2660        .duration_since(UNIX_EPOCH)
2661        .map(|duration| duration.as_nanos())
2662        .unwrap_or(0);
2663    for attempt in 0..100u32 {
2664        let namespace = parent.join(format!(
2665            ".par2-create-{kind}-{}-{stamp}-{index}-{attempt}.tmp",
2666            process_token()
2667        ));
2668        match create_private_directory(&namespace) {
2669            Ok(()) => return Ok((namespace.clone(), namespace.join(target_name))),
2670            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
2671            Err(error) => return Err(Par2Error::Io(error)),
2672        }
2673    }
2674    Err(Par2Error::ResourceLimitExceeded {
2675        reason: "could not allocate a unique transaction path".to_string(),
2676    })
2677}
2678
2679fn create_private_directory(path: &Path) -> std::io::Result<()> {
2680    #[cfg(unix)]
2681    {
2682        use std::os::unix::fs::DirBuilderExt;
2683
2684        let mut builder = fs::DirBuilder::new();
2685        builder.mode(0o700);
2686        builder.create(path)
2687    }
2688    #[cfg(not(unix))]
2689    fs::create_dir(path)
2690}
2691
2692fn write_zeroes(
2693    file: &mut File,
2694    mut length: usize,
2695    cancellation: &CancellationToken,
2696) -> Result<()> {
2697    while length > 0 {
2698        check_cancel(cancellation)?;
2699        let take = length.min(ZERO_WRITE_BUFFER.len());
2700        file.write_all(&ZERO_WRITE_BUFFER[..take])
2701            .map_err(Par2Error::Io)?;
2702        length -= take;
2703    }
2704    Ok(())
2705}
2706
2707fn check_cancel(cancellation: &CancellationToken) -> Result<()> {
2708    if cancellation.is_cancelled() {
2709        Err(Par2Error::Cancelled)
2710    } else {
2711        Ok(())
2712    }
2713}
2714
2715fn pad_body(body: &mut Vec<u8>) -> Result<()> {
2716    let padding = (4 - body.len() % 4) % 4;
2717    body.try_reserve(padding)
2718        .map_err(|_| Par2Error::ResourceLimitExceeded {
2719            reason: "packet body allocation failed".to_string(),
2720        })?;
2721    body.resize(body.len() + padding, 0);
2722    Ok(())
2723}
2724
2725fn bit_length(value: u32) -> u32 {
2726    u32::BITS - value.leading_zeros()
2727}
2728
2729fn validation_error(path: &Path, reason: String) -> Par2Error {
2730    Par2Error::CreationValidation {
2731        path: path.display().to_string(),
2732        reason,
2733    }
2734}
2735
2736#[cfg(test)]
2737mod tests {
2738    use super::*;
2739
2740    #[test]
2741    fn zero_fill_honors_cancellation_before_writing() {
2742        let staged = tempfile::NamedTempFile::new().unwrap();
2743        let mut file = OpenOptions::new().write(true).open(staged.path()).unwrap();
2744        let cancellation = CancellationToken::new();
2745        cancellation.cancel();
2746
2747        let error =
2748            write_zeroes(&mut file, ZERO_WRITE_BUFFER.len() * 2, &cancellation).unwrap_err();
2749        assert!(matches!(error, Par2Error::Cancelled));
2750        assert_eq!(file.metadata().unwrap().len(), 0);
2751    }
2752
2753    #[test]
2754    fn cleanup_stage_files_drops_handles_before_removal() {
2755        let directory = tempfile::tempdir().unwrap();
2756        let stage_path = directory.path().join("stage.tmp");
2757        let file = OpenOptions::new()
2758            .create_new(true)
2759            .read(true)
2760            .write(true)
2761            .open(&stage_path)
2762            .unwrap();
2763        cleanup_stage_files(vec![VolumeState {
2764            stage_path: stage_path.clone(),
2765            target_path: directory.path().join("target.par2"),
2766            file,
2767            expected: Vec::new(),
2768            recovery_slots: Vec::new(),
2769        }]);
2770        assert!(!stage_path.exists());
2771    }
2772
2773    #[test]
2774    fn no_replace_publication_rejects_a_deterministic_target_race() {
2775        let directory = tempfile::tempdir().unwrap();
2776        let stage = directory.path().join("stage.tmp");
2777        let target = directory.path().join("set.par2");
2778        fs::write(&stage, b"staged").unwrap();
2779
2780        let error = publish_no_replace_with_hook(&stage, &target, || {
2781            fs::write(&target, b"foreign").unwrap();
2782        })
2783        .unwrap_err();
2784
2785        assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists);
2786        assert_eq!(fs::read(&stage).unwrap(), b"staged");
2787        assert_eq!(fs::read(&target).unwrap(), b"foreign");
2788    }
2789
2790    #[test]
2791    fn published_target_is_tracked_when_stage_unlink_fails() {
2792        let directory = tempfile::tempdir().unwrap();
2793        let stage = directory.path().join("stage.tmp");
2794        let target = directory.path().join("set.par2");
2795        fs::write(&stage, b"staged").unwrap();
2796
2797        let error = publish_no_replace_with_tracking_hooks(
2798            &stage,
2799            &target,
2800            || {},
2801            || {
2802                fs::remove_file(&stage).unwrap();
2803                fs::create_dir(&stage).unwrap();
2804            },
2805        )
2806        .unwrap_err();
2807
2808        assert!(matches!(error, PublishError::Installed { .. }));
2809        assert_eq!(fs::read(&target).unwrap(), b"staged");
2810        assert!(stage.is_dir());
2811    }
2812
2813    #[test]
2814    fn rollback_boundary_swap_preserves_a_foreign_target() {
2815        let directory = tempfile::tempdir().unwrap();
2816        let stage = directory.path().join("stage.tmp");
2817        let target = directory.path().join("set.par2");
2818        fs::write(&stage, b"staged").unwrap();
2819        let identity = publish_no_replace(&stage, &target)
2820            .unwrap()
2821            .expect("local test files have a strong identity");
2822
2823        let installed = InstalledTarget {
2824            path: target.clone(),
2825            identity: Some(identity),
2826            pin: Some(InodePin::open(&target, 1).unwrap()),
2827        };
2828
2829        // Delete and recreate inside one clock tick. On an inode-recycling
2830        // filesystem the foreign file inherits the removed file's inode number
2831        // *and* its birth time, so only the pinned handle can tell them apart.
2832        let error = quarantine_owned_target_with_hook(&installed, || {
2833            fs::remove_file(&target).unwrap();
2834            fs::write(&target, b"foreign").unwrap();
2835        })
2836        .unwrap_err();
2837        assert!(matches!(error, Par2Error::CreationValidation { .. }));
2838        assert_eq!(fs::read(&target).unwrap(), b"foreign");
2839        assert!(fs::read_dir(directory.path()).unwrap().all(|entry| {
2840            !entry
2841                .unwrap()
2842                .file_name()
2843                .to_string_lossy()
2844                .starts_with(".par2-create-quarantine-")
2845        }));
2846    }
2847
2848    /// Inode reuse cannot forge a pinned identity, and the test does not
2849    /// depend on the filesystem actually reusing the inode.
2850    ///
2851    /// The pin makes the answer the same either way: while the handle lives the
2852    /// kernel cannot hand its inode to the replacement, so a recycling
2853    /// filesystem (overlayfs, ext4) and a non-recycling one (APFS) both end at
2854    /// "not our file". NTFS is a third shape again — it recycles the MFT record
2855    /// but bumps the sequence number in the same 64-bit index, so the numbers
2856    /// move even unpinned — and it lands on the same answer. The diagnostic line
2857    /// records which case ran, so a container run can show the hostile one was
2858    /// really exercised.
2859    #[test]
2860    fn pinned_identity_refuses_a_recreated_file_even_when_the_inode_is_reused() {
2861        let directory = tempfile::tempdir().unwrap();
2862
2863        // Control first: the same delete+recreate with nothing pinned, which is
2864        // what the pre-fix check faced. On a recycling filesystem the
2865        // replacement inherits the inode number outright, so `(device, inode)`
2866        // -- and the birth time, stamped from the same coarse clock tick --
2867        // report the foreign file as ours.
2868        let control = directory.path().join("unpinned.par2");
2869        fs::write(&control, b"ours").unwrap();
2870        let control_before = file_identity_numbers(&control);
2871        fs::remove_file(&control).unwrap();
2872        fs::write(&control, b"foreign").unwrap();
2873        let control_after = file_identity_numbers(&control);
2874        eprintln!(
2875            "unpinned reuse: {} (before={control_before:?} after={control_after:?})",
2876            match reuse_verdict(control_before, control_after) {
2877                "YES" => "YES -- the pre-fix identity was forged",
2878                "no" => "no -- this filesystem did not recycle here",
2879                other => other,
2880            }
2881        );
2882
2883        let path = directory.path().join("owned.par2");
2884        fs::write(&path, b"ours").unwrap();
2885        let before = file_identity_numbers(&path);
2886        let pin = InodePin::open(&path, 1).unwrap();
2887        assert!(pin.still_at(&path).unwrap());
2888
2889        // Same tick, same directory, same filesystem -- but pinned.
2890        fs::remove_file(&path).unwrap();
2891        fs::write(&path, b"foreign").unwrap();
2892        let after = file_identity_numbers(&path);
2893
2894        // The pin is why this reads "no" on a filesystem that recycled in the
2895        // control above: a referenced inode cannot be handed to a new file.
2896        eprintln!(
2897            "pinned reuse:   {} (before={before:?} after={after:?})",
2898            reuse_verdict(before, after)
2899        );
2900        assert!(
2901            !pin.still_at(&path).unwrap(),
2902            "a recreated file must never satisfy the pin"
2903        );
2904    }
2905
2906    /// The same scenario through the rollback entry point, which is where a
2907    /// wrong answer would move a foreign file into quarantine.
2908    #[test]
2909    fn rollback_refuses_a_recreated_target_on_a_recycling_filesystem() {
2910        let directory = tempfile::tempdir().unwrap();
2911        let path = directory.path().join("set.par2");
2912        fs::write(&path, b"ours").unwrap();
2913        let identity = target_file_identity(&path).unwrap().unwrap();
2914        let before = file_identity_numbers(&path);
2915        let installed = InstalledTarget {
2916            path: path.clone(),
2917            identity: Some(identity),
2918            pin: Some(InodePin::open(&path, 1).unwrap()),
2919        };
2920
2921        fs::remove_file(&path).unwrap();
2922        fs::write(&path, b"foreign").unwrap();
2923        eprintln!(
2924            "inode reuse: {}",
2925            reuse_verdict(before, file_identity_numbers(&path))
2926        );
2927
2928        let error = quarantine_owned_target(&installed).unwrap_err();
2929        assert!(matches!(error, Par2Error::CreationValidation { .. }));
2930        // The foreign file is left exactly where it was.
2931        assert_eq!(fs::read(&path).unwrap(), b"foreign");
2932        assert!(fs::read_dir(directory.path()).unwrap().all(|entry| {
2933            !entry
2934                .unwrap()
2935                .file_name()
2936                .to_string_lossy()
2937                .starts_with(".par2-create-quarantine-")
2938        }));
2939    }
2940
2941    /// NTFS file tunneling forges the creation time, and the pin refuses anyway.
2942    ///
2943    /// The Windows counterpart of the inode-reuse case above, and the reason
2944    /// `FileIdentity::Windows`'s `birth` field is documented as insufficient on
2945    /// its own. NTFS replays a removed file's creation time onto a file
2946    /// recreated with the same name in the same directory, so the control arm
2947    /// here is a deliberate, filesystem-provided forgery rather than a
2948    /// clock-granularity accident — and the shape it forges is exactly the shape
2949    /// this module's comparison sites see. Every one of them is a same-name,
2950    /// same-directory comparison.
2951    ///
2952    /// The forgery is reported, not asserted: a volume with tunneling disabled
2953    /// (`MaximumTunnelEntries = 0`) or a non-NTFS volume just prints the weaker
2954    /// control line. The pinned assertion is the one that must hold everywhere.
2955    #[cfg(windows)]
2956    #[test]
2957    fn a_pin_refuses_a_recreated_file_whose_creation_time_ntfs_replayed() {
2958        let directory = tempfile::tempdir().unwrap();
2959
2960        // Control first, unpinned: what the recorded identity alone would face.
2961        let control = directory.path().join("unpinned.par2");
2962        fs::write(&control, b"ours").unwrap();
2963        let birth_before = file_creation_ticks(&control);
2964        let numbers_before = file_identity_numbers(&control);
2965        fs::remove_file(&control).unwrap();
2966        fs::write(&control, b"foreign").unwrap();
2967        let birth_after = file_creation_ticks(&control);
2968        let numbers_after = file_identity_numbers(&control);
2969        eprintln!(
2970            "ntfs creation-time replay: {} (before={birth_before:?} after={birth_after:?})",
2971            if birth_before.is_some() && birth_before == birth_after {
2972                "YES -- tunneling forged the timestamp; a birth field alone would have been fooled"
2973            } else {
2974                "no -- tunneling is disabled or unsupported on this volume"
2975            }
2976        );
2977        eprintln!(
2978            "ntfs file-index repeat: {} (before={numbers_before:?} after={numbers_after:?})",
2979            reuse_verdict(numbers_before, numbers_after)
2980        );
2981
2982        // The same delete-and-recreate against a live pin, which must refuse it
2983        // whatever the two control lines above reported.
2984        let path = directory.path().join("owned.par2");
2985        fs::write(&path, b"ours").unwrap();
2986        let pin = InodePin::open(&path, 1).unwrap();
2987        assert!(pin.still_at(&path).unwrap());
2988        fs::remove_file(&path).unwrap();
2989        fs::write(&path, b"foreign").unwrap();
2990        assert!(
2991            !pin.still_at(&path).unwrap(),
2992            "a recreated file must never satisfy the pin"
2993        );
2994        assert_eq!(fs::read(&path).unwrap(), b"foreign");
2995    }
2996
2997    /// Creation-time ticks for the tunneling diagnostic above.
2998    #[cfg(windows)]
2999    fn file_creation_ticks(path: &Path) -> Option<u64> {
3000        match target_file_identity(path) {
3001            Ok(Some(FileIdentity::Windows { birth, .. })) => birth,
3002            _ => None,
3003        }
3004    }
3005
3006    /// `(device, inode)` for the diagnostic in the two tests above.
3007    #[cfg(unix)]
3008    fn file_identity_numbers(path: &Path) -> Option<(u64, u64)> {
3009        use std::os::unix::fs::MetadataExt;
3010
3011        fs::symlink_metadata(path)
3012            .ok()
3013            .map(|metadata| (metadata.dev(), metadata.ino()))
3014    }
3015
3016    /// `(volume, index)` — the same diagnostic on Windows. Before this existed
3017    /// the helper returned `None` on every non-Unix target, so the reuse line
3018    /// compared `None` against `None` and printed "YES" on a run where nothing
3019    /// had been measured at all. A diagnostic that reports a forgery it never
3020    /// observed is worse than no diagnostic.
3021    #[cfg(windows)]
3022    fn file_identity_numbers(path: &Path) -> Option<(u64, u64)> {
3023        match target_file_identity(path) {
3024            Ok(Some(FileIdentity::Windows { volume, index, .. })) => {
3025                Some((u64::from(volume), index))
3026            }
3027            _ => None,
3028        }
3029    }
3030
3031    #[cfg(not(any(unix, windows)))]
3032    fn file_identity_numbers(_path: &Path) -> Option<(u64, u64)> {
3033        None
3034    }
3035
3036    /// Reuse verdict for the diagnostics, with "the numbers were unavailable"
3037    /// kept distinct from "the numbers matched" — see `file_identity_numbers`.
3038    fn reuse_verdict(before: Option<(u64, u64)>, after: Option<(u64, u64)>) -> &'static str {
3039        match (before, after) {
3040            (Some(before), Some(after)) if before == after => "YES",
3041            (Some(_), Some(_)) => "no",
3042            _ => "unknown -- this target reports no file identity numbers",
3043        }
3044    }
3045
3046    #[test]
3047    fn overwrite_backup_boundary_restores_a_directory() {
3048        let directory = tempfile::tempdir().unwrap();
3049        let target = directory.path().join("set.par2");
3050        fs::write(&target, b"planned").unwrap();
3051        let staged = test_staged_outputs(&directory, &["set"]);
3052
3053        let error = staged
3054            .commit_with_transaction_hooks(
3055                true,
3056                &CancellationToken::new(),
3057                |target, _| {
3058                    fs::remove_file(target).unwrap();
3059                    fs::create_dir(target).unwrap();
3060                    fs::write(target.join("foreign"), b"foreign directory material").unwrap();
3061                },
3062                |_, _| {},
3063            )
3064            .unwrap_err();
3065
3066        assert!(matches!(error, Par2Error::CreationValidation { .. }));
3067        assert!(target.is_dir());
3068        assert_eq!(
3069            fs::read(target.join("foreign")).unwrap(),
3070            b"foreign directory material"
3071        );
3072        assert!(fs::read_dir(directory.path()).unwrap().all(|entry| {
3073            !entry
3074                .unwrap()
3075                .file_name()
3076                .to_string_lossy()
3077                .starts_with(".par2-create-backup-")
3078        }));
3079    }
3080
3081    #[cfg(unix)]
3082    #[test]
3083    fn overwrite_backup_boundary_restores_a_symlink() {
3084        use std::os::unix::fs::symlink;
3085
3086        let directory = tempfile::tempdir().unwrap();
3087        let target = directory.path().join("set.par2");
3088        let referent = directory.path().join("foreign.bin");
3089        fs::write(&target, b"planned").unwrap();
3090        fs::write(&referent, b"foreign symlink material").unwrap();
3091        let staged = test_staged_outputs(&directory, &["set"]);
3092
3093        let error = staged
3094            .commit_with_transaction_hooks(
3095                true,
3096                &CancellationToken::new(),
3097                |target, _| {
3098                    fs::remove_file(target).unwrap();
3099                    symlink(&referent, target).unwrap();
3100                },
3101                |_, _| {},
3102            )
3103            .unwrap_err();
3104
3105        assert!(matches!(error, Par2Error::CreationValidation { .. }));
3106        assert!(
3107            fs::symlink_metadata(&target)
3108                .unwrap()
3109                .file_type()
3110                .is_symlink()
3111        );
3112        assert_eq!(fs::read_link(&target).unwrap(), referent);
3113        assert_eq!(fs::read(&target).unwrap(), b"foreign symlink material");
3114        assert!(fs::read_dir(directory.path()).unwrap().all(|entry| {
3115            !entry
3116                .unwrap()
3117                .file_name()
3118                .to_string_lossy()
3119                .starts_with(".par2-create-backup-")
3120        }));
3121    }
3122
3123    fn test_staged_outputs(directory: &tempfile::TempDir, names: &[&str]) -> StagedOutputs {
3124        let volumes: Vec<VolumeState> = names
3125            .iter()
3126            .map(|name| {
3127                let stage_path = directory.path().join(format!(".{name}.stage"));
3128                let target_path = directory.path().join(format!("{name}.par2"));
3129                let mut file = OpenOptions::new()
3130                    .create_new(true)
3131                    .read(true)
3132                    .write(true)
3133                    .open(&stage_path)
3134                    .unwrap();
3135                file.write_all(format!("staged-{name}").as_bytes()).unwrap();
3136                VolumeState {
3137                    stage_path,
3138                    target_path,
3139                    file,
3140                    expected: Vec::new(),
3141                    recovery_slots: Vec::new(),
3142                }
3143            })
3144            .collect();
3145        let planned_targets: Vec<TargetSnapshot> = volumes
3146            .iter()
3147            .map(|volume| capture_target_snapshot(&volume.target_path).unwrap())
3148            .collect();
3149        // Same pinning the production constructor does, so the tests exercise
3150        // the shipped comparison and not a weaker one.
3151        let planned_pins = volumes
3152            .iter()
3153            .zip(planned_targets.iter())
3154            .map(|(volume, snapshot)| match snapshot {
3155                TargetSnapshot::Absent => None,
3156                _ => Some(InodePin::open(&volume.target_path, volumes.len()).unwrap()),
3157            })
3158            .collect();
3159        StagedOutputs {
3160            critical_offsets: Vec::new(),
3161            volumes,
3162            locations: Vec::new(),
3163            planned_targets,
3164            planned_pins,
3165            committed: false,
3166        }
3167    }
3168
3169    #[test]
3170    fn overwrite_rejects_a_file_that_appears_after_absent_planning() {
3171        let directory = tempfile::tempdir().unwrap();
3172        let target = directory.path().join("set.par2");
3173        let staged = test_staged_outputs(&directory, &["set"]);
3174        fs::write(&target, b"foreign").unwrap();
3175
3176        let error = staged.commit(true, &CancellationToken::new()).unwrap_err();
3177
3178        assert!(matches!(error, Par2Error::CreationValidation { .. }));
3179        assert_eq!(fs::read(&target).unwrap(), b"foreign");
3180    }
3181
3182    #[test]
3183    fn overwrite_rejects_a_replacement_of_the_planned_target() {
3184        let directory = tempfile::tempdir().unwrap();
3185        let target = directory.path().join("set.par2");
3186        fs::write(&target, b"planned").unwrap();
3187        let staged = test_staged_outputs(&directory, &["set"]);
3188        fs::remove_file(&target).unwrap();
3189        fs::write(&target, b"foreign replacement").unwrap();
3190
3191        let error = staged.commit(true, &CancellationToken::new()).unwrap_err();
3192
3193        assert!(matches!(error, Par2Error::CreationValidation { .. }));
3194        assert_eq!(fs::read(&target).unwrap(), b"foreign replacement");
3195    }
3196
3197    #[test]
3198    fn overwrite_rejects_a_directory_that_appears_after_absent_planning() {
3199        let directory = tempfile::tempdir().unwrap();
3200        let target = directory.path().join("set.par2");
3201        let staged = test_staged_outputs(&directory, &["set"]);
3202        fs::create_dir(&target).unwrap();
3203        fs::write(target.join("foreign"), b"foreign directory material").unwrap();
3204
3205        let error = staged.commit(true, &CancellationToken::new()).unwrap_err();
3206
3207        assert!(matches!(error, Par2Error::CreationValidation { .. }));
3208        assert_eq!(
3209            fs::read(target.join("foreign")).unwrap(),
3210            b"foreign directory material"
3211        );
3212    }
3213
3214    #[test]
3215    fn no_overwrite_failure_after_an_earlier_output_keeps_foreign_output_and_quarantines_owned() {
3216        let directory = tempfile::tempdir().unwrap();
3217        let second = directory.path().join("second.par2");
3218        fs::write(&second, b"foreign").unwrap();
3219
3220        let staged = test_staged_outputs(&directory, &["first", "second"]);
3221        let error = staged.commit(false, &CancellationToken::new()).unwrap_err();
3222
3223        assert!(matches!(error, Par2Error::CreationOutputExists { .. }));
3224        assert!(!directory.path().join("first.par2").exists());
3225        assert_eq!(fs::read(&second).unwrap(), b"foreign");
3226        assert!(fs::read_dir(directory.path()).unwrap().any(|entry| {
3227            let path = entry.unwrap().path();
3228            path.file_name().is_some_and(|name| {
3229                name.to_string_lossy()
3230                    .starts_with(".par2-create-quarantine-")
3231            }) && path.join("first.par2").is_file()
3232        }));
3233    }
3234
3235    #[test]
3236    fn overwrite_failure_after_an_earlier_output_restores_owned_and_preserves_foreign() {
3237        let directory = tempfile::tempdir().unwrap();
3238        let first = directory.path().join("first.par2");
3239        let second = directory.path().join("second.par2");
3240        fs::write(&first, b"old-first").unwrap();
3241        fs::write(&second, b"old-second").unwrap();
3242
3243        let staged = test_staged_outputs(&directory, &["first", "second"]);
3244        let error = staged
3245            .commit_with_publish_hook(true, &CancellationToken::new(), |_, target| {
3246                if target == second.as_path() {
3247                    fs::write(target, b"foreign").unwrap();
3248                }
3249            })
3250            .unwrap_err();
3251
3252        assert!(matches!(error, Par2Error::CreationValidation { .. }));
3253        assert_eq!(fs::read(&first).unwrap(), b"old-first");
3254        assert_eq!(fs::read(&second).unwrap(), b"foreign");
3255        assert!(fs::read_dir(directory.path()).unwrap().any(|entry| {
3256            let path = entry.unwrap().path();
3257            path.file_name()
3258                .is_some_and(|name| name.to_string_lossy().starts_with(".par2-create-backup-"))
3259                && path.join("second.par2").is_file()
3260        }));
3261    }
3262
3263    #[test]
3264    fn cancellation_during_staged_validation_cleans_every_stage() {
3265        use std::sync::atomic::Ordering;
3266
3267        let directory = tempfile::tempdir().unwrap();
3268        let source = directory.path().join("source.bin");
3269        let output = directory.path().join("cancelled");
3270        fs::write(&source, b"staged validation cancellation").unwrap();
3271
3272        let cancellation = CancellationToken::new();
3273        let mut options = Par2CreatorOptions::with_output(
3274            output.clone(),
3275            Some(directory.path().to_path_buf()),
3276            vec![source],
3277        );
3278        options.recovery_amount = super::super::options::RecoveryAmount::Count(0);
3279        options.cancellation = cancellation;
3280        let creator = crate::create::Par2Creator::new(options);
3281        let plan = creator.plan().unwrap();
3282
3283        CANCEL_AFTER_VALIDATION_SCAN.store(true, Ordering::Relaxed);
3284        let error = creator.create(&plan).unwrap_err();
3285
3286        assert!(matches!(error, Par2Error::Cancelled));
3287        assert!(!output.with_extension("par2").exists());
3288        assert!(fs::read_dir(directory.path()).unwrap().all(|entry| {
3289            !entry
3290                .unwrap()
3291                .file_name()
3292                .to_string_lossy()
3293                .starts_with(".par2-create-")
3294        }));
3295    }
3296
3297    #[test]
3298    fn backup_namespace_is_atomically_reserved_and_cleanable() {
3299        let directory = tempfile::tempdir().unwrap();
3300        let target = directory.path().join("set.par2");
3301        fs::write(&target, b"old").unwrap();
3302        let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
3303        let (first, second) = std::thread::scope(|scope| {
3304            let first_barrier = std::sync::Arc::clone(&barrier);
3305            let first_target = target.clone();
3306            let first = scope.spawn(move || {
3307                first_barrier.wait();
3308                reserve_backup_namespace(&first_target, 0).unwrap()
3309            });
3310            let second_barrier = std::sync::Arc::clone(&barrier);
3311            let second_target = target.clone();
3312            let second = scope.spawn(move || {
3313                second_barrier.wait();
3314                reserve_backup_namespace(&second_target, 0).unwrap()
3315            });
3316            (first.join().unwrap(), second.join().unwrap())
3317        });
3318        let (namespace, backup) = first;
3319        let (second_namespace, second_backup) = second;
3320        assert!(namespace.is_dir());
3321        assert!(second_namespace.is_dir());
3322        assert_ne!(namespace, second_namespace);
3323        assert!(!backup.exists());
3324        assert!(!second_backup.exists());
3325        fs::rename(&target, &backup).unwrap();
3326        assert!(!target.exists());
3327        fs::rename(&backup, &target).unwrap();
3328        fs::remove_dir(&namespace).unwrap();
3329        fs::remove_dir(&second_namespace).unwrap();
3330        assert_eq!(fs::read(&target).unwrap(), b"old");
3331    }
3332}