par2-rs 0.3.0

PAR2 parity verification and repair
Documentation
use std::fs::File;
use std::io::{self, Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};

use bytes::Bytes;

use crate::checksum::Md5State;
use crate::error::{Par2Error, Result};
use crate::types::RecoveryExponent;

#[derive(Debug, Clone)]
pub enum RecoverySliceData {
    InMemory(Bytes),
    FileBacked {
        path: PathBuf,
        offset: u64,
        len: usize,
        /// Packet MD5 from the header, kept so lazily-loaded payloads can be
        /// re-validated against on-disk damage before repair uses them.
        /// `None` means the payload was already validated (or is synthetic).
        packet_hash: Option<[u8; 16]>,
    },
}

impl RecoverySliceData {
    pub fn in_memory(data: Bytes) -> Self {
        Self::InMemory(data)
    }

    pub fn file_backed(path: PathBuf, offset: u64, len: usize) -> Self {
        Self::FileBacked {
            path,
            offset,
            len,
            packet_hash: None,
        }
    }

    pub fn file_backed_with_hash(
        path: PathBuf,
        offset: u64,
        len: usize,
        packet_hash: [u8; 16],
    ) -> Self {
        Self::FileBacked {
            path,
            offset,
            len,
            packet_hash: Some(packet_hash),
        }
    }

    /// Re-check the PAR2 packet hash covering this recovery slice.
    ///
    /// In-memory payloads were hash-validated when the packet was parsed, and
    /// file-backed payloads recorded without a hash are trusted as-is.
    /// File-backed payloads captured by the streaming scanner (which skips
    /// payload hashing for speed) are re-hashed from disk here: the packet
    /// hash covers `recovery_set_id || type || exponent || payload`.
    pub fn validate_packet_hash(
        &self,
        recovery_set_id: &[u8; 16],
        exponent: RecoveryExponent,
    ) -> io::Result<bool> {
        let Self::FileBacked {
            path,
            offset,
            len,
            packet_hash: Some(expected),
        } = self
        else {
            return Ok(true);
        };

        let mut hasher = Md5State::new();
        hasher.update(recovery_set_id);
        hasher.update(super::header::TYPE_RECOVERY);
        hasher.update(&exponent.to_le_bytes());

        let mut file = File::open(path)?;
        file.seek(SeekFrom::Start(*offset))?;
        let mut remaining = *len;
        let mut buf = vec![0u8; remaining.clamp(1, 256 * 1024)];
        while remaining > 0 {
            let take = remaining.min(buf.len());
            file.read_exact(&mut buf[..take])?;
            hasher.update(&buf[..take]);
            remaining -= take;
        }
        let file_len = file
            .metadata()
            .ok()
            .map_or(*offset + *len as u64, |metadata| metadata.len());
        crate::file_cache::drop_touched_file_cache(&file, path, file_len, *offset, *len as u64);
        Ok(hasher.finalize() == *expected)
    }

    pub fn len(&self) -> usize {
        match self {
            Self::InMemory(data) => data.len(),
            Self::FileBacked { len, .. } => *len,
        }
    }

    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }

    pub fn as_bytes(&self) -> Option<&[u8]> {
        match self {
            Self::InMemory(data) => Some(data.as_ref()),
            Self::FileBacked { .. } => None,
        }
    }

    pub fn file_span(&self) -> Option<(&Path, u64, usize)> {
        match self {
            Self::InMemory(_) => None,
            Self::FileBacked {
                path, offset, len, ..
            } => Some((path.as_path(), *offset, *len)),
        }
    }

    pub fn to_vec(&self) -> io::Result<Vec<u8>> {
        let mut out = vec![0u8; self.len()];
        self.read_range_padded(0, &mut out)?;
        Ok(out)
    }

    pub fn read_range_padded(&self, start: usize, dst: &mut [u8]) -> io::Result<()> {
        dst.fill(0);

        match self {
            Self::InMemory(data) => {
                if start >= data.len() {
                    return Ok(());
                }
                let end = (start + dst.len()).min(data.len());
                let copy_len = end - start;
                dst[..copy_len].copy_from_slice(&data[start..end]);
                Ok(())
            }
            Self::FileBacked {
                path, offset, len, ..
            } => {
                if start >= *len {
                    return Ok(());
                }

                let read_len = dst.len().min(*len - start);
                let mut file = File::open(path)?;
                read_exact_at_fallback(&mut file, offset + start as u64, &mut dst[..read_len])?;
                let file_len = file
                    .metadata()
                    .ok()
                    .map_or(*len as u64, |metadata| metadata.len());
                crate::file_cache::drop_touched_file_cache(
                    &file,
                    path,
                    file_len,
                    offset + start as u64,
                    read_len as u64,
                );
                Ok(())
            }
        }
    }
}

impl From<Bytes> for RecoverySliceData {
    fn from(value: Bytes) -> Self {
        Self::InMemory(value)
    }
}

impl From<Vec<u8>> for RecoverySliceData {
    fn from(value: Vec<u8>) -> Self {
        Self::InMemory(Bytes::from(value))
    }
}

fn read_exact_at_fallback(file: &mut File, offset: u64, buf: &mut [u8]) -> io::Result<()> {
    file.seek(SeekFrom::Start(offset))?;
    file.read_exact(buf)
}

/// Parsed Recovery Slice packet.
///
/// Contains one recovery block identified by its exponent.
/// The actual Reed-Solomon math is not performed here; we just store the data.
#[derive(Debug, Clone)]
pub struct RecoverySlicePacket {
    /// The exponent identifying this recovery block.
    pub exponent: RecoveryExponent,
    /// The recovery data (length should equal slice_size from the Main packet).
    pub data: RecoverySliceData,
}

impl RecoverySlicePacket {
    /// Parse a Recovery Slice packet from its body (after the 64-byte header).
    pub fn parse(body: &[u8]) -> Result<Self> {
        if body.len() <= 4 {
            return Err(Par2Error::InvalidRecoveryPacket {
                reason: format!("body too short: {} bytes, need more than 4", body.len()),
            });
        }

        let exponent = u32::from_le_bytes(body[0..4].try_into().unwrap());
        let data = RecoverySliceData::in_memory(Bytes::copy_from_slice(&body[4..]));

        Ok(RecoverySlicePacket { exponent, data })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_valid_recovery() {
        let mut body = Vec::new();
        body.extend_from_slice(&42u32.to_le_bytes());
        body.extend_from_slice(&[0xAB; 128]);

        let pkt = RecoverySlicePacket::parse(&body).unwrap();
        assert_eq!(pkt.exponent, 42);
        assert_eq!(pkt.data.len(), 128);
        assert!(pkt.data.as_bytes().unwrap().iter().all(|&b| b == 0xAB));
    }

    #[test]
    fn reject_recovery_empty_data() {
        let body = 0u32.to_le_bytes();
        let err = RecoverySlicePacket::parse(&body).unwrap_err();
        assert!(matches!(err, Par2Error::InvalidRecoveryPacket { .. }));
    }

    #[test]
    fn reject_too_short() {
        let body = [0u8; 2];
        let err = RecoverySlicePacket::parse(&body).unwrap_err();
        assert!(matches!(err, Par2Error::InvalidRecoveryPacket { .. }));
    }
}