use std::path::Path;
#[derive(Debug, Clone)]
pub struct Par2FileEntry {
pub filename: String,
pub hash_16k: [u8; 16],
}
const PAR2_MAGIC: &[u8; 8] = b"PAR2\0PKT";
const FILE_DESC_TYPE: &[u8; 16] = b"PAR 2.0\0FileDesc";
const HEADER_SIZE: usize = 8 + 8 + 16 + 16 + 16;
const TYPE_OFFSET: usize = 8 + 8 + 16 + 16;
const FILE_DESC_FIXED_BODY: usize = 16 + 16 + 16 + 8;
const MD5_16K_OFFSET: usize = 16 + 16;
pub fn parse_par2_file_entries(par2_path: &Path) -> crate::Result<Vec<Par2FileEntry>> {
let data = std::fs::read(par2_path)?;
Ok(parse_par2_file_entries_from_bytes(&data))
}
pub(crate) fn parse_par2_file_entries_from_bytes(data: &[u8]) -> Vec<Par2FileEntry> {
let mut entries = Vec::new();
let mut pos = 0;
while pos + HEADER_SIZE <= data.len() {
match find_magic(data, pos) {
Some(magic_pos) => pos = magic_pos,
None => break,
}
if pos + HEADER_SIZE > data.len() {
break;
}
let packet_len =
u64::from_le_bytes(data[pos + 8..pos + 16].try_into().unwrap_or([0; 8])) as usize;
if packet_len < HEADER_SIZE || pos + packet_len > data.len() {
pos += 8; continue;
}
let type_sig = &data[pos + TYPE_OFFSET..pos + TYPE_OFFSET + 16];
if type_sig == FILE_DESC_TYPE {
let body_start = pos + HEADER_SIZE;
let body_len = packet_len - HEADER_SIZE;
if body_len >= FILE_DESC_FIXED_BODY {
let md5_start = body_start + MD5_16K_OFFSET;
let mut hash_16k = [0u8; 16];
hash_16k.copy_from_slice(&data[md5_start..md5_start + 16]);
let name_start = body_start + FILE_DESC_FIXED_BODY;
let name_end = pos + packet_len;
if name_start < name_end {
let name_bytes = &data[name_start..name_end];
let filename = extract_filename(name_bytes);
if !filename.is_empty() {
entries.push(Par2FileEntry { filename, hash_16k });
}
}
}
}
pos += packet_len;
}
entries
}
fn find_magic(data: &[u8], start: usize) -> Option<usize> {
if start + PAR2_MAGIC.len() > data.len() {
return None;
}
data[start..]
.windows(PAR2_MAGIC.len())
.position(|w| w == PAR2_MAGIC)
.map(|offset| start + offset)
}
fn extract_filename(bytes: &[u8]) -> String {
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
String::from_utf8_lossy(&bytes[..end]).into_owned()
}
pub fn compute_16k_md5(file_path: &Path) -> crate::Result<[u8; 16]> {
use std::io::Read;
let mut file = std::fs::File::open(file_path)?;
let mut buffer = [0u8; 16384]; let bytes_read = file.read(&mut buffer)?;
let digest = md5::compute(&buffer[..bytes_read]);
Ok(digest.0)
}
#[allow(clippy::unwrap_used, clippy::expect_used)]
#[cfg(test)]
mod tests {
use super::*;
fn build_file_desc_packet(filename: &str, hash_16k: [u8; 16]) -> Vec<u8> {
let name_bytes = filename.as_bytes();
let padded_len = (name_bytes.len() + 3) & !3; let mut padded_name = vec![0u8; padded_len];
padded_name[..name_bytes.len()].copy_from_slice(name_bytes);
let body_len = FILE_DESC_FIXED_BODY + padded_len;
let packet_len = (HEADER_SIZE + body_len) as u64;
let mut packet = Vec::with_capacity(packet_len as usize);
packet.extend_from_slice(PAR2_MAGIC);
packet.extend_from_slice(&packet_len.to_le_bytes());
packet.extend_from_slice(&[0u8; 16]);
packet.extend_from_slice(&[0u8; 16]);
packet.extend_from_slice(FILE_DESC_TYPE);
packet.extend_from_slice(&[0u8; 16]);
packet.extend_from_slice(&[0u8; 16]);
packet.extend_from_slice(&hash_16k);
packet.extend_from_slice(&1024u64.to_le_bytes());
packet.extend_from_slice(&padded_name);
packet
}
#[test]
fn parse_single_file_desc_packet() {
let hash = [1u8; 16];
let data = build_file_desc_packet("movie.mkv", hash);
let entries = parse_par2_file_entries_from_bytes(&data);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].filename, "movie.mkv");
assert_eq!(entries[0].hash_16k, hash);
}
#[test]
fn parse_multiple_file_desc_packets() {
let hash1 = [1u8; 16];
let hash2 = [2u8; 16];
let mut data = build_file_desc_packet("file1.rar", hash1);
data.extend_from_slice(&build_file_desc_packet("file2.rar", hash2));
let entries = parse_par2_file_entries_from_bytes(&data);
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].filename, "file1.rar");
assert_eq!(entries[0].hash_16k, hash1);
assert_eq!(entries[1].filename, "file2.rar");
assert_eq!(entries[1].hash_16k, hash2);
}
#[test]
fn parse_empty_data_returns_no_entries() {
let entries = parse_par2_file_entries_from_bytes(&[]);
assert!(entries.is_empty());
}
#[test]
fn parse_garbage_data_returns_no_entries() {
let garbage = vec![0xFFu8; 1024];
let entries = parse_par2_file_entries_from_bytes(&garbage);
assert!(entries.is_empty());
}
#[test]
fn parse_truncated_packet_returns_no_entries() {
let full = build_file_desc_packet("test.bin", [3u8; 16]);
let truncated = &full[..HEADER_SIZE];
let entries = parse_par2_file_entries_from_bytes(truncated);
assert!(entries.is_empty());
}
#[test]
fn extract_filename_handles_null_padding() {
let bytes = b"hello.txt\0\0\0"; assert_eq!(extract_filename(bytes), "hello.txt");
}
#[test]
fn extract_filename_handles_no_null() {
let bytes = b"hello.txt";
assert_eq!(extract_filename(bytes), "hello.txt");
}
#[test]
fn non_file_desc_packets_are_skipped() {
let mut data = Vec::new();
const BODY_LEN: usize = 16; let packet_len = (HEADER_SIZE + BODY_LEN) as u64;
data.extend_from_slice(PAR2_MAGIC);
data.extend_from_slice(&packet_len.to_le_bytes());
data.extend_from_slice(&[0u8; 16]); data.extend_from_slice(&[0u8; 16]); data.extend_from_slice(b"PAR 2.0\0Main\0\0\0\0"); data.extend_from_slice(&[0u8; BODY_LEN]);
data.extend_from_slice(&build_file_desc_packet("real.rar", [5u8; 16]));
let entries = parse_par2_file_entries_from_bytes(&data);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].filename, "real.rar");
}
}