Skip to main content

par2_rs/
disk.rs

1//! Filesystem-backed implementation of [`FileAccess`].
2
3use std::collections::HashMap;
4#[cfg(unix)]
5use std::ffi::{CString, OsStr};
6#[cfg(not(windows))]
7use std::fs::OpenOptions;
8use std::fs::{self, File};
9use std::io::{self, Read, Seek, SeekFrom, Write};
10#[cfg(unix)]
11use std::os::fd::{AsRawFd, FromRawFd};
12#[cfg(unix)]
13use std::os::unix::ffi::OsStrExt;
14#[cfg(unix)]
15use std::os::unix::fs::OpenOptionsExt;
16use std::path::{Component, Path, PathBuf};
17
18use crate::par2_set::Par2FileSet;
19use crate::placement::PlacementPlan;
20use crate::types::FileId;
21use crate::verify::{FileAccess, FileRangeReader};
22
23fn repair_path_components(path: &Path) -> io::Result<Vec<&std::ffi::OsStr>> {
24    let mut components = Vec::new();
25    for component in path.components() {
26        match component {
27            Component::Normal(name) => components.push(name),
28            Component::CurDir => {}
29            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
30                return Err(io::Error::new(
31                    io::ErrorKind::InvalidInput,
32                    format!(
33                        "repair destination must remain relative to the working directory: {}",
34                        path.display()
35                    ),
36                ));
37            }
38        }
39    }
40    if components.is_empty() {
41        return Err(io::Error::new(
42            io::ErrorKind::InvalidInput,
43            "repair destination has no filename",
44        ));
45    }
46    Ok(components)
47}
48
49/// Read into `dst` until it is full or the source ends, returning how many
50/// bytes landed. Short of an error, the only way this returns less than
51/// `dst.len()` is end-of-file.
52///
53/// A single [`Read::read`] is permitted to return fewer bytes than asked for at
54/// any time and on any platform, so "one `read` fills the buffer" is never a
55/// safe assumption. It fails *systematically* under wasmtime on
56/// `wasm32-wasip1-threads`: a guest with shared linear memory cannot have host
57/// bytes written straight into it, so wasmtime's WASI preview1 `fd_read` stages
58/// every transfer through a bounce buffer that it caps at 64 KiB — measured,
59/// a 196,608-byte `read` there returns exactly 65,536 every time, while the
60/// same call on plain `wasm32-wasip1` returns all 196,608. That is legal POSIX
61/// behaviour, not a runtime bug.
62///
63/// It matters here because every [`FileAccess`] consumer reads a short return
64/// as end-of-file: `read_file_slice_into` and `checksum_file_slice_padded` stop
65/// filling at the first short read, and `check_slice_span` marks a whole span
66/// damaged when the fill does not reach the expected length. A truncated read
67/// therefore surfaces as *phantom damage* — the shape that made PAR2 repair
68/// fail on `wasm32-wasip1-threads` while the staged bytes on disk were already
69/// byte-perfect. Looping here keeps every filesystem-backed implementation
70/// honest to the contract its callers already assume, on every target.
71pub(crate) fn read_filled<R: Read + ?Sized>(reader: &mut R, dst: &mut [u8]) -> io::Result<usize> {
72    let mut filled = 0usize;
73    while filled < dst.len() {
74        match reader.read(&mut dst[filled..]) {
75            Ok(0) => break,
76            Ok(read) => filled += read,
77            Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
78            Err(error) => return Err(error),
79        }
80    }
81    Ok(filled)
82}
83
84#[cfg(unix)]
85fn c_component(component: &OsStr) -> io::Result<CString> {
86    CString::new(component.as_bytes()).map_err(|_| {
87        io::Error::new(
88            io::ErrorKind::InvalidInput,
89            "repair destination contains a NUL byte",
90        )
91    })
92}
93
94#[cfg(unix)]
95fn open_directory_at(parent: &File, component: &OsStr, create: bool) -> io::Result<File> {
96    let component = c_component(component)?;
97    let flags = libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW;
98    let open = || {
99        // SAFETY: `parent` and `component` stay alive for the call, and the
100        // returned descriptor is immediately adopted by `File` on success.
101        let fd = unsafe { libc::openat(parent.as_raw_fd(), component.as_ptr(), flags) };
102        if fd < 0 {
103            Err(io::Error::last_os_error())
104        } else {
105            // SAFETY: `openat` returned a new, owned descriptor.
106            Ok(unsafe { File::from_raw_fd(fd) })
107        }
108    };
109
110    match open() {
111        Ok(directory) => Ok(directory),
112        Err(error) if create && error.kind() == io::ErrorKind::NotFound => {
113            // SAFETY: the directory descriptor and C string are valid for the
114            // call. The process umask restricts the requested mode as usual.
115            let result = unsafe {
116                libc::mkdirat(
117                    parent.as_raw_fd(),
118                    component.as_ptr(),
119                    0o777 as libc::mode_t,
120                )
121            };
122            if result < 0 {
123                let error = io::Error::last_os_error();
124                if error.kind() != io::ErrorKind::AlreadyExists {
125                    return Err(error);
126                }
127            }
128            open()
129        }
130        Err(error) => Err(error),
131    }
132}
133
134#[cfg(unix)]
135fn open_repair_output(base_dir: &Path, destination: &Path) -> io::Result<File> {
136    let relative = destination.strip_prefix(base_dir).map_err(|_| {
137        io::Error::new(
138            io::ErrorKind::InvalidInput,
139            "repair destination is outside the working directory",
140        )
141    })?;
142    let components = repair_path_components(relative)?;
143    let (filename, parent_components) = components.split_last().expect("checked non-empty");
144
145    // Following a symlink supplied as the base directory is intentional: the
146    // caller chooses that root. Every PAR2-controlled component below it is
147    // opened relative to the resulting descriptor without following links.
148    let mut directory = OpenOptions::new()
149        .read(true)
150        .custom_flags(libc::O_DIRECTORY | libc::O_CLOEXEC)
151        .open(base_dir)?;
152    for component in parent_components {
153        directory = open_directory_at(&directory, component, true)?;
154    }
155
156    let filename = c_component(filename)?;
157    let flags = libc::O_WRONLY | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW;
158    // SAFETY: the directory descriptor and C string are valid for the call,
159    // and the returned descriptor is immediately adopted by `File`.
160    let fd = unsafe {
161        libc::openat(
162            directory.as_raw_fd(),
163            filename.as_ptr(),
164            flags,
165            0o666 as libc::c_uint,
166        )
167    };
168    if fd < 0 {
169        Err(io::Error::last_os_error())
170    } else {
171        // SAFETY: `openat` returned a new, owned descriptor.
172        Ok(unsafe { File::from_raw_fd(fd) })
173    }
174}
175
176#[cfg(unix)]
177fn unix_repair_parent(
178    mut directory: File,
179    components: &[&OsStr],
180    create: bool,
181) -> io::Result<(File, CString)> {
182    let (filename, parent_components) = components.split_last().expect("checked non-empty");
183    for component in parent_components {
184        directory = open_directory_at(&directory, component, create)?;
185    }
186    Ok((directory, c_component(filename)?))
187}
188
189#[cfg(unix)]
190pub(crate) fn rename_within_base(base_dir: &Path, from: &Path, to: &Path) -> io::Result<()> {
191    let from_relative = from.strip_prefix(base_dir).map_err(|_| {
192        io::Error::new(
193            io::ErrorKind::InvalidInput,
194            "repair rename source is outside the working directory",
195        )
196    })?;
197    let to_relative = to.strip_prefix(base_dir).map_err(|_| {
198        io::Error::new(
199            io::ErrorKind::InvalidInput,
200            "repair rename destination is outside the working directory",
201        )
202    })?;
203    let from_components = repair_path_components(from_relative)?;
204    let to_components = repair_path_components(to_relative)?;
205    let root = OpenOptions::new()
206        .read(true)
207        .custom_flags(libc::O_DIRECTORY | libc::O_CLOEXEC)
208        .open(base_dir)?;
209    let (from_parent, from_name) = unix_repair_parent(root.try_clone()?, &from_components, false)?;
210    let (to_parent, to_name) = unix_repair_parent(root, &to_components, true)?;
211
212    // SAFETY: both directory descriptors and C strings remain valid for the
213    // call. `renameat` operates on the link entries themselves and therefore
214    // does not follow either final path component.
215    let result = unsafe {
216        libc::renameat(
217            from_parent.as_raw_fd(),
218            from_name.as_ptr(),
219            to_parent.as_raw_fd(),
220            to_name.as_ptr(),
221        )
222    };
223    if result < 0 {
224        Err(io::Error::last_os_error())
225    } else {
226        Ok(())
227    }
228}
229
230#[cfg(unix)]
231pub(crate) fn remove_file_within_base(base_dir: &Path, path: &Path) -> io::Result<()> {
232    let relative = path.strip_prefix(base_dir).map_err(|_| {
233        io::Error::new(
234            io::ErrorKind::InvalidInput,
235            "repair removal target is outside the working directory",
236        )
237    })?;
238    let components = repair_path_components(relative)?;
239    let root = OpenOptions::new()
240        .read(true)
241        .custom_flags(libc::O_DIRECTORY | libc::O_CLOEXEC)
242        .open(base_dir)?;
243    let (parent, filename) = unix_repair_parent(root, &components, false)?;
244
245    // SAFETY: the directory descriptor and C string remain valid for the
246    // call. With no removal flags, `unlinkat` removes a file or symlink entry
247    // without following the final component.
248    let result = unsafe { libc::unlinkat(parent.as_raw_fd(), filename.as_ptr(), 0) };
249    if result < 0 {
250        Err(io::Error::last_os_error())
251    } else {
252        Ok(())
253    }
254}
255
256#[cfg(windows)]
257fn open_repair_output(base_dir: &Path, destination: &Path) -> io::Result<File> {
258    use cap_std::ambient_authority;
259    use cap_std::fs::{Dir, OpenOptions as CapOpenOptions};
260
261    let relative = destination.strip_prefix(base_dir).map_err(|_| {
262        io::Error::new(
263            io::ErrorKind::InvalidInput,
264            "repair destination is outside the working directory",
265        )
266    })?;
267    let components = repair_path_components(relative)?;
268    let (filename, parent_components) = components.split_last().expect("checked non-empty");
269    let mut directory = Dir::open_ambient_dir(base_dir, ambient_authority())?;
270
271    for component in parent_components {
272        match directory.symlink_metadata(component) {
273            Ok(metadata) if metadata.file_type().is_symlink() => {
274                return Err(io::Error::new(
275                    io::ErrorKind::InvalidInput,
276                    format!(
277                        "repair destination traverses a link: {}",
278                        destination.display()
279                    ),
280                ));
281            }
282            Ok(metadata) if !metadata.is_dir() => {
283                return Err(io::Error::new(
284                    io::ErrorKind::NotADirectory,
285                    format!(
286                        "repair destination parent is not a directory: {}",
287                        destination.display()
288                    ),
289                ));
290            }
291            Ok(_) => {}
292            Err(error) if error.kind() == io::ErrorKind::NotFound => {
293                directory.create_dir(component)?;
294            }
295            Err(error) => return Err(error),
296        }
297        // cap-std resolves each component relative to the held directory
298        // handle and refuses any link traversal that would escape the root.
299        directory = directory.open_dir(component)?;
300    }
301
302    match directory.symlink_metadata(filename) {
303        Ok(metadata) if metadata.file_type().is_symlink() => {
304            return Err(io::Error::new(
305                io::ErrorKind::InvalidInput,
306                format!("repair destination is a link: {}", destination.display()),
307            ));
308        }
309        Ok(_) => {}
310        Err(error) if error.kind() == io::ErrorKind::NotFound => {}
311        Err(error) => return Err(error),
312    }
313
314    let mut options = CapOpenOptions::new();
315    options.write(true).create(true).truncate(false);
316    directory
317        .open_with(filename, &options)
318        .map(cap_std::fs::File::into_std)
319}
320
321#[cfg(windows)]
322pub(crate) fn rename_within_base(base_dir: &Path, from: &Path, to: &Path) -> io::Result<()> {
323    use cap_std::ambient_authority;
324    use cap_std::fs::Dir;
325
326    let from_relative = from.strip_prefix(base_dir).map_err(|_| {
327        io::Error::new(
328            io::ErrorKind::InvalidInput,
329            "repair rename source is outside the working directory",
330        )
331    })?;
332    let to_relative = to.strip_prefix(base_dir).map_err(|_| {
333        io::Error::new(
334            io::ErrorKind::InvalidInput,
335            "repair rename destination is outside the working directory",
336        )
337    })?;
338    repair_path_components(from_relative)?;
339    repair_path_components(to_relative)?;
340    let directory = Dir::open_ambient_dir(base_dir, ambient_authority())?;
341    if let Some(parent) = to_relative.parent() {
342        directory.create_dir_all(parent)?;
343    }
344    directory.rename(from_relative, &directory, to_relative)
345}
346
347#[cfg(windows)]
348pub(crate) fn remove_file_within_base(base_dir: &Path, path: &Path) -> io::Result<()> {
349    use cap_std::ambient_authority;
350    use cap_std::fs::Dir;
351
352    let relative = path.strip_prefix(base_dir).map_err(|_| {
353        io::Error::new(
354            io::ErrorKind::InvalidInput,
355            "repair removal target is outside the working directory",
356        )
357    })?;
358    repair_path_components(relative)?;
359    Dir::open_ambient_dir(base_dir, ambient_authority())?.remove_file(relative)
360}
361
362#[cfg(not(any(unix, windows)))]
363fn open_repair_output(base_dir: &Path, destination: &Path) -> io::Result<File> {
364    let relative = destination.strip_prefix(base_dir).map_err(|_| {
365        io::Error::new(
366            io::ErrorKind::InvalidInput,
367            "repair destination is outside the working directory",
368        )
369    })?;
370    let components = repair_path_components(relative)?;
371    let mut current = base_dir.to_path_buf();
372    for component in &components[..components.len() - 1] {
373        current.push(component);
374        match fs::symlink_metadata(&current) {
375            Ok(metadata) if metadata.file_type().is_symlink() => {
376                return Err(io::Error::new(
377                    io::ErrorKind::InvalidInput,
378                    format!("repair destination traverses a link: {}", current.display()),
379                ));
380            }
381            Ok(metadata) if !metadata.is_dir() => {
382                return Err(io::Error::new(
383                    io::ErrorKind::NotADirectory,
384                    format!(
385                        "repair destination parent is not a directory: {}",
386                        current.display()
387                    ),
388                ));
389            }
390            Ok(_) => {}
391            Err(error) if error.kind() == io::ErrorKind::NotFound => {
392                fs::create_dir(&current)?;
393                let metadata = fs::symlink_metadata(&current)?;
394                if metadata.file_type().is_symlink() || !metadata.is_dir() {
395                    return Err(io::Error::new(
396                        io::ErrorKind::InvalidInput,
397                        format!("repair destination parent changed: {}", current.display()),
398                    ));
399                }
400            }
401            Err(error) => return Err(error),
402        }
403    }
404
405    match fs::symlink_metadata(destination) {
406        Ok(metadata) if metadata.file_type().is_symlink() => Err(io::Error::new(
407            io::ErrorKind::InvalidInput,
408            format!("repair destination is a link: {}", destination.display()),
409        )),
410        Ok(_) | Err(_) => OpenOptions::new()
411            .write(true)
412            .create(true)
413            .truncate(false)
414            .open(destination),
415    }
416}
417
418#[cfg(not(any(unix, windows)))]
419pub(crate) fn rename_within_base(base_dir: &Path, from: &Path, to: &Path) -> io::Result<()> {
420    let from_relative = from.strip_prefix(base_dir).map_err(|_| {
421        io::Error::new(
422            io::ErrorKind::InvalidInput,
423            "repair rename source is outside the working directory",
424        )
425    })?;
426    let to_relative = to.strip_prefix(base_dir).map_err(|_| {
427        io::Error::new(
428            io::ErrorKind::InvalidInput,
429            "repair rename destination is outside the working directory",
430        )
431    })?;
432    repair_path_components(from_relative)?;
433    repair_path_components(to_relative)?;
434    if let Some(parent) = to.parent() {
435        fs::create_dir_all(parent)?;
436    }
437    fs::rename(from, to)
438}
439
440#[cfg(not(any(unix, windows)))]
441pub(crate) fn remove_file_within_base(base_dir: &Path, path: &Path) -> io::Result<()> {
442    let relative = path.strip_prefix(base_dir).map_err(|_| {
443        io::Error::new(
444            io::ErrorKind::InvalidInput,
445            "repair removal target is outside the working directory",
446        )
447    })?;
448    repair_path_components(relative)?;
449    fs::remove_file(path)
450}
451
452/// A [`FileAccess`] implementation that reads and writes files on disk.
453///
454/// Files are located by combining a base directory with the filename from the
455/// PAR2 file descriptions. The mapping from [`FileId`] to filename is built
456/// from the [`Par2FileSet`] at construction time.
457pub struct DiskFileAccess {
458    /// Base directory where files are located.
459    base_dir: PathBuf,
460    /// Map from FileId to filename (populated from Par2FileSet).
461    file_map: HashMap<FileId, String>,
462    /// Repair outputs stay open for the accessor lifetime so a multi-slice
463    /// repair does not reopen the same destination for every block.
464    write_files: HashMap<FileId, File>,
465}
466
467impl DiskFileAccess {
468    /// Create a new `DiskFileAccess` from a base directory and a PAR2 file set.
469    ///
470    /// Builds the internal file map from the file descriptions in the PAR2 set.
471    pub fn new(base_dir: PathBuf, par2_set: &Par2FileSet) -> Self {
472        let mut file_map = HashMap::new();
473        for (file_id, desc) in &par2_set.files {
474            file_map.insert(*file_id, desc.filename.clone());
475        }
476        Self {
477            base_dir,
478            file_map,
479            write_files: HashMap::new(),
480        }
481    }
482
483    /// Resolve the full path for a given file ID.
484    fn path_for(&self, file_id: &FileId) -> Option<PathBuf> {
485        self.file_map
486            .get(file_id)
487            .map(|name| self.base_dir.join(name))
488    }
489}
490
491impl FileAccess for DiskFileAccess {
492    fn read_file_range(&self, file_id: &FileId, offset: u64, len: u64) -> io::Result<Vec<u8>> {
493        let path = self
494            .path_for(file_id)
495            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "unknown file ID"))?;
496        let mut file = File::open(&path)?;
497        file.seek(SeekFrom::Start(offset))?;
498        let mut buf = vec![0u8; len as usize];
499        let n = read_filled(&mut file, &mut buf)?;
500        buf.truncate(n);
501        Ok(buf)
502    }
503
504    fn read_file_range_into(
505        &self,
506        file_id: &FileId,
507        offset: u64,
508        dst: &mut [u8],
509    ) -> io::Result<usize> {
510        let path = self
511            .path_for(file_id)
512            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "unknown file ID"))?;
513        let mut file = File::open(&path)?;
514        file.seek(SeekFrom::Start(offset))?;
515        read_filled(&mut file, dst)
516    }
517
518    fn open_sequential_reader(&self, file_id: &FileId) -> io::Result<Option<Box<dyn Read>>> {
519        let path = self
520            .path_for(file_id)
521            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "unknown file ID"))?;
522        Ok(Some(Box::new(crate::file_cache::CacheAdvisedReader::open(
523            &path,
524        )?)))
525    }
526
527    fn open_range_reader(&self, file_id: &FileId) -> io::Result<Option<Box<dyn FileRangeReader>>> {
528        let path = self
529            .path_for(file_id)
530            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "unknown file ID"))?;
531        Ok(Some(Box::new(File::open(path)?)))
532    }
533
534    fn file_exists(&self, file_id: &FileId) -> bool {
535        self.path_for(file_id).map(|p| p.exists()).unwrap_or(false)
536    }
537
538    fn file_length(&self, file_id: &FileId) -> Option<u64> {
539        let path = self.path_for(file_id)?;
540        fs::metadata(&path).ok().map(|m| m.len())
541    }
542
543    fn read_file(&self, file_id: &FileId) -> io::Result<Vec<u8>> {
544        let path = self
545            .path_for(file_id)
546            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "unknown file ID"))?;
547        crate::file_cache::read_to_vec(&path)
548    }
549
550    fn write_file_range(&mut self, file_id: &FileId, offset: u64, data: &[u8]) -> io::Result<()> {
551        let path = self
552            .path_for(file_id)
553            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "unknown file ID"))?;
554        let file = if let Some(file) = self.write_files.get_mut(file_id) {
555            file
556        } else {
557            let file = open_repair_output(&self.base_dir, &path)?;
558            self.write_files.entry(*file_id).or_insert(file)
559        };
560        file.seek(SeekFrom::Start(offset))?;
561        file.write_all(data)?;
562        Ok(())
563    }
564}
565
566/// A [`FileAccess`] that reads files through placement overrides.
567///
568/// Verification can use this to resolve a PAR2 file ID to the file currently
569/// holding that content without mutating filenames on disk.
570pub struct PlacementFileAccess {
571    base_dir: PathBuf,
572    file_map: HashMap<FileId, String>,
573    overrides: HashMap<FileId, String>,
574    write_files: HashMap<FileId, File>,
575}
576
577impl PlacementFileAccess {
578    pub fn new(
579        base_dir: PathBuf,
580        par2_set: &Par2FileSet,
581        overrides: HashMap<FileId, String>,
582    ) -> Self {
583        let mut file_map = HashMap::new();
584        for (file_id, desc) in &par2_set.files {
585            file_map.insert(*file_id, desc.filename.clone());
586        }
587        Self {
588            base_dir,
589            file_map,
590            overrides,
591            write_files: HashMap::new(),
592        }
593    }
594
595    pub fn from_plan(base_dir: PathBuf, par2_set: &Par2FileSet, plan: &PlacementPlan) -> Self {
596        let mut overrides = HashMap::new();
597
598        for (a, b) in &plan.swaps {
599            overrides.insert(a.file_id, a.current_name.clone());
600            overrides.insert(b.file_id, b.current_name.clone());
601        }
602        for entry in &plan.renames {
603            overrides.insert(entry.file_id, entry.current_name.clone());
604        }
605
606        Self::new(base_dir, par2_set, overrides)
607    }
608
609    fn path_for(&self, file_id: &FileId) -> Option<PathBuf> {
610        let name = self
611            .overrides
612            .get(file_id)
613            .or_else(|| self.file_map.get(file_id))?;
614        Some(self.base_dir.join(name))
615    }
616}
617
618impl FileAccess for PlacementFileAccess {
619    fn read_file_range(&self, file_id: &FileId, offset: u64, len: u64) -> io::Result<Vec<u8>> {
620        let path = self
621            .path_for(file_id)
622            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "unknown file ID"))?;
623        let mut file = File::open(&path)?;
624        file.seek(SeekFrom::Start(offset))?;
625        let mut buf = vec![0u8; len as usize];
626        let n = read_filled(&mut file, &mut buf)?;
627        buf.truncate(n);
628        Ok(buf)
629    }
630
631    fn read_file_range_into(
632        &self,
633        file_id: &FileId,
634        offset: u64,
635        dst: &mut [u8],
636    ) -> io::Result<usize> {
637        let path = self
638            .path_for(file_id)
639            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "unknown file ID"))?;
640        let mut file = File::open(&path)?;
641        file.seek(SeekFrom::Start(offset))?;
642        read_filled(&mut file, dst)
643    }
644
645    fn open_sequential_reader(&self, file_id: &FileId) -> io::Result<Option<Box<dyn Read>>> {
646        let path = self
647            .path_for(file_id)
648            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "unknown file ID"))?;
649        Ok(Some(Box::new(crate::file_cache::CacheAdvisedReader::open(
650            &path,
651        )?)))
652    }
653
654    fn open_range_reader(&self, file_id: &FileId) -> io::Result<Option<Box<dyn FileRangeReader>>> {
655        let path = self
656            .path_for(file_id)
657            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "unknown file ID"))?;
658        Ok(Some(Box::new(File::open(path)?)))
659    }
660
661    fn file_exists(&self, file_id: &FileId) -> bool {
662        self.path_for(file_id).map(|p| p.exists()).unwrap_or(false)
663    }
664
665    fn file_length(&self, file_id: &FileId) -> Option<u64> {
666        let path = self.path_for(file_id)?;
667        fs::metadata(&path).ok().map(|m| m.len())
668    }
669
670    fn read_file(&self, file_id: &FileId) -> io::Result<Vec<u8>> {
671        let path = self
672            .path_for(file_id)
673            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "unknown file ID"))?;
674        crate::file_cache::read_to_vec(&path)
675    }
676
677    fn write_file_range(&mut self, file_id: &FileId, offset: u64, data: &[u8]) -> io::Result<()> {
678        let path = self
679            .path_for(file_id)
680            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "unknown file ID"))?;
681        let file = if let Some(file) = self.write_files.get_mut(file_id) {
682            file
683        } else {
684            let file = open_repair_output(&self.base_dir, &path)?;
685            self.write_files.entry(*file_id).or_insert(file)
686        };
687        file.seek(SeekFrom::Start(offset))?;
688        file.write_all(data)?;
689        Ok(())
690    }
691}
692
693/// A [`FileAccess`] that searches multiple directories for files.
694///
695/// When reading a file, directories are searched in order: primary first, then
696/// each additional search directory. The first directory containing the file wins.
697/// Writes always go to the primary directory.
698pub struct MultiDirectoryFileAccess {
699    primary: DiskFileAccess,
700    search_dirs: Vec<DiskFileAccess>,
701}
702
703impl MultiDirectoryFileAccess {
704    /// Create a new multi-directory accessor.
705    ///
706    /// `primary_dir` is the main directory for reads and writes.
707    /// `search_dirs` are additional directories to search when a file isn't found
708    /// in the primary (e.g., directories from duplicate NZB downloads).
709    pub fn new(primary_dir: PathBuf, search_dirs: Vec<PathBuf>, par2_set: &Par2FileSet) -> Self {
710        let primary = DiskFileAccess::new(primary_dir, par2_set);
711        let search = search_dirs
712            .into_iter()
713            .map(|dir| DiskFileAccess::new(dir, par2_set))
714            .collect();
715        Self {
716            primary,
717            search_dirs: search,
718        }
719    }
720
721    /// Find which accessor can provide a given file.
722    fn find_reader(&self, file_id: &FileId) -> Option<&DiskFileAccess> {
723        if self.primary.file_exists(file_id) {
724            return Some(&self.primary);
725        }
726        self.search_dirs.iter().find(|d| d.file_exists(file_id))
727    }
728}
729
730impl FileAccess for MultiDirectoryFileAccess {
731    fn read_file_range(&self, file_id: &FileId, offset: u64, len: u64) -> io::Result<Vec<u8>> {
732        match self.find_reader(file_id) {
733            Some(accessor) => accessor.read_file_range(file_id, offset, len),
734            None => Err(io::Error::new(
735                io::ErrorKind::NotFound,
736                "file not found in any directory",
737            )),
738        }
739    }
740
741    fn read_file_range_into(
742        &self,
743        file_id: &FileId,
744        offset: u64,
745        dst: &mut [u8],
746    ) -> io::Result<usize> {
747        match self.find_reader(file_id) {
748            Some(accessor) => accessor.read_file_range_into(file_id, offset, dst),
749            None => Err(io::Error::new(
750                io::ErrorKind::NotFound,
751                "file not found in any directory",
752            )),
753        }
754    }
755
756    fn open_sequential_reader(&self, file_id: &FileId) -> io::Result<Option<Box<dyn Read>>> {
757        match self.find_reader(file_id) {
758            Some(accessor) => accessor.open_sequential_reader(file_id),
759            None => Err(io::Error::new(
760                io::ErrorKind::NotFound,
761                "file not found in any directory",
762            )),
763        }
764    }
765
766    fn open_range_reader(&self, file_id: &FileId) -> io::Result<Option<Box<dyn FileRangeReader>>> {
767        match self.find_reader(file_id) {
768            Some(accessor) => accessor.open_range_reader(file_id),
769            None => Err(io::Error::new(
770                io::ErrorKind::NotFound,
771                "file not found in any directory",
772            )),
773        }
774    }
775
776    fn file_exists(&self, file_id: &FileId) -> bool {
777        self.find_reader(file_id).is_some()
778    }
779
780    fn file_length(&self, file_id: &FileId) -> Option<u64> {
781        self.find_reader(file_id)?.file_length(file_id)
782    }
783
784    fn read_file(&self, file_id: &FileId) -> io::Result<Vec<u8>> {
785        match self.find_reader(file_id) {
786            Some(accessor) => accessor.read_file(file_id),
787            None => Err(io::Error::new(
788                io::ErrorKind::NotFound,
789                "file not found in any directory",
790            )),
791        }
792    }
793
794    fn write_file_range(&mut self, file_id: &FileId, offset: u64, data: &[u8]) -> io::Result<()> {
795        self.primary.write_file_range(file_id, offset, data)
796    }
797}
798
799#[cfg(test)]
800mod tests {
801    use super::*;
802    use crate::checksum;
803    use crate::checksum::SliceChecksumState;
804    use crate::packet::header;
805    use crate::par2_set::Par2FileSet;
806    use crate::placement::scan_placement;
807    use crate::types::SliceChecksum;
808    use crate::verify::{FileStatus, verify_all};
809    use md5::{Digest, Md5};
810    use tempfile::TempDir;
811
812    /// Helper to build a complete valid packet (header + body).
813    fn make_full_packet(packet_type: &[u8; 16], body: &[u8], recovery_set_id: [u8; 16]) -> Vec<u8> {
814        let length = (header::HEADER_SIZE + body.len()) as u64;
815        let mut hash_input = Vec::new();
816        hash_input.extend_from_slice(&recovery_set_id);
817        hash_input.extend_from_slice(packet_type);
818        hash_input.extend_from_slice(body);
819        let packet_hash: [u8; 16] = Md5::digest(&hash_input).into();
820
821        let mut data = Vec::new();
822        data.extend_from_slice(header::MAGIC);
823        data.extend_from_slice(&length.to_le_bytes());
824        data.extend_from_slice(&packet_hash);
825        data.extend_from_slice(&recovery_set_id);
826        data.extend_from_slice(packet_type);
827        data.extend_from_slice(body);
828        data
829    }
830
831    /// Build a Par2FileSet for testing with a known filename.
832    fn setup_par2_set(file_data: &[u8], slice_size: u64, filename: &str) -> (Par2FileSet, FileId) {
833        let file_length = file_data.len() as u64;
834        let hash_full = checksum::md5(file_data);
835        let hash_16k_data = &file_data[..file_data.len().min(16384)];
836        let hash_16k = checksum::md5(hash_16k_data);
837
838        let mut id_input = Vec::new();
839        id_input.extend_from_slice(&hash_16k);
840        id_input.extend_from_slice(&file_length.to_le_bytes());
841        id_input.extend_from_slice(filename.as_bytes());
842        let file_id_bytes: [u8; 16] = Md5::digest(&id_input).into();
843        let file_id = FileId::from_bytes(file_id_bytes);
844
845        let num_slices = if file_length == 0 {
846            0
847        } else {
848            file_length.div_ceil(slice_size) as usize
849        };
850
851        let mut checksums = Vec::new();
852        for i in 0..num_slices {
853            let offset = i as u64 * slice_size;
854            let end = ((offset + slice_size) as usize).min(file_data.len());
855            let slice_data = &file_data[offset as usize..end];
856            let mut state = SliceChecksumState::new();
857            state.update(slice_data);
858            let pad_to = if (slice_data.len() as u64) < slice_size {
859                Some(slice_size)
860            } else {
861                None
862            };
863            let (crc, md5) = state.finalize(pad_to);
864            checksums.push(SliceChecksum { crc32: crc, md5 });
865        }
866
867        let mut main_body = Vec::new();
868        main_body.extend_from_slice(&slice_size.to_le_bytes());
869        main_body.extend_from_slice(&1u32.to_le_bytes());
870        main_body.extend_from_slice(&file_id_bytes);
871        let rsid: [u8; 16] = Md5::digest(&main_body).into();
872
873        let mut fd_body = Vec::new();
874        fd_body.extend_from_slice(&file_id_bytes);
875        fd_body.extend_from_slice(&hash_full);
876        fd_body.extend_from_slice(&hash_16k);
877        fd_body.extend_from_slice(&file_length.to_le_bytes());
878        fd_body.extend_from_slice(filename.as_bytes());
879        while fd_body.len() % 4 != 0 {
880            fd_body.push(0);
881        }
882
883        let mut ifsc_body = Vec::new();
884        ifsc_body.extend_from_slice(&file_id_bytes);
885        for cs in &checksums {
886            ifsc_body.extend_from_slice(&cs.md5);
887            ifsc_body.extend_from_slice(&cs.crc32.to_le_bytes());
888        }
889
890        let mut stream = Vec::new();
891        stream.extend_from_slice(&make_full_packet(header::TYPE_MAIN, &main_body, rsid));
892        stream.extend_from_slice(&make_full_packet(header::TYPE_FILE_DESC, &fd_body, rsid));
893        stream.extend_from_slice(&make_full_packet(header::TYPE_IFSC, &ifsc_body, rsid));
894
895        let set = Par2FileSet::from_files(&[&stream]).unwrap();
896        (set, file_id)
897    }
898
899    fn setup_par2_set_multi(
900        files: &[(&[u8], &str)],
901        slice_size: u64,
902    ) -> (Par2FileSet, Vec<FileId>) {
903        let mut file_ids = Vec::new();
904        let mut fd_bodies = Vec::new();
905        let mut ifsc_bodies = Vec::new();
906
907        for &(file_data, filename) in files {
908            let file_length = file_data.len() as u64;
909            let hash_full = checksum::md5(file_data);
910            let hash_16k = checksum::md5(&file_data[..file_data.len().min(16384)]);
911
912            let mut id_input = Vec::new();
913            id_input.extend_from_slice(&hash_16k);
914            id_input.extend_from_slice(&file_length.to_le_bytes());
915            id_input.extend_from_slice(filename.as_bytes());
916            let file_id_bytes: [u8; 16] = Md5::digest(&id_input).into();
917            file_ids.push(FileId::from_bytes(file_id_bytes));
918
919            let num_slices = if file_length == 0 {
920                0
921            } else {
922                file_length.div_ceil(slice_size) as usize
923            };
924
925            let mut checksums = Vec::new();
926            for i in 0..num_slices {
927                let offset = i as u64 * slice_size;
928                let end = ((offset + slice_size) as usize).min(file_data.len());
929                let slice_data = &file_data[offset as usize..end];
930                let mut state = SliceChecksumState::new();
931                state.update(slice_data);
932                let pad_to = if (slice_data.len() as u64) < slice_size {
933                    Some(slice_size)
934                } else {
935                    None
936                };
937                let (crc, md5) = state.finalize(pad_to);
938                checksums.push(SliceChecksum { crc32: crc, md5 });
939            }
940
941            let mut fd_body = Vec::new();
942            fd_body.extend_from_slice(&file_id_bytes);
943            fd_body.extend_from_slice(&hash_full);
944            fd_body.extend_from_slice(&hash_16k);
945            fd_body.extend_from_slice(&file_length.to_le_bytes());
946            fd_body.extend_from_slice(filename.as_bytes());
947            while fd_body.len() % 4 != 0 {
948                fd_body.push(0);
949            }
950            fd_bodies.push(fd_body);
951
952            let mut ifsc_body = Vec::new();
953            ifsc_body.extend_from_slice(&file_id_bytes);
954            for cs in &checksums {
955                ifsc_body.extend_from_slice(&cs.md5);
956                ifsc_body.extend_from_slice(&cs.crc32.to_le_bytes());
957            }
958            ifsc_bodies.push(ifsc_body);
959        }
960
961        let mut main_body = Vec::new();
962        main_body.extend_from_slice(&slice_size.to_le_bytes());
963        main_body.extend_from_slice(&(file_ids.len() as u32).to_le_bytes());
964        for file_id in &file_ids {
965            main_body.extend_from_slice(file_id.as_bytes());
966        }
967        let rsid: [u8; 16] = Md5::digest(&main_body).into();
968
969        let mut stream = Vec::new();
970        stream.extend_from_slice(&make_full_packet(header::TYPE_MAIN, &main_body, rsid));
971        for fd_body in &fd_bodies {
972            stream.extend_from_slice(&make_full_packet(header::TYPE_FILE_DESC, fd_body, rsid));
973        }
974        for ifsc_body in &ifsc_bodies {
975            stream.extend_from_slice(&make_full_packet(header::TYPE_IFSC, ifsc_body, rsid));
976        }
977
978        let set = Par2FileSet::from_files(&[&stream]).unwrap();
979        (set, file_ids)
980    }
981
982    #[test]
983    fn disk_access_read_write_exists_length() {
984        let dir = TempDir::new().unwrap();
985        let file_data = b"Hello, PAR2 world!";
986        let filename = "test.dat";
987
988        // Write test file to tempdir
989        std::fs::write(dir.path().join(filename), file_data).unwrap();
990
991        let (par2_set, file_id) = setup_par2_set(file_data, 1024, filename);
992        let mut access = DiskFileAccess::new(dir.path().to_path_buf(), &par2_set);
993
994        // file_exists
995        assert!(access.file_exists(&file_id));
996
997        // file_length
998        assert_eq!(access.file_length(&file_id), Some(file_data.len() as u64));
999
1000        // read_file
1001        let read_all = access.read_file(&file_id).unwrap();
1002        assert_eq!(read_all, file_data);
1003
1004        // read_file_range
1005        let range = access.read_file_range(&file_id, 7, 4).unwrap();
1006        assert_eq!(&range, b"PAR2");
1007
1008        // write_file_range
1009        access.write_file_range(&file_id, 7, b"par2").unwrap();
1010        access.write_file_range(&file_id, 11, b"!").unwrap();
1011        assert_eq!(access.write_files.len(), 1);
1012        let after_write = access.read_file_range(&file_id, 7, 4).unwrap();
1013        assert_eq!(&after_write, b"par2");
1014    }
1015
1016    #[test]
1017    fn disk_access_missing_file() {
1018        let dir = TempDir::new().unwrap();
1019        let file_data = b"data";
1020        let filename = "missing.dat";
1021
1022        let (par2_set, file_id) = setup_par2_set(file_data, 1024, filename);
1023        let access = DiskFileAccess::new(dir.path().to_path_buf(), &par2_set);
1024
1025        assert!(!access.file_exists(&file_id));
1026        assert_eq!(access.file_length(&file_id), None);
1027        assert!(access.read_file(&file_id).is_err());
1028    }
1029
1030    #[test]
1031    fn disk_access_unknown_file_id() {
1032        let dir = TempDir::new().unwrap();
1033        let file_data = b"data";
1034        let filename = "test.dat";
1035
1036        let (par2_set, _) = setup_par2_set(file_data, 1024, filename);
1037        let access = DiskFileAccess::new(dir.path().to_path_buf(), &par2_set);
1038
1039        let unknown_id = FileId::from_bytes([0xFF; 16]);
1040        assert!(!access.file_exists(&unknown_id));
1041        assert_eq!(access.file_length(&unknown_id), None);
1042        assert!(access.read_file(&unknown_id).is_err());
1043    }
1044
1045    #[test]
1046    fn disk_access_create_on_write() {
1047        let dir = TempDir::new().unwrap();
1048        let file_data = b"original";
1049        let filename = "newfile.dat";
1050
1051        let (par2_set, file_id) = setup_par2_set(file_data, 1024, filename);
1052        let mut access = DiskFileAccess::new(dir.path().to_path_buf(), &par2_set);
1053
1054        // File does not exist yet
1055        assert!(!access.file_exists(&file_id));
1056
1057        // Write creates the file
1058        access.write_file_range(&file_id, 0, b"created").unwrap();
1059        assert!(access.file_exists(&file_id));
1060
1061        let content = access.read_file(&file_id).unwrap();
1062        assert_eq!(&content, b"created");
1063    }
1064
1065    #[cfg(unix)]
1066    #[test]
1067    fn disk_access_rejects_symlink_destination() {
1068        let dir = TempDir::new().unwrap();
1069        let outside = TempDir::new().unwrap();
1070        let outside_path = outside.path().join("outside.dat");
1071        std::fs::write(&outside_path, b"outside stays unchanged").unwrap();
1072        std::os::unix::fs::symlink(&outside_path, dir.path().join("victim.dat")).unwrap();
1073
1074        let (par2_set, file_id) = setup_par2_set(b"repaired data", 1024, "victim.dat");
1075        let mut access = DiskFileAccess::new(dir.path().to_path_buf(), &par2_set);
1076
1077        assert!(
1078            access
1079                .write_file_range(&file_id, 0, b"repaired data")
1080                .is_err()
1081        );
1082        assert_eq!(
1083            std::fs::read(&outside_path).unwrap(),
1084            b"outside stays unchanged"
1085        );
1086        assert!(
1087            std::fs::symlink_metadata(dir.path().join("victim.dat"))
1088                .unwrap()
1089                .file_type()
1090                .is_symlink()
1091        );
1092    }
1093
1094    #[cfg(unix)]
1095    #[test]
1096    fn disk_access_rejects_symlink_parent() {
1097        let dir = TempDir::new().unwrap();
1098        let outside = TempDir::new().unwrap();
1099        let outside_path = outside.path().join("victim.dat");
1100        std::fs::write(&outside_path, b"outside stays unchanged").unwrap();
1101        std::os::unix::fs::symlink(outside.path(), dir.path().join("nested")).unwrap();
1102
1103        let (par2_set, file_id) = setup_par2_set(b"repaired data", 1024, "nested/victim.dat");
1104        let mut access = DiskFileAccess::new(dir.path().to_path_buf(), &par2_set);
1105
1106        assert!(
1107            access
1108                .write_file_range(&file_id, 0, b"repaired data")
1109                .is_err()
1110        );
1111        assert_eq!(
1112            std::fs::read(&outside_path).unwrap(),
1113            b"outside stays unchanged"
1114        );
1115        assert!(
1116            std::fs::symlink_metadata(dir.path().join("nested"))
1117                .unwrap()
1118                .file_type()
1119                .is_symlink()
1120        );
1121    }
1122
1123    #[cfg(unix)]
1124    #[test]
1125    fn rename_within_base_rejects_symlink_parent() {
1126        let dir = TempDir::new().unwrap();
1127        let outside = TempDir::new().unwrap();
1128        let source = dir.path().join("source.dat");
1129        let outside_target = outside.path().join("victim.dat");
1130        std::fs::write(&source, b"repaired data").unwrap();
1131        std::fs::write(&outside_target, b"outside stays unchanged").unwrap();
1132        std::os::unix::fs::symlink(outside.path(), dir.path().join("nested")).unwrap();
1133
1134        assert!(
1135            rename_within_base(dir.path(), &source, &dir.path().join("nested/victim.dat")).is_err()
1136        );
1137        assert_eq!(std::fs::read(&source).unwrap(), b"repaired data");
1138        assert_eq!(
1139            std::fs::read(&outside_target).unwrap(),
1140            b"outside stays unchanged"
1141        );
1142    }
1143
1144    #[cfg(unix)]
1145    #[test]
1146    fn disk_access_allows_caller_selected_symlink_base() {
1147        let parent = TempDir::new().unwrap();
1148        let base = TempDir::new().unwrap();
1149        let base_link = parent.path().join("base-link");
1150        std::os::unix::fs::symlink(base.path(), &base_link).unwrap();
1151
1152        let (par2_set, file_id) = setup_par2_set(b"repaired data", 1024, "created.dat");
1153        let mut access = DiskFileAccess::new(base_link, &par2_set);
1154
1155        access
1156            .write_file_range(&file_id, 0, b"repaired data")
1157            .unwrap();
1158        assert_eq!(
1159            std::fs::read(base.path().join("created.dat")).unwrap(),
1160            b"repaired data"
1161        );
1162    }
1163
1164    #[test]
1165    fn multi_dir_finds_file_in_secondary() {
1166        let primary = TempDir::new().unwrap();
1167        let secondary = TempDir::new().unwrap();
1168        let file_data = b"found in secondary";
1169        let filename = "target.dat";
1170
1171        let (par2_set, file_id) = setup_par2_set(file_data, 1024, filename);
1172
1173        // File only in secondary
1174        std::fs::write(secondary.path().join(filename), file_data).unwrap();
1175
1176        let access = MultiDirectoryFileAccess::new(
1177            primary.path().to_path_buf(),
1178            vec![secondary.path().to_path_buf()],
1179            &par2_set,
1180        );
1181
1182        assert!(access.file_exists(&file_id));
1183        assert_eq!(access.read_file(&file_id).unwrap(), file_data);
1184    }
1185
1186    #[test]
1187    fn multi_dir_primary_wins() {
1188        let primary = TempDir::new().unwrap();
1189        let secondary = TempDir::new().unwrap();
1190        let filename = "target.dat";
1191
1192        let (par2_set, file_id) = setup_par2_set(b"primary", 1024, filename);
1193
1194        std::fs::write(primary.path().join(filename), b"primary").unwrap();
1195        std::fs::write(secondary.path().join(filename), b"secondary").unwrap();
1196
1197        let access = MultiDirectoryFileAccess::new(
1198            primary.path().to_path_buf(),
1199            vec![secondary.path().to_path_buf()],
1200            &par2_set,
1201        );
1202
1203        let content = access.read_file(&file_id).unwrap();
1204        assert_eq!(&content, b"primary");
1205    }
1206
1207    #[test]
1208    fn multi_dir_write_goes_to_primary() {
1209        let primary = TempDir::new().unwrap();
1210        let secondary = TempDir::new().unwrap();
1211        let filename = "target.dat";
1212
1213        let (par2_set, file_id) = setup_par2_set(b"data", 1024, filename);
1214
1215        let mut access = MultiDirectoryFileAccess::new(
1216            primary.path().to_path_buf(),
1217            vec![secondary.path().to_path_buf()],
1218            &par2_set,
1219        );
1220
1221        access.write_file_range(&file_id, 0, b"written").unwrap();
1222        assert!(primary.path().join(filename).exists());
1223        assert!(!secondary.path().join(filename).exists());
1224    }
1225
1226    #[test]
1227    fn multi_dir_not_found_anywhere() {
1228        let primary = TempDir::new().unwrap();
1229        let secondary = TempDir::new().unwrap();
1230
1231        let (par2_set, file_id) = setup_par2_set(b"data", 1024, "missing.dat");
1232
1233        let access = MultiDirectoryFileAccess::new(
1234            primary.path().to_path_buf(),
1235            vec![secondary.path().to_path_buf()],
1236            &par2_set,
1237        );
1238
1239        assert!(!access.file_exists(&file_id));
1240        assert!(access.read_file(&file_id).is_err());
1241    }
1242
1243    /// A reader that hands back at most `cap` bytes per `read`, the way
1244    /// wasmtime's WASI preview1 `fd_read` caps every transfer at 64 KiB for a
1245    /// guest with shared linear memory (`wasm32-wasip1-threads`).
1246    struct CappedReader<'a> {
1247        data: &'a [u8],
1248        cap: usize,
1249    }
1250
1251    impl Read for CappedReader<'_> {
1252        fn read(&mut self, dst: &mut [u8]) -> io::Result<usize> {
1253            let take = dst.len().min(self.cap).min(self.data.len());
1254            dst[..take].copy_from_slice(&self.data[..take]);
1255            self.data = &self.data[take..];
1256            Ok(take)
1257        }
1258    }
1259
1260    #[test]
1261    fn read_filled_fills_across_short_reads() {
1262        let data: Vec<u8> = (0..200_000u32).map(|i| i as u8).collect();
1263        let mut reader = CappedReader {
1264            data: &data,
1265            cap: 65_536,
1266        };
1267        let mut dst = vec![0u8; data.len()];
1268
1269        let filled = read_filled(&mut reader, &mut dst).unwrap();
1270
1271        assert_eq!(filled, data.len(), "a capped reader must still fill dst");
1272        assert_eq!(dst, data);
1273    }
1274
1275    #[test]
1276    fn read_filled_stops_at_end_of_input() {
1277        let data = vec![7u8; 100];
1278        let mut reader = CappedReader {
1279            data: &data,
1280            cap: 8,
1281        };
1282        let mut dst = vec![0u8; 512];
1283
1284        let filled = read_filled(&mut reader, &mut dst).unwrap();
1285
1286        assert_eq!(filled, data.len());
1287        assert!(dst[data.len()..].iter().all(|&byte| byte == 0));
1288    }
1289
1290    /// A truncated `read_file_range_into` is read as end-of-file by every
1291    /// caller in `verify.rs`, so a disk-backed [`FileAccess`] that returns one
1292    /// short read reports intact slices as damaged. This is the contract that
1293    /// PAR2 repair on `wasm32-wasip1-threads` violated: the host capped each
1294    /// `fd_read` at 64 KiB and the staged file — byte-perfect on disk — failed
1295    /// its own post-repair readback.
1296    #[test]
1297    fn disk_access_read_file_range_fills_beyond_one_host_read() {
1298        let dir = TempDir::new().unwrap();
1299        let data: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
1300        let (par2_set, file_id) = setup_par2_set(&data, 65_536, "big.dat");
1301        std::fs::write(dir.path().join("big.dat"), &data).unwrap();
1302
1303        let access = DiskFileAccess::new(dir.path().to_path_buf(), &par2_set);
1304
1305        let mut dst = vec![0u8; data.len()];
1306        let read = access.read_file_range_into(&file_id, 0, &mut dst).unwrap();
1307        assert_eq!(read, data.len());
1308        assert_eq!(dst, data);
1309
1310        let owned = access
1311            .read_file_range(&file_id, 0, data.len() as u64)
1312            .unwrap();
1313        assert_eq!(owned, data);
1314    }
1315
1316    #[test]
1317    fn placement_access_verifies_swapped_valid_names() {
1318        let dir = TempDir::new().unwrap();
1319        let data_a = b"placement-aware file A data";
1320        let data_b = b"placement-aware file B data";
1321
1322        let (par2_set, _ids) =
1323            setup_par2_set_multi(&[(data_a, "file_a.rar"), (data_b, "file_b.rar")], 1024);
1324
1325        std::fs::write(dir.path().join("file_a.rar"), data_b).unwrap();
1326        std::fs::write(dir.path().join("file_b.rar"), data_a).unwrap();
1327
1328        let plan = scan_placement(dir.path(), &par2_set).unwrap();
1329        let access = PlacementFileAccess::from_plan(dir.path().to_path_buf(), &par2_set, &plan);
1330        let result = verify_all(&par2_set, &access);
1331
1332        assert_eq!(result.total_missing_blocks, 0);
1333        assert!(
1334            result
1335                .files
1336                .iter()
1337                .all(|file| matches!(file.status, FileStatus::Complete))
1338        );
1339    }
1340}