use std::collections::HashMap;
use std::fs::{self, File};
use std::io;
use std::path::{Path, PathBuf};
use tracing::debug;
use crate::checksum;
use crate::packet::header::{self, MAGIC, PacketHeader};
use crate::par2_set::Par2FileSet;
use crate::path::is_generated_par2_artifact_name;
use crate::types::{FileId, RecoverySetId};
#[derive(Debug, Clone)]
pub struct RenameSuggestion {
pub current_path: PathBuf,
pub correct_name: String,
pub file_id: FileId,
pub match_type: MatchType,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MatchType {
Hash16k,
Par2File,
}
pub fn scan_for_renames(dir: &Path, par2_set: &Par2FileSet) -> io::Result<Vec<RenameSuggestion>> {
let mut hash_lookup: HashMap<[u8; 16], (FileId, &str)> = HashMap::new();
for (file_id, desc) in &par2_set.files {
hash_lookup.insert(desc.hash_16k, (*file_id, &desc.filename));
}
let known_filenames: std::collections::HashSet<&str> = par2_set
.files
.values()
.map(|d| d.filename.as_str())
.collect();
let mut suggestions = Vec::new();
let entries = fs::read_dir(dir)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
let file_name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => continue,
};
if is_generated_par2_artifact_name(file_name) {
continue;
}
if known_filenames.contains(file_name) {
continue;
}
let data = read_first_n_bytes(&path, 16384)?;
if data.is_empty() {
continue;
}
let hash = checksum::md5(&data);
if let Some(&(file_id, correct_name)) = hash_lookup.get(&hash)
&& file_name != correct_name
{
debug!(
"rename match: {} -> {} (16k hash match)",
file_name, correct_name
);
suggestions.push(RenameSuggestion {
current_path: path,
correct_name: correct_name.to_string(),
file_id,
match_type: MatchType::Hash16k,
});
}
}
Ok(suggestions)
}
pub fn identify_par2_files(
dir: &Path,
expected_set_id: &RecoverySetId,
) -> io::Result<Vec<PathBuf>> {
let mut matches = Vec::new();
let entries = fs::read_dir(dir)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
if is_generated_par2_artifact_name(file_name) {
continue;
}
let data = read_first_n_bytes(&path, header::HEADER_SIZE)?;
if data.len() < header::HEADER_SIZE {
continue;
}
if &data[0..8] != MAGIC {
continue;
}
if let Ok(hdr) = PacketHeader::parse(&data, 0)
&& hdr.recovery_set_id == *expected_set_id
{
debug!("identified par2 file: {}", path.display());
matches.push(path);
}
}
Ok(matches)
}
#[derive(Debug, Clone)]
pub struct SplitFileGroup {
pub base_name: String,
pub parts: Vec<PathBuf>,
pub part_numbers: Vec<u32>,
pub contiguous: bool,
}
pub fn detect_split_files(dir: &Path) -> io::Result<Vec<SplitFileGroup>> {
let mut groups: HashMap<String, Vec<(u32, PathBuf)>> = HashMap::new();
let entries = fs::read_dir(dir)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
let file_name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
if let Some((base, ext)) = file_name.rsplit_once('.')
&& !ext.is_empty()
&& ext.chars().all(|c| c.is_ascii_digit())
&& let Ok(num) = ext.parse::<u32>()
{
groups
.entry(base.to_string())
.or_default()
.push((num, path));
}
}
let mut result = Vec::new();
for (base_name, mut parts) in groups {
if parts.len() < 2 {
continue;
}
parts.sort_by_key(|(n, _)| *n);
let part_numbers: Vec<u32> = parts.iter().map(|(n, _)| *n).collect();
let paths: Vec<PathBuf> = parts.into_iter().map(|(_, p)| p).collect();
let contiguous = part_numbers[0] == 1
&& part_numbers
.windows(2)
.all(|pair| matches!(pair, [a, b] if *b == *a + 1));
result.push(SplitFileGroup {
base_name,
parts: paths,
part_numbers,
contiguous,
});
}
result.sort_by(|a, b| a.base_name.cmp(&b.base_name));
Ok(result)
}
fn read_first_n_bytes(path: &Path, n: usize) -> io::Result<Vec<u8>> {
let mut file = File::open(path)?;
let file_len = file.metadata()?.len();
let mut buf = vec![0u8; n];
let bytes_read = crate::disk::read_filled(&mut file, &mut buf)?;
crate::file_cache::drop_touched_file_cache(&file, path, file_len, 0, bytes_read as u64);
buf.truncate(bytes_read);
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::checksum::SliceChecksumState;
use crate::packet::header;
use crate::types::SliceChecksum;
use md5::{Digest, Md5};
use tempfile::TempDir;
fn make_full_packet(packet_type: &[u8; 16], body: &[u8], recovery_set_id: [u8; 16]) -> Vec<u8> {
let length = (header::HEADER_SIZE + body.len()) as u64;
let mut hash_input = Vec::new();
hash_input.extend_from_slice(&recovery_set_id);
hash_input.extend_from_slice(packet_type);
hash_input.extend_from_slice(body);
let packet_hash: [u8; 16] = Md5::digest(&hash_input).into();
let mut data = Vec::new();
data.extend_from_slice(header::MAGIC);
data.extend_from_slice(&length.to_le_bytes());
data.extend_from_slice(&packet_hash);
data.extend_from_slice(&recovery_set_id);
data.extend_from_slice(packet_type);
data.extend_from_slice(body);
data
}
fn setup_par2_set(file_data: &[u8], slice_size: u64, filename: &str) -> (Par2FileSet, FileId) {
let file_length = file_data.len() as u64;
let hash_full = checksum::md5(file_data);
let hash_16k_data = &file_data[..file_data.len().min(16384)];
let hash_16k = checksum::md5(hash_16k_data);
let mut id_input = Vec::new();
id_input.extend_from_slice(&hash_16k);
id_input.extend_from_slice(&file_length.to_le_bytes());
id_input.extend_from_slice(filename.as_bytes());
let file_id_bytes: [u8; 16] = Md5::digest(&id_input).into();
let file_id = FileId::from_bytes(file_id_bytes);
let num_slices = if file_length == 0 {
0
} else {
file_length.div_ceil(slice_size) as usize
};
let mut checksums = Vec::new();
for i in 0..num_slices {
let offset = i as u64 * slice_size;
let end = ((offset + slice_size) as usize).min(file_data.len());
let slice_data = &file_data[offset as usize..end];
let mut state = SliceChecksumState::new();
state.update(slice_data);
let pad_to = if (slice_data.len() as u64) < slice_size {
Some(slice_size)
} else {
None
};
let (crc, md5) = state.finalize(pad_to);
checksums.push(SliceChecksum { crc32: crc, md5 });
}
let mut main_body = Vec::new();
main_body.extend_from_slice(&slice_size.to_le_bytes());
main_body.extend_from_slice(&1u32.to_le_bytes());
main_body.extend_from_slice(&file_id_bytes);
let rsid: [u8; 16] = Md5::digest(&main_body).into();
let mut fd_body = Vec::new();
fd_body.extend_from_slice(&file_id_bytes);
fd_body.extend_from_slice(&hash_full);
fd_body.extend_from_slice(&hash_16k);
fd_body.extend_from_slice(&file_length.to_le_bytes());
fd_body.extend_from_slice(filename.as_bytes());
while fd_body.len() % 4 != 0 {
fd_body.push(0);
}
let mut ifsc_body = Vec::new();
ifsc_body.extend_from_slice(&file_id_bytes);
for cs in &checksums {
ifsc_body.extend_from_slice(&cs.md5);
ifsc_body.extend_from_slice(&cs.crc32.to_le_bytes());
}
let mut stream = Vec::new();
stream.extend_from_slice(&make_full_packet(header::TYPE_MAIN, &main_body, rsid));
stream.extend_from_slice(&make_full_packet(header::TYPE_FILE_DESC, &fd_body, rsid));
stream.extend_from_slice(&make_full_packet(header::TYPE_IFSC, &ifsc_body, rsid));
let set = Par2FileSet::from_files(&[&stream]).unwrap();
(set, file_id)
}
#[test]
fn scan_finds_obfuscated_file() {
let dir = TempDir::new().unwrap();
let file_data = b"This is the real file content for rename testing!!";
let correct_name = "movie.rar";
let (par2_set, file_id) = setup_par2_set(file_data, 1024, correct_name);
fs::write(dir.path().join("abc123def456.bin"), file_data).unwrap();
let suggestions = scan_for_renames(dir.path(), &par2_set).unwrap();
assert_eq!(suggestions.len(), 1);
assert_eq!(suggestions[0].correct_name, correct_name);
assert_eq!(suggestions[0].file_id, file_id);
assert_eq!(suggestions[0].match_type, MatchType::Hash16k);
}
#[test]
fn scan_skips_correctly_named() {
let dir = TempDir::new().unwrap();
let file_data = b"This is the real file content for rename testing!!";
let correct_name = "movie.rar";
let (par2_set, _) = setup_par2_set(file_data, 1024, correct_name);
fs::write(dir.path().join(correct_name), file_data).unwrap();
let suggestions = scan_for_renames(dir.path(), &par2_set).unwrap();
assert!(suggestions.is_empty());
}
#[test]
fn scan_skips_generated_repair_artifacts() {
let dir = TempDir::new().unwrap();
let file_data = b"This is the real file content for rename testing!!";
let correct_name = "movie.rar";
let (par2_set, _) = setup_par2_set(file_data, 1024, correct_name);
fs::write(
dir.path().join("movie.rar.weaver-par2-backup.123"),
file_data,
)
.unwrap();
let suggestions = scan_for_renames(dir.path(), &par2_set).unwrap();
assert!(suggestions.is_empty());
}
#[test]
fn scan_empty_dir() {
let dir = TempDir::new().unwrap();
let file_data = b"data";
let (par2_set, _) = setup_par2_set(file_data, 1024, "test.bin");
let suggestions = scan_for_renames(dir.path(), &par2_set).unwrap();
assert!(suggestions.is_empty());
}
#[test]
fn identify_par2_files_finds_match() {
let dir = TempDir::new().unwrap();
let rsid = [0x42u8; 16];
let main_body_data = vec![0u8; 12]; let packet_data = make_full_packet(header::TYPE_MAIN, &main_body_data, rsid);
fs::write(dir.path().join("random_name.bin"), &packet_data).unwrap();
let expected_id = RecoverySetId::from_bytes(rsid);
let matches = identify_par2_files(dir.path(), &expected_id).unwrap();
assert_eq!(matches.len(), 1);
}
#[test]
fn identify_par2_files_ignores_non_par2() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("regular.txt"), b"not a par2 file").unwrap();
let expected_id = RecoverySetId::from_bytes([0x42; 16]);
let matches = identify_par2_files(dir.path(), &expected_id).unwrap();
assert!(matches.is_empty());
}
#[test]
fn detect_split_files_basic() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("movie.mkv.001"), b"part1").unwrap();
fs::write(dir.path().join("movie.mkv.002"), b"part2").unwrap();
fs::write(dir.path().join("movie.mkv.003"), b"part3").unwrap();
fs::write(dir.path().join("other.txt"), b"not split").unwrap();
let groups = detect_split_files(dir.path()).unwrap();
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].base_name, "movie.mkv");
assert_eq!(groups[0].part_numbers, vec![1, 2, 3]);
assert!(groups[0].contiguous);
}
#[test]
fn detect_split_files_with_gap() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("data.bin.001"), b"p1").unwrap();
fs::write(dir.path().join("data.bin.003"), b"p3").unwrap();
let groups = detect_split_files(dir.path()).unwrap();
assert_eq!(groups.len(), 1);
assert!(!groups[0].contiguous);
assert_eq!(groups[0].part_numbers, vec![1, 3]);
}
#[test]
fn detect_split_files_single_part_ignored() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("lonely.bin.001"), b"alone").unwrap();
let groups = detect_split_files(dir.path()).unwrap();
assert!(groups.is_empty());
}
#[test]
fn detect_split_files_empty_dir() {
let dir = TempDir::new().unwrap();
let groups = detect_split_files(dir.path()).unwrap();
assert!(groups.is_empty());
}
}