1use std::collections::HashMap;
8use std::fs::{self, File};
9use std::io;
10use std::path::{Path, PathBuf};
11
12use tracing::debug;
13
14use crate::checksum;
15use crate::packet::header::{self, MAGIC, PacketHeader};
16use crate::par2_set::Par2FileSet;
17use crate::path::is_generated_par2_artifact_name;
18use crate::types::{FileId, RecoverySetId};
19
20#[derive(Debug, Clone)]
22pub struct RenameSuggestion {
23 pub current_path: PathBuf,
25 pub correct_name: String,
27 pub file_id: FileId,
29 pub match_type: MatchType,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum MatchType {
36 Hash16k,
38 Par2File,
40}
41
42pub fn scan_for_renames(dir: &Path, par2_set: &Par2FileSet) -> io::Result<Vec<RenameSuggestion>> {
50 let mut hash_lookup: HashMap<[u8; 16], (FileId, &str)> = HashMap::new();
52 for (file_id, desc) in &par2_set.files {
53 hash_lookup.insert(desc.hash_16k, (*file_id, &desc.filename));
54 }
55
56 let known_filenames: std::collections::HashSet<&str> = par2_set
59 .files
60 .values()
61 .map(|d| d.filename.as_str())
62 .collect();
63
64 let mut suggestions = Vec::new();
65
66 let entries = fs::read_dir(dir)?;
67 for entry in entries {
68 let entry = entry?;
69 let path = entry.path();
70
71 if !path.is_file() {
72 continue;
73 }
74
75 let file_name = match path.file_name().and_then(|n| n.to_str()) {
76 Some(n) => n,
77 None => continue,
78 };
79
80 if is_generated_par2_artifact_name(file_name) {
81 continue;
82 }
83
84 if known_filenames.contains(file_name) {
86 continue;
87 }
88
89 let data = read_first_n_bytes(&path, 16384)?;
91 if data.is_empty() {
92 continue;
93 }
94 let hash = checksum::md5(&data);
95
96 if let Some(&(file_id, correct_name)) = hash_lookup.get(&hash)
97 && file_name != correct_name
98 {
99 debug!(
100 "rename match: {} -> {} (16k hash match)",
101 file_name, correct_name
102 );
103 suggestions.push(RenameSuggestion {
104 current_path: path,
105 correct_name: correct_name.to_string(),
106 file_id,
107 match_type: MatchType::Hash16k,
108 });
109 }
110 }
111
112 Ok(suggestions)
113}
114
115pub fn identify_par2_files(
121 dir: &Path,
122 expected_set_id: &RecoverySetId,
123) -> io::Result<Vec<PathBuf>> {
124 let mut matches = Vec::new();
125
126 let entries = fs::read_dir(dir)?;
127 for entry in entries {
128 let entry = entry?;
129 let path = entry.path();
130
131 if !path.is_file() {
132 continue;
133 }
134
135 let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
136 continue;
137 };
138
139 if is_generated_par2_artifact_name(file_name) {
140 continue;
141 }
142
143 let data = read_first_n_bytes(&path, header::HEADER_SIZE)?;
144 if data.len() < header::HEADER_SIZE {
145 continue;
146 }
147
148 if &data[0..8] != MAGIC {
150 continue;
151 }
152
153 if let Ok(hdr) = PacketHeader::parse(&data, 0)
155 && hdr.recovery_set_id == *expected_set_id
156 {
157 debug!("identified par2 file: {}", path.display());
158 matches.push(path);
159 }
160 }
161
162 Ok(matches)
163}
164
165#[derive(Debug, Clone)]
167pub struct SplitFileGroup {
168 pub base_name: String,
170 pub parts: Vec<PathBuf>,
172 pub part_numbers: Vec<u32>,
174 pub contiguous: bool,
176}
177
178pub fn detect_split_files(dir: &Path) -> io::Result<Vec<SplitFileGroup>> {
182 let mut groups: HashMap<String, Vec<(u32, PathBuf)>> = HashMap::new();
183
184 let entries = fs::read_dir(dir)?;
185 for entry in entries {
186 let entry = entry?;
187 let path = entry.path();
188
189 if !path.is_file() {
190 continue;
191 }
192
193 let file_name = match path.file_name().and_then(|n| n.to_str()) {
194 Some(n) => n.to_string(),
195 None => continue,
196 };
197
198 if let Some((base, ext)) = file_name.rsplit_once('.')
200 && !ext.is_empty()
201 && ext.chars().all(|c| c.is_ascii_digit())
202 && let Ok(num) = ext.parse::<u32>()
203 {
204 groups
205 .entry(base.to_string())
206 .or_default()
207 .push((num, path));
208 }
209 }
210
211 let mut result = Vec::new();
212 for (base_name, mut parts) in groups {
213 if parts.len() < 2 {
214 continue;
215 }
216 parts.sort_by_key(|(n, _)| *n);
217
218 let part_numbers: Vec<u32> = parts.iter().map(|(n, _)| *n).collect();
219 let paths: Vec<PathBuf> = parts.into_iter().map(|(_, p)| p).collect();
220
221 let contiguous = part_numbers[0] == 1
223 && part_numbers
224 .windows(2)
225 .all(|pair| matches!(pair, [a, b] if *b == *a + 1));
226
227 result.push(SplitFileGroup {
228 base_name,
229 parts: paths,
230 part_numbers,
231 contiguous,
232 });
233 }
234
235 result.sort_by(|a, b| a.base_name.cmp(&b.base_name));
237 Ok(result)
238}
239
240fn read_first_n_bytes(path: &Path, n: usize) -> io::Result<Vec<u8>> {
242 let mut file = File::open(path)?;
243 let file_len = file.metadata()?.len();
244 let mut buf = vec![0u8; n];
245 let bytes_read = crate::disk::read_filled(&mut file, &mut buf)?;
248 crate::file_cache::drop_touched_file_cache(&file, path, file_len, 0, bytes_read as u64);
249 buf.truncate(bytes_read);
250 Ok(buf)
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256 use crate::checksum::SliceChecksumState;
257 use crate::packet::header;
258 use crate::types::SliceChecksum;
259 use md5::{Digest, Md5};
260 use tempfile::TempDir;
261
262 fn make_full_packet(packet_type: &[u8; 16], body: &[u8], recovery_set_id: [u8; 16]) -> Vec<u8> {
264 let length = (header::HEADER_SIZE + body.len()) as u64;
265 let mut hash_input = Vec::new();
266 hash_input.extend_from_slice(&recovery_set_id);
267 hash_input.extend_from_slice(packet_type);
268 hash_input.extend_from_slice(body);
269 let packet_hash: [u8; 16] = Md5::digest(&hash_input).into();
270
271 let mut data = Vec::new();
272 data.extend_from_slice(header::MAGIC);
273 data.extend_from_slice(&length.to_le_bytes());
274 data.extend_from_slice(&packet_hash);
275 data.extend_from_slice(&recovery_set_id);
276 data.extend_from_slice(packet_type);
277 data.extend_from_slice(body);
278 data
279 }
280
281 fn setup_par2_set(file_data: &[u8], slice_size: u64, filename: &str) -> (Par2FileSet, FileId) {
283 let file_length = file_data.len() as u64;
284 let hash_full = checksum::md5(file_data);
285 let hash_16k_data = &file_data[..file_data.len().min(16384)];
286 let hash_16k = checksum::md5(hash_16k_data);
287
288 let mut id_input = Vec::new();
289 id_input.extend_from_slice(&hash_16k);
290 id_input.extend_from_slice(&file_length.to_le_bytes());
291 id_input.extend_from_slice(filename.as_bytes());
292 let file_id_bytes: [u8; 16] = Md5::digest(&id_input).into();
293 let file_id = FileId::from_bytes(file_id_bytes);
294
295 let num_slices = if file_length == 0 {
296 0
297 } else {
298 file_length.div_ceil(slice_size) as usize
299 };
300
301 let mut checksums = Vec::new();
302 for i in 0..num_slices {
303 let offset = i as u64 * slice_size;
304 let end = ((offset + slice_size) as usize).min(file_data.len());
305 let slice_data = &file_data[offset as usize..end];
306 let mut state = SliceChecksumState::new();
307 state.update(slice_data);
308 let pad_to = if (slice_data.len() as u64) < slice_size {
309 Some(slice_size)
310 } else {
311 None
312 };
313 let (crc, md5) = state.finalize(pad_to);
314 checksums.push(SliceChecksum { crc32: crc, md5 });
315 }
316
317 let mut main_body = Vec::new();
318 main_body.extend_from_slice(&slice_size.to_le_bytes());
319 main_body.extend_from_slice(&1u32.to_le_bytes());
320 main_body.extend_from_slice(&file_id_bytes);
321 let rsid: [u8; 16] = Md5::digest(&main_body).into();
322
323 let mut fd_body = Vec::new();
324 fd_body.extend_from_slice(&file_id_bytes);
325 fd_body.extend_from_slice(&hash_full);
326 fd_body.extend_from_slice(&hash_16k);
327 fd_body.extend_from_slice(&file_length.to_le_bytes());
328 fd_body.extend_from_slice(filename.as_bytes());
329 while fd_body.len() % 4 != 0 {
330 fd_body.push(0);
331 }
332
333 let mut ifsc_body = Vec::new();
334 ifsc_body.extend_from_slice(&file_id_bytes);
335 for cs in &checksums {
336 ifsc_body.extend_from_slice(&cs.md5);
337 ifsc_body.extend_from_slice(&cs.crc32.to_le_bytes());
338 }
339
340 let mut stream = Vec::new();
341 stream.extend_from_slice(&make_full_packet(header::TYPE_MAIN, &main_body, rsid));
342 stream.extend_from_slice(&make_full_packet(header::TYPE_FILE_DESC, &fd_body, rsid));
343 stream.extend_from_slice(&make_full_packet(header::TYPE_IFSC, &ifsc_body, rsid));
344
345 let set = Par2FileSet::from_files(&[&stream]).unwrap();
346 (set, file_id)
347 }
348
349 #[test]
350 fn scan_finds_obfuscated_file() {
351 let dir = TempDir::new().unwrap();
352 let file_data = b"This is the real file content for rename testing!!";
353 let correct_name = "movie.rar";
354
355 let (par2_set, file_id) = setup_par2_set(file_data, 1024, correct_name);
356
357 fs::write(dir.path().join("abc123def456.bin"), file_data).unwrap();
359
360 let suggestions = scan_for_renames(dir.path(), &par2_set).unwrap();
361 assert_eq!(suggestions.len(), 1);
362 assert_eq!(suggestions[0].correct_name, correct_name);
363 assert_eq!(suggestions[0].file_id, file_id);
364 assert_eq!(suggestions[0].match_type, MatchType::Hash16k);
365 }
366
367 #[test]
368 fn scan_skips_correctly_named() {
369 let dir = TempDir::new().unwrap();
370 let file_data = b"This is the real file content for rename testing!!";
371 let correct_name = "movie.rar";
372
373 let (par2_set, _) = setup_par2_set(file_data, 1024, correct_name);
374
375 fs::write(dir.path().join(correct_name), file_data).unwrap();
377
378 let suggestions = scan_for_renames(dir.path(), &par2_set).unwrap();
379 assert!(suggestions.is_empty());
380 }
381
382 #[test]
383 fn scan_skips_generated_repair_artifacts() {
384 let dir = TempDir::new().unwrap();
385 let file_data = b"This is the real file content for rename testing!!";
386 let correct_name = "movie.rar";
387
388 let (par2_set, _) = setup_par2_set(file_data, 1024, correct_name);
389
390 fs::write(
391 dir.path().join("movie.rar.weaver-par2-backup.123"),
392 file_data,
393 )
394 .unwrap();
395
396 let suggestions = scan_for_renames(dir.path(), &par2_set).unwrap();
397 assert!(suggestions.is_empty());
398 }
399
400 #[test]
401 fn scan_empty_dir() {
402 let dir = TempDir::new().unwrap();
403 let file_data = b"data";
404 let (par2_set, _) = setup_par2_set(file_data, 1024, "test.bin");
405
406 let suggestions = scan_for_renames(dir.path(), &par2_set).unwrap();
407 assert!(suggestions.is_empty());
408 }
409
410 #[test]
411 fn identify_par2_files_finds_match() {
412 let dir = TempDir::new().unwrap();
413
414 let rsid = [0x42u8; 16];
416 let main_body_data = vec![0u8; 12]; let packet_data = make_full_packet(header::TYPE_MAIN, &main_body_data, rsid);
418
419 fs::write(dir.path().join("random_name.bin"), &packet_data).unwrap();
421
422 let expected_id = RecoverySetId::from_bytes(rsid);
423 let matches = identify_par2_files(dir.path(), &expected_id).unwrap();
424 assert_eq!(matches.len(), 1);
425 }
426
427 #[test]
428 fn identify_par2_files_ignores_non_par2() {
429 let dir = TempDir::new().unwrap();
430
431 fs::write(dir.path().join("regular.txt"), b"not a par2 file").unwrap();
432
433 let expected_id = RecoverySetId::from_bytes([0x42; 16]);
434 let matches = identify_par2_files(dir.path(), &expected_id).unwrap();
435 assert!(matches.is_empty());
436 }
437
438 #[test]
439 fn detect_split_files_basic() {
440 let dir = TempDir::new().unwrap();
441
442 fs::write(dir.path().join("movie.mkv.001"), b"part1").unwrap();
443 fs::write(dir.path().join("movie.mkv.002"), b"part2").unwrap();
444 fs::write(dir.path().join("movie.mkv.003"), b"part3").unwrap();
445 fs::write(dir.path().join("other.txt"), b"not split").unwrap();
446
447 let groups = detect_split_files(dir.path()).unwrap();
448 assert_eq!(groups.len(), 1);
449 assert_eq!(groups[0].base_name, "movie.mkv");
450 assert_eq!(groups[0].part_numbers, vec![1, 2, 3]);
451 assert!(groups[0].contiguous);
452 }
453
454 #[test]
455 fn detect_split_files_with_gap() {
456 let dir = TempDir::new().unwrap();
457
458 fs::write(dir.path().join("data.bin.001"), b"p1").unwrap();
459 fs::write(dir.path().join("data.bin.003"), b"p3").unwrap();
460
461 let groups = detect_split_files(dir.path()).unwrap();
462 assert_eq!(groups.len(), 1);
463 assert!(!groups[0].contiguous);
464 assert_eq!(groups[0].part_numbers, vec![1, 3]);
465 }
466
467 #[test]
468 fn detect_split_files_single_part_ignored() {
469 let dir = TempDir::new().unwrap();
470
471 fs::write(dir.path().join("lonely.bin.001"), b"alone").unwrap();
472
473 let groups = detect_split_files(dir.path()).unwrap();
474 assert!(groups.is_empty());
475 }
476
477 #[test]
478 fn detect_split_files_empty_dir() {
479 let dir = TempDir::new().unwrap();
480 let groups = detect_split_files(dir.path()).unwrap();
481 assert!(groups.is_empty());
482 }
483}