Skip to main content

par2_rs/
rename.rs

1//! Obfuscated filename recovery and split-file detection.
2//!
3//! Usenet files are frequently uploaded with randomized filenames to evade
4//! takedowns. PAR2 file descriptions contain the original filename and a 16KB
5//! MD5 hash, which can be used to identify and rename obfuscated files.
6
7use 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/// A suggestion to rename a file to its correct name.
21#[derive(Debug, Clone)]
22pub struct RenameSuggestion {
23    /// Current path on disk.
24    pub current_path: PathBuf,
25    /// The correct filename from PAR2 metadata.
26    pub correct_name: String,
27    /// The file ID this matched against.
28    pub file_id: FileId,
29    /// How the match was determined.
30    pub match_type: MatchType,
31}
32
33/// How an obfuscated file was identified.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum MatchType {
36    /// Matched via MD5 hash of the first 16KB.
37    Hash16k,
38    /// Identified as a PAR2 file by its packet header recovery set ID.
39    Par2File,
40}
41
42/// Scan a directory and match files against PAR2 file descriptions.
43///
44/// Reads the first 16KB of each file, computes its MD5 hash, and compares
45/// against the `hash_16k` values from the PAR2 set. Returns rename suggestions
46/// for files whose names don't match the PAR2 metadata.
47///
48/// Does **not** perform any actual renames — the caller decides what to do.
49pub fn scan_for_renames(dir: &Path, par2_set: &Par2FileSet) -> io::Result<Vec<RenameSuggestion>> {
50    // Build lookup: hash_16k -> (FileId, correct_filename)
51    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    // Set of filenames already correctly named (so we don't suggest renaming
57    // a file that already has the right name).
58    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        // Skip files that already have a known correct name.
85        if known_filenames.contains(file_name) {
86            continue;
87        }
88
89        // Read first 16KB and compute MD5.
90        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
115/// Identify PAR2 files in a directory that belong to a specific recovery set.
116///
117/// Reads the first 64 bytes of each file to check for PAR2 magic and extract
118/// the recovery set ID. Returns paths of files that match `expected_set_id`
119/// but may have obfuscated names.
120pub 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        // Check for PAR2 magic.
149        if &data[0..8] != MAGIC {
150            continue;
151        }
152
153        // Try to parse the header.
154        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/// A group of split files that together form one logical file.
166#[derive(Debug, Clone)]
167pub struct SplitFileGroup {
168    /// The base name without the numeric extension (e.g., "movie.mkv" for "movie.mkv.001").
169    pub base_name: String,
170    /// Paths of the split parts, sorted by part number.
171    pub parts: Vec<PathBuf>,
172    /// The part numbers found (e.g., [1, 2, 3]).
173    pub part_numbers: Vec<u32>,
174    /// Whether the sequence is contiguous starting from 1 (no gaps).
175    pub contiguous: bool,
176}
177
178/// Detect split files (`.001`, `.002`, etc.) in a directory.
179///
180/// Groups files by base name and returns groups with 2 or more parts.
181pub 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        // Check if the extension is purely numeric.
199        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        // Check contiguity: starts at 1 and no gaps.
222        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    // Sort by base name for deterministic output.
236    result.sort_by(|a, b| a.base_name.cmp(&b.base_name));
237    Ok(result)
238}
239
240/// Read up to `n` bytes from the start of a file.
241fn 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    // Fill, don't single-read: a short read here would silently hash fewer
246    // bytes than the 16k quick hash is defined over. See `disk::read_filled`.
247    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    /// Helper to build a complete valid packet (header + body).
263    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    /// Build a Par2FileSet for a single file with known content.
282    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        // Write file with obfuscated name
358        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        // Write file with correct name
376        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        // Build a minimal PAR2 packet
415        let rsid = [0x42u8; 16];
416        let main_body_data = vec![0u8; 12]; // dummy main body
417        let packet_data = make_full_packet(header::TYPE_MAIN, &main_body_data, rsid);
418
419        // Write with obfuscated name
420        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}