1use std::collections::HashSet;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12use std::time::Instant;
13
14use nzb_core::models::{JobFailureCode, StageResult, StageStatus};
15use tracing::{debug, error, info, warn};
16
17use crate::detect::{
18 ArchiveType, find_archives, find_cleanup_files, find_par2_files, is_par2_volume,
19};
20use crate::par2::par2_repair;
21use crate::resources::PostProcResourcePool;
22use crate::unpack::{ArchivePasswordRequired, extract_7z, extract_rar, extract_tar, extract_zip};
23
24fn increment_counter(name: &'static str) {
25 opentelemetry::global::meter_provider()
26 .meter("rustnzb")
27 .u64_counter(name)
28 .build()
29 .add(1, &[]);
30}
31
32enum VerifyRepairOutcome {
36 AllCorrect {
37 intact_count: usize,
38 },
39 Damaged {
40 intact: usize,
41 damaged: usize,
42 missing: usize,
43 blocks_needed: u32,
44 blocks_available: u32,
45 repair_result: Result<rust_par2::RepairResult, rust_par2::RepairError>,
46 },
47}
48
49fn parse_par2_sets(paths: &[PathBuf]) -> (Vec<rust_par2::Par2FileSet>, Vec<String>) {
58 let mut recovery_set_ids = HashSet::new();
59 let mut sets = Vec::new();
60 let mut errors = Vec::new();
61 for path in paths.iter().filter(|path| {
62 path.file_name()
63 .and_then(|name| name.to_str())
64 .is_none_or(|name| !is_par2_volume(&name.to_ascii_lowercase()))
65 }) {
66 match rust_par2::parse(path) {
67 Ok(set) if recovery_set_ids.insert(set.recovery_set_id) => sets.push(set),
68 Ok(_) => {}
69 Err(error) => errors.push(format!("{}: {error}", path.display())),
70 }
71 }
72 (sets, errors)
73}
74
75fn verify_repair_set(file_set: &rust_par2::Par2FileSet, dir: &Path) -> VerifyRepairOutcome {
78 let verify_result = rust_par2::verify(file_set, dir);
79 if verify_result.all_correct() {
80 return VerifyRepairOutcome::AllCorrect {
81 intact_count: verify_result.intact.len(),
82 };
83 }
84
85 let intact = verify_result.intact.len();
86 let damaged = verify_result.damaged.len();
87 let missing = verify_result.missing.len();
88 let blocks_needed = verify_result.blocks_needed();
89 let blocks_available = verify_result.recovery_blocks_available;
90 info!(
91 intact,
92 damaged,
93 missing,
94 blocks_needed,
95 "Native PAR2 verify: damage detected, attempting native repair"
96 );
97 let repair_result = rust_par2::repair_from_verify(file_set, dir, &verify_result);
98 VerifyRepairOutcome::Damaged {
99 intact,
100 damaged,
101 missing,
102 blocks_needed,
103 blocks_available,
104 repair_result,
105 }
106}
107
108#[derive(Debug)]
110pub struct PostProcResult {
111 pub success: bool,
113 pub stages: Vec<StageResult>,
115 pub error: Option<String>,
117 pub failure_code: Option<JobFailureCode>,
119}
120
121#[derive(Debug, Clone)]
123pub struct PostProcConfig {
124 pub cleanup_after_extract: bool,
126 pub delete_archives: bool,
131 pub output_dir: Option<PathBuf>,
134 pub articles_failed: usize,
139 pub content_articles_failed: usize,
142 pub skip_extract: bool,
145 pub password: Option<String>,
147 pub max_nested_archive_depth: u8,
150}
151
152impl Default for PostProcConfig {
153 fn default() -> Self {
154 Self {
155 cleanup_after_extract: true,
156 delete_archives: true,
157 output_dir: None,
158 articles_failed: 0,
159 content_articles_failed: 0,
160 skip_extract: false,
161 password: None,
162 max_nested_archive_depth: 5,
163 }
164 }
165}
166
167pub async fn run_pipeline(job_dir: &Path, config: &PostProcConfig) -> PostProcResult {
175 run_pipeline_with_resources(job_dir, config, None).await
176}
177
178pub async fn run_pipeline_with_resources(
184 job_dir: &Path,
185 config: &PostProcConfig,
186 resources: Option<&Arc<PostProcResourcePool>>,
187) -> PostProcResult {
188 run_pipeline_with_cleanup(job_dir, config, resources, &[], &[]).await
189}
190
191pub async fn run_pipeline_with_cleanup(
193 job_dir: &Path,
194 config: &PostProcConfig,
195 resources: Option<&Arc<PostProcResourcePool>>,
196 cleanup_patterns: &[String],
197 unwanted_extensions: &[String],
198) -> PostProcResult {
199 run_stages(
200 job_dir,
201 config,
202 resources,
203 cleanup_patterns,
204 unwanted_extensions,
205 true,
206 )
207 .await
208}
209
210pub async fn run_repair_pipeline(
216 job_dir: &Path,
217 config: &PostProcConfig,
218 resources: Option<&Arc<PostProcResourcePool>>,
219) -> PostProcResult {
220 run_stages(job_dir, config, resources, &[], &[], false).await
221}
222
223async fn run_stages(
224 job_dir: &Path,
225 config: &PostProcConfig,
226 resources: Option<&Arc<PostProcResourcePool>>,
227 cleanup_patterns: &[String],
228 unwanted_extensions: &[String],
229 unpack: bool,
230) -> PostProcResult {
231 let mut stages: Vec<StageResult> = Vec::new();
232 let mut pipeline_ok = true;
233 let mut failure_code = None;
234
235 info!(dir = %job_dir.display(), "Starting post-processing pipeline");
236
237 let par2_files = find_par2_files(job_dir);
249
250 info!(
251 par2_files = par2_files.len(),
252 "PAR2 files discovered for post-processing"
253 );
254
255 if par2_files.is_empty() {
256 if config.content_articles_failed > 0 {
257 pipeline_ok = false;
258 failure_code = Some(JobFailureCode::ArticlesUnavailable);
259 stages.push(StageResult {
260 name: "Verify".to_string(),
261 status: StageStatus::Failed,
262 message: Some(format!(
263 "{} content article(s) missing and no PAR2 recovery set is available",
264 config.content_articles_failed
265 )),
266 duration_secs: 0.0,
267 });
268 } else {
269 stages.push(StageResult {
270 name: "Verify".to_string(),
271 status: StageStatus::Skipped,
272 message: Some("No par2 files found".to_string()),
273 duration_secs: 0.0,
274 });
275 }
276 } else {
277 let started = Instant::now();
278 let (file_sets, parse_errors) = parse_par2_sets(&par2_files);
279 debug!(
280 recovery_sets = file_sets.len(),
281 parse_failures = parse_errors.len(),
282 "Parsed distinct PAR2 recovery sets"
283 );
284
285 if file_sets.is_empty() {
286 let error = parse_errors
289 .first()
290 .map(String::as_str)
291 .unwrap_or("no PAR2 index file found");
292 if config.articles_failed == 0 {
293 stages.push(StageResult {
294 name: "Verify".to_string(),
295 status: StageStatus::Skipped,
296 message: Some(format!(
297 "PAR2 parse failed ({error}), but zero article failures"
298 )),
299 duration_secs: started.elapsed().as_secs_f64(),
300 });
301 } else {
302 stages.push(StageResult {
303 name: "Verify".to_string(),
304 status: StageStatus::Skipped,
305 message: Some(format!("PAR2 parse failed ({error}), attempting repair")),
306 duration_secs: started.elapsed().as_secs_f64(),
307 });
308 let repair_result = run_repair_stage(job_dir).await;
309 increment_counter(if repair_result.status == StageStatus::Failed {
310 "par2.repair_failure"
311 } else {
312 "par2.repair_success"
313 });
314 if repair_result.status == StageStatus::Failed {
315 pipeline_ok = false;
316 failure_code = Some(JobFailureCode::RepairFailed);
317 }
318 stages.push(repair_result);
319 }
320 } else if config.articles_failed == 0 {
321 info!(
332 recovery_sets = file_sets.len(),
333 "Skipping PAR2 verification — zero article failures (CRC-verified)"
334 );
335 for file_set in &file_sets {
336 rename_to_par2_names(file_set, job_dir);
337 }
338 stages.push(StageResult {
339 name: "Verify".to_string(),
340 status: StageStatus::Skipped,
341 message: Some(format!(
342 "Skipped — zero article failures (PAR2-guided rename applied to {} recovery set(s))",
343 file_sets.len()
344 )),
345 duration_secs: started.elapsed().as_secs_f64(),
346 });
347 } else {
348 let _repair_permit = if let Some(resources) = resources {
349 Some(resources.acquire_repair().await)
350 } else {
351 None
352 };
353 for file_set in &file_sets {
356 rename_to_par2_names(file_set, job_dir);
357 }
358 let recovery_set_count = file_sets.len();
359 let dir = job_dir.to_path_buf();
360 let outcomes = tokio::task::spawn_blocking(move || {
365 file_sets
366 .iter()
367 .map(|file_set| verify_repair_set(file_set, &dir))
368 .collect::<Vec<_>>()
369 })
370 .await;
371 let duration = started.elapsed().as_secs_f64();
372
373 match outcomes {
374 Ok(outcomes) => {
375 for (index, outcome) in outcomes.into_iter().enumerate() {
376 let set_number = index + 1;
377 match outcome {
378 VerifyRepairOutcome::AllCorrect { intact_count } => {
379 increment_counter("par2.verify_success");
380 info!(
381 set_number,
382 recovery_set_count,
383 files = intact_count,
384 "Native PAR2 verify: all files correct"
385 );
386 stages.push(StageResult {
387 name: "Verify".to_string(),
388 status: StageStatus::Success,
389 message: Some(format!(
390 "Recovery set {set_number}/{recovery_set_count}: all {intact_count} files correct"
391 )),
392 duration_secs: duration,
393 });
394 }
395 VerifyRepairOutcome::Damaged {
396 intact,
397 damaged,
398 missing,
399 blocks_needed,
400 blocks_available,
401 repair_result,
402 } => {
403 stages.push(StageResult {
404 name: "Verify".to_string(),
405 status: StageStatus::Success,
406 message: Some(format!(
407 "Recovery set {set_number}/{recovery_set_count}: {intact} intact, {damaged} damaged, {missing} missing — {blocks_needed} blocks needed"
408 )),
409 duration_secs: duration,
410 });
411 match repair_result {
412 Ok(result) => {
413 increment_counter(if result.success {
414 "par2.repair_success"
415 } else {
416 "par2.repair_failure"
417 });
418 info!(
419 set_number,
420 recovery_set_count,
421 blocks_repaired = result.blocks_repaired,
422 files_repaired = result.files_repaired,
423 "Native PAR2 repair complete"
424 );
425 if !result.success {
426 pipeline_ok = false;
427 failure_code = Some(JobFailureCode::RepairFailed);
428 }
429 stages.push(StageResult {
430 name: "Repair".to_string(),
431 status: if result.success {
432 StageStatus::Success
433 } else {
434 StageStatus::Failed
435 },
436 message: Some(format!(
437 "Recovery set {set_number}/{recovery_set_count}: {}",
438 result.message
439 )),
440 duration_secs: duration,
441 });
442 }
443 Err(e) => {
444 increment_counter("par2.repair_failure");
445 error!(
446 error = %e,
447 blocks_needed,
448 blocks_available,
449 damaged,
450 missing,
451 set_number,
452 recovery_set_count,
453 "Native PAR2 repair failed"
454 );
455 pipeline_ok = false;
456 failure_code = Some(JobFailureCode::RepairFailed);
457 stages.push(StageResult {
458 name: "Repair".to_string(),
459 status: StageStatus::Failed,
460 message: Some(format!(
461 "Recovery set {set_number}/{recovery_set_count}: repair failed: {e}"
462 )),
463 duration_secs: duration,
464 });
465 }
466 }
467 }
468 }
469 }
470 }
471 Err(e) => {
472 error!(error = %e, "Verify/repair task panicked");
473 pipeline_ok = false;
474 failure_code = Some(JobFailureCode::RepairFailed);
475 stages.push(StageResult {
476 name: "Verify".to_string(),
477 status: StageStatus::Failed,
478 message: Some(format!("Verify task panicked: {e}")),
479 duration_secs: duration,
480 });
481 }
482 }
483 }
484 }
485
486 let should_extract = unpack && pipeline_ok;
493 let mut extracted_archives = Vec::new();
494 if should_extract {
495 let output_dir = config.output_dir.as_deref().unwrap_or(job_dir);
496 let source_dir = if config.skip_extract {
500 info!("Outer extraction completed by direct unpack; checking for nested archives");
501 output_dir
502 } else {
503 job_dir
504 };
505 let job_dir_archives: Vec<_> = if config.skip_extract && source_dir != job_dir {
509 find_archives(job_dir)
510 .into_iter()
511 .filter(|(archive_type, _)| *archive_type != ArchiveType::Rar)
512 .collect()
513 } else {
514 Vec::new()
515 };
516 let _extract_permit = if let Some(resources) = resources {
517 Some(resources.acquire_extract().await)
518 } else {
519 None
520 };
521 let (result, processed_archives, extract_failure_code) = run_extract_stage(
522 source_dir,
523 job_dir_archives,
524 output_dir,
525 config.password.as_deref(),
526 config.max_nested_archive_depth,
527 )
528 .await;
529 extracted_archives = processed_archives;
530 if result.status == StageStatus::Failed {
531 pipeline_ok = false;
532 failure_code = extract_failure_code.or(Some(JobFailureCode::ArchiveInvalid));
533 } else if result.status == StageStatus::Success {
534 pipeline_ok = true;
536 }
537 stages.push(result);
538 }
539
540 if unpack && pipeline_ok && config.cleanup_after_extract {
544 let cleanup_root = config.output_dir.as_deref().unwrap_or(job_dir);
545 let result = run_cleanup_stage_with_rules(
546 job_dir,
547 cleanup_root,
548 &extracted_archives,
549 cleanup_patterns,
550 unwanted_extensions,
551 config.delete_archives,
552 );
553 stages.push(result);
554 }
555
556 let error = if pipeline_ok {
557 None
558 } else {
559 let msgs: Vec<String> = stages
561 .iter()
562 .filter(|s| s.status == StageStatus::Failed)
563 .filter_map(|s| s.message.clone())
564 .collect();
565 Some(msgs.join("; "))
566 };
567
568 info!(
569 success = pipeline_ok,
570 stages = stages.len(),
571 "Post-processing pipeline finished"
572 );
573
574 PostProcResult {
575 success: pipeline_ok,
576 stages,
577 error,
578 failure_code: if pipeline_ok {
579 None
580 } else {
581 failure_code.or(Some(JobFailureCode::DownloadFailed))
582 },
583 }
584}
585
586fn rename_to_par2_names(file_set: &rust_par2::Par2FileSet, dir: &Path) {
600 let mut expected: std::collections::HashMap<[u8; 16], &str> = std::collections::HashMap::new();
602 for par2_file in file_set.files.values() {
603 expected.insert(par2_file.hash_16k, &par2_file.filename);
604 }
605
606 let any_match = file_set
608 .files
609 .values()
610 .any(|f| dir.join(&f.filename).exists());
611 if any_match {
612 return;
613 }
614
615 let entries: Vec<_> = match std::fs::read_dir(dir) {
617 Ok(rd) => rd.filter_map(|e| e.ok()).collect(),
618 Err(_) => return,
619 };
620
621 let mut renamed = 0u32;
622 for entry in &entries {
623 let path = entry.path();
624 if !path.is_file() {
625 continue;
626 }
627 let current_name = match path.file_name().and_then(|n| n.to_str()) {
628 Some(n) => n.to_string(),
629 None => continue,
630 };
631
632 if current_name.to_lowercase().ends_with(".par2") {
634 continue;
635 }
636
637 let hash = match rust_par2::compute_hash_16k(&path) {
638 Ok(h) => h,
639 Err(_) => continue,
640 };
641
642 if let Some(&par2_name) = expected.get(&hash)
643 && current_name != par2_name
644 {
645 let new_path = dir.join(par2_name);
646 if !new_path.exists() {
647 if let Err(e) = std::fs::rename(&path, &new_path) {
648 warn!(
649 from = %current_name,
650 to = %par2_name,
651 "Failed to rename file to PAR2 expected name: {e}"
652 );
653 } else {
654 renamed += 1;
655 debug!(
656 from = %current_name,
657 to = %par2_name,
658 "Renamed file to match PAR2 metadata"
659 );
660 }
661 }
662 }
663 }
664
665 if renamed > 0 {
666 info!(
667 renamed,
668 "PAR2-guided deobfuscation: renamed files to match PAR2 expected names"
669 );
670 }
671}
672
673async fn run_repair_stage(job_dir: &Path) -> StageResult {
680 let start = Instant::now();
681 let par2_files = find_par2_files(job_dir);
682
683 if par2_files.is_empty() {
684 return StageResult {
685 name: "Repair".to_string(),
686 status: StageStatus::Skipped,
687 message: Some("No par2 files found".to_string()),
688 duration_secs: start.elapsed().as_secs_f64(),
689 };
690 }
691
692 let index_par2 = &par2_files[0];
693 info!(file = %index_par2.display(), "Running native par2 repair");
694
695 match par2_repair(index_par2).await {
696 Ok(result) => {
697 let status = if result.repaired || result.success {
698 StageStatus::Success
699 } else {
700 StageStatus::Failed
701 };
702
703 StageResult {
704 name: "Repair".to_string(),
705 status,
706 message: Some(result.message),
707 duration_secs: start.elapsed().as_secs_f64(),
708 }
709 }
710 Err(e) => {
711 error!(error = %e, "par2 repair failed with error");
712 StageResult {
713 name: "Repair".to_string(),
714 status: StageStatus::Failed,
715 message: Some(format!("par2 repair error: {e}")),
716 duration_secs: start.elapsed().as_secs_f64(),
717 }
718 }
719 }
720}
721
722async fn run_extract_stage(
726 source_dir: &Path,
727 extra_archives: Vec<(ArchiveType, PathBuf)>,
728 output_dir: &Path,
729 password: Option<&str>,
730 max_nested_archive_depth: u8,
731) -> (StageResult, Vec<PathBuf>, Option<JobFailureCode>) {
732 let start = Instant::now();
733 let mut all_ok = true;
734 let mut messages: Vec<String> = Vec::new();
735 let mut processed: HashSet<PathBuf> = if source_dir == output_dir {
740 HashSet::new()
741 } else {
742 find_archives(output_dir)
743 .into_iter()
744 .map(|(_, path)| path)
745 .collect()
746 };
747 let mut extracted_archives = Vec::new();
748 let mut scan_dir = source_dir;
749 let mut extracted_any = false;
750 let mut failure_code = None;
751 let mut extra_archives = Some(extra_archives);
752
753 for depth in 0..=max_nested_archive_depth {
754 let archives: Vec<_> = find_archives(scan_dir)
755 .into_iter()
756 .chain(extra_archives.take().unwrap_or_default())
757 .filter(|(_, path)| processed.insert(path.clone()))
758 .collect();
759 if archives.is_empty() {
760 break;
761 }
762
763 extracted_any = true;
764 extracted_archives.extend(archives.iter().map(|(_, path)| path.clone()));
765 for (archive_type, path) in &archives {
766 info!(depth, kind = %archive_type, file = %path.display(), "Extracting archive");
767 let result = match archive_type {
768 ArchiveType::Rar => extract_rar(path, output_dir, password).await,
769 ArchiveType::SevenZip => extract_7z(path, output_dir, password).await,
770 ArchiveType::Tar => extract_tar(path, output_dir).await,
771 ArchiveType::Zip => extract_zip(path, output_dir).await,
772 };
773
774 match result {
775 Ok(unpack_result) if unpack_result.success => {
776 messages.push(format!("depth {depth} {archive_type}: OK"));
777 }
778 Ok(unpack_result) => {
779 all_ok = false;
780 failure_code = Some(JobFailureCode::ArchiveInvalid);
781 let detail = unpack_result
782 .error_output
783 .trim()
784 .lines()
785 .find(|line| !line.trim().is_empty());
786 messages.push(match detail {
787 Some(detail) => format!("depth {depth} {archive_type}: failed ({detail})"),
788 None => format!("depth {depth} {archive_type}: failed"),
789 });
790 }
791 Err(e) => {
792 all_ok = false;
793 failure_code = Some(if e.downcast_ref::<ArchivePasswordRequired>().is_some() {
794 JobFailureCode::ArchivePasswordRequired
795 } else {
796 JobFailureCode::ArchiveInvalid
797 });
798 error!(depth, kind = %archive_type, file = %path.display(), error = %e, "Extraction error");
799 messages.push(format!("depth {depth} {archive_type}: {e}"));
800 }
801 }
802 }
803
804 if !all_ok {
805 break;
806 }
807 scan_dir = output_dir;
808 }
809
810 if all_ok
811 && !find_archives(scan_dir)
812 .into_iter()
813 .all(|(_, path)| processed.contains(&path))
814 {
815 all_ok = false;
816 failure_code = Some(JobFailureCode::ArchiveInvalid);
817 messages.push(format!(
818 "nested archive depth limit ({max_nested_archive_depth}) reached; source files retained"
819 ));
820 }
821
822 if !extracted_any {
823 info!("No archives found — skipping extraction");
824 return (
825 StageResult {
826 name: "Extract".to_string(),
827 status: StageStatus::Skipped,
828 message: Some("No archives found".to_string()),
829 duration_secs: start.elapsed().as_secs_f64(),
830 },
831 Vec::new(),
832 None,
833 );
834 }
835
836 (
837 StageResult {
838 name: "Extract".to_string(),
839 status: if all_ok {
840 StageStatus::Success
841 } else {
842 StageStatus::Failed
843 },
844 message: Some(messages.join("; ")),
845 duration_secs: start.elapsed().as_secs_f64(),
846 },
847 extracted_archives,
848 failure_code,
849 )
850}
851
852#[cfg(test)]
853fn run_cleanup_stage(job_dir: &Path, extracted_archives: &[PathBuf]) -> StageResult {
854 run_cleanup_stage_with_rules(job_dir, job_dir, extracted_archives, &[], &[], true)
855}
856
857fn wildcard_match(pattern: &str, value: &str) -> bool {
858 let (mut pattern_index, mut value_index) = (0usize, 0usize);
859 let mut star = None;
860 let mut star_value = 0usize;
861 let pattern = pattern.as_bytes();
862 let value = value.as_bytes();
863
864 while value_index < value.len() {
865 if pattern_index < pattern.len()
866 && (pattern[pattern_index] == value[value_index] || pattern[pattern_index] == b'?')
867 {
868 pattern_index += 1;
869 value_index += 1;
870 } else if pattern_index < pattern.len() && pattern[pattern_index] == b'*' {
871 star = Some(pattern_index);
872 pattern_index += 1;
873 star_value = value_index;
874 } else if let Some(star_index) = star {
875 pattern_index = star_index + 1;
876 star_value += 1;
877 value_index = star_value;
878 } else {
879 return false;
880 }
881 }
882 while pattern_index < pattern.len() && pattern[pattern_index] == b'*' {
883 pattern_index += 1;
884 }
885 pattern_index == pattern.len()
886}
887
888fn run_cleanup_stage_with_rules(
889 job_dir: &Path,
890 cleanup_root: &Path,
891 extracted_archives: &[PathBuf],
892 cleanup_patterns: &[String],
893 unwanted_extensions: &[String],
894 delete_archives: bool,
895) -> StageResult {
896 let start = Instant::now();
897 let mut files = find_cleanup_files(job_dir);
898 if delete_archives {
899 files.extend(
900 extracted_archives
901 .iter()
902 .filter(|path| {
903 std::fs::symlink_metadata(path)
904 .map(|metadata| metadata.file_type().is_file())
905 .unwrap_or(false)
906 })
907 .cloned(),
908 );
909 } else {
910 files.retain(|path| {
913 path.extension()
914 .is_some_and(|extension| extension.eq_ignore_ascii_case("par2"))
915 });
916 }
917
918 let normalized_extensions: Vec<String> = unwanted_extensions
919 .iter()
920 .map(|extension| {
921 let extension = extension.trim().to_ascii_lowercase();
922 if extension.starts_with('.') {
923 extension
924 } else {
925 format!(".{extension}")
926 }
927 })
928 .filter(|extension| extension.len() > 1)
929 .collect();
930 let patterns: Vec<String> = cleanup_patterns
931 .iter()
932 .map(|pattern| pattern.replace('\\', "/").to_ascii_lowercase())
933 .filter(|pattern| !pattern.is_empty())
934 .collect();
935 for root in [job_dir, cleanup_root] {
936 for entry in walkdir::WalkDir::new(root).into_iter().flatten() {
937 let path = entry.path();
938 let Ok(metadata) = std::fs::symlink_metadata(path) else {
939 continue;
940 };
941 if !metadata.file_type().is_file() {
942 continue;
943 }
944 let Ok(relative) = path.strip_prefix(root) else {
945 continue;
946 };
947 let relative = relative.to_string_lossy().replace('\\', "/");
948 let filename = path
949 .file_name()
950 .map(|name| name.to_string_lossy().to_string())
951 .unwrap_or_default();
952 if normalized_extensions
953 .iter()
954 .any(|extension| filename.to_ascii_lowercase().ends_with(extension))
955 || patterns.iter().any(|pattern| {
956 wildcard_match(pattern, &relative.to_ascii_lowercase())
957 || wildcard_match(pattern, &filename.to_ascii_lowercase())
958 })
959 {
960 files.push(path.to_path_buf());
961 }
962 }
963 }
964 files.sort();
965 files.dedup();
966
967 if files.is_empty() {
968 return StageResult {
969 name: "Cleanup".to_string(),
970 status: StageStatus::Skipped,
971 message: Some("No files to clean up".to_string()),
972 duration_secs: start.elapsed().as_secs_f64(),
973 };
974 }
975
976 let mut removed = 0u32;
977 let mut errors = 0u32;
978
979 for path in &files {
980 let under_allowed_root = path.starts_with(job_dir) || path.starts_with(cleanup_root);
981 let is_regular_file = std::fs::symlink_metadata(path)
982 .map(|metadata| metadata.file_type().is_file())
983 .unwrap_or(false);
984 if !under_allowed_root || !is_regular_file {
985 warn!(file = %path.display(), "Skipping cleanup path outside job roots or through a link");
986 errors += 1;
987 continue;
988 }
989 match std::fs::remove_file(path) {
990 Ok(()) => {
991 removed += 1;
992 }
993 Err(e) => {
994 warn!(file = %path.display(), error = %e, "Failed to remove cleanup file");
995 errors += 1;
996 }
997 }
998 }
999
1000 let status = if errors == 0 {
1001 StageStatus::Success
1002 } else {
1003 StageStatus::Failed
1004 };
1005
1006 StageResult {
1007 name: "Cleanup".to_string(),
1008 status,
1009 message: Some(format!("Removed {removed} files, {errors} errors")),
1010 duration_secs: start.elapsed().as_secs_f64(),
1011 }
1012}
1013
1014#[cfg(test)]
1019mod tests {
1020 use super::*;
1021 use std::fs;
1022 use std::io::Write;
1023
1024 fn make_test_dir(files: &[&str]) -> tempfile::TempDir {
1025 let dir = tempfile::tempdir().unwrap();
1026 for name in files {
1027 let path = dir.path().join(name);
1028 if let Some(parent) = path.parent() {
1029 fs::create_dir_all(parent).unwrap();
1030 }
1031 fs::write(&path, b"").unwrap();
1032 }
1033 dir
1034 }
1035
1036 #[test]
1037 fn test_post_proc_result_default() {
1038 let result = PostProcResult {
1039 success: true,
1040 stages: vec![],
1041 error: None,
1042 failure_code: None,
1043 };
1044 assert!(result.success);
1045 assert!(result.stages.is_empty());
1046 assert!(result.error.is_none());
1047 assert!(result.failure_code.is_none());
1048 }
1049
1050 #[test]
1051 fn test_config_default() {
1052 let config = PostProcConfig::default();
1053 assert!(config.cleanup_after_extract);
1054 assert!(config.output_dir.is_none());
1055 assert_eq!(config.articles_failed, 0);
1056 }
1057
1058 #[tokio::test]
1059 async fn test_pipeline_no_files() {
1060 let dir = make_test_dir(&[]);
1062 let config = PostProcConfig::default();
1063 let result = run_pipeline(dir.path(), &config).await;
1064
1065 assert!(result.success, "Pipeline should succeed for empty dir");
1066
1067 let verify_stage = result.stages.iter().find(|s| s.name == "Verify");
1069 assert!(verify_stage.is_some(), "Verify stage should be present");
1070 assert_eq!(verify_stage.unwrap().status, StageStatus::Skipped);
1071
1072 let extract_stage = result.stages.iter().find(|s| s.name == "Extract");
1073 assert!(extract_stage.is_some(), "Extract stage should be present");
1074 assert_eq!(extract_stage.unwrap().status, StageStatus::Skipped);
1075 }
1076
1077 #[tokio::test]
1078 async fn test_pipeline_only_text_files() {
1079 let dir = make_test_dir(&["readme.txt", "info.nfo"]);
1080 let config = PostProcConfig::default();
1081 let result = run_pipeline(dir.path(), &config).await;
1082
1083 assert!(result.success);
1084 for stage in &result.stages {
1086 assert_eq!(
1087 stage.status,
1088 StageStatus::Skipped,
1089 "Stage '{}' should be skipped",
1090 stage.name
1091 );
1092 }
1093 }
1094
1095 #[test]
1096 fn test_cleanup_removes_files() {
1097 let dir = make_test_dir(&[
1098 "movie.par2",
1099 "movie.vol00+01.par2",
1100 "movie.rar",
1101 "movie.r00",
1102 "movie.mkv", ]);
1104
1105 let result = run_cleanup_stage(dir.path(), &[]);
1106 assert_eq!(result.status, StageStatus::Success);
1107
1108 assert!(dir.path().join("movie.mkv").exists());
1110 assert!(!dir.path().join("movie.par2").exists());
1112 assert!(!dir.path().join("movie.vol00+01.par2").exists());
1113 assert!(!dir.path().join("movie.rar").exists());
1114 assert!(!dir.path().join("movie.r00").exists());
1115 }
1116
1117 fn write_zip(path: &Path, entries: &[(&str, &[u8])]) {
1118 let file = fs::File::create(path).unwrap();
1119 let mut writer = zip::ZipWriter::new(file);
1120 for (name, contents) in entries {
1121 writer
1122 .start_file(*name, zip::write::SimpleFileOptions::default())
1123 .unwrap();
1124 writer.write_all(contents).unwrap();
1125 }
1126 writer.finish().unwrap();
1127 }
1128
1129 #[tokio::test]
1130 async fn nested_zip_archives_are_extracted_recursively() {
1131 let source = tempfile::tempdir().unwrap();
1132 let output = tempfile::tempdir().unwrap();
1133 let inner = source.path().join("inner.zip");
1134 write_zip(&inner, &[("payload.txt", b"nested payload")]);
1135 let outer = source.path().join("outer.zip");
1136 write_zip(&outer, &[("inner.zip", &fs::read(&inner).unwrap())]);
1137 fs::remove_file(inner).unwrap();
1138
1139 let (result, _, failure_code) =
1140 run_extract_stage(source.path(), Vec::new(), output.path(), None, 1).await;
1141
1142 assert_eq!(result.status, StageStatus::Success, "{result:?}");
1143 assert_eq!(failure_code, None);
1144 assert_eq!(
1145 fs::read(output.path().join("payload.txt")).unwrap(),
1146 b"nested payload"
1147 );
1148 }
1149
1150 #[tokio::test]
1151 async fn nested_archive_depth_limit_fails_without_discarding_inner_archive() {
1152 let source = tempfile::tempdir().unwrap();
1153 let output = tempfile::tempdir().unwrap();
1154 let inner = source.path().join("inner.zip");
1155 write_zip(&inner, &[("payload.txt", b"nested payload")]);
1156 let outer = source.path().join("outer.zip");
1157 write_zip(&outer, &[("inner.zip", &fs::read(&inner).unwrap())]);
1158 fs::remove_file(inner).unwrap();
1159
1160 let (result, _, failure_code) =
1161 run_extract_stage(source.path(), Vec::new(), output.path(), None, 0).await;
1162
1163 assert_eq!(result.status, StageStatus::Failed, "{result:?}");
1164 assert_eq!(failure_code, Some(JobFailureCode::ArchiveInvalid));
1165 assert!(output.path().join("inner.zip").exists());
1166 assert!(!output.path().join("payload.txt").exists());
1167 }
1168
1169 #[tokio::test]
1170 async fn repair_pipeline_neither_extracts_nor_cleans_up() {
1171 let source = tempfile::tempdir().unwrap();
1172 let output = tempfile::tempdir().unwrap();
1173 let archive = source.path().join("release.zip");
1174 write_zip(&archive, &[("payload.txt", b"payload")]);
1175
1176 let config = PostProcConfig {
1177 output_dir: Some(output.path().to_path_buf()),
1178 ..Default::default()
1179 };
1180 let result = run_repair_pipeline(source.path(), &config, None).await;
1181
1182 assert!(result.success, "{result:?}");
1183 assert!(
1184 result
1185 .stages
1186 .iter()
1187 .all(|stage| stage.name != "Extract" && stage.name != "Cleanup"),
1188 "{:?}",
1189 result.stages
1190 );
1191 assert!(archive.exists());
1192 assert!(!output.path().join("payload.txt").exists());
1193 }
1194
1195 #[tokio::test]
1196 async fn recursive_cleanup_removes_nested_archives_from_output_directory() {
1197 let source = tempfile::tempdir().unwrap();
1198 let output = tempfile::tempdir().unwrap();
1199 let inner = source.path().join("inner.zip");
1200 write_zip(&inner, &[("payload.txt", b"nested payload")]);
1201 let outer = source.path().join("outer.zip");
1202 write_zip(&outer, &[("inner.zip", &fs::read(&inner).unwrap())]);
1203 fs::remove_file(inner).unwrap();
1204
1205 let config = PostProcConfig {
1206 output_dir: Some(output.path().to_path_buf()),
1207 ..Default::default()
1208 };
1209 let result = run_pipeline(source.path(), &config).await;
1210
1211 assert!(result.success, "{result:?}");
1212 assert_eq!(
1213 fs::read(output.path().join("payload.txt")).unwrap(),
1214 b"nested payload"
1215 );
1216 assert!(!output.path().join("inner.zip").exists());
1217 assert!(!source.path().join("outer.zip").exists());
1218 }
1219
1220 #[tokio::test]
1221 async fn cleanup_keeps_unrelated_archives_in_the_output_directory() {
1222 let source = tempfile::tempdir().unwrap();
1223 let output = tempfile::tempdir().unwrap();
1224 let inner = source.path().join("inner.zip");
1225 write_zip(&inner, &[("payload.txt", b"nested payload")]);
1226 let outer = source.path().join("outer.zip");
1227 write_zip(&outer, &[("inner.zip", &fs::read(&inner).unwrap())]);
1228 fs::remove_file(inner).unwrap();
1229 let unrelated = output.path().join("keep-me.zip");
1230 write_zip(&unrelated, &[("unrelated.txt", b"keep")]);
1231
1232 let config = PostProcConfig {
1233 output_dir: Some(output.path().to_path_buf()),
1234 ..Default::default()
1235 };
1236 let result = run_pipeline(source.path(), &config).await;
1237
1238 assert!(result.success, "{result:?}");
1239 assert!(unrelated.exists());
1240 assert!(!output.path().join("inner.zip").exists());
1241 }
1242
1243 #[tokio::test]
1244 async fn cleanup_keeps_letter_digit_payload_when_no_archive_exists() {
1245 let job_dir = tempfile::tempdir().unwrap();
1246 let output = tempfile::tempdir().unwrap();
1247 fs::write(job_dir.path().join("Game.n64"), b"rom").unwrap();
1248 fs::write(job_dir.path().join("disk.d64"), b"disk").unwrap();
1249
1250 let config = PostProcConfig {
1251 output_dir: Some(output.path().to_path_buf()),
1252 ..Default::default()
1253 };
1254 let result = run_pipeline(job_dir.path(), &config).await;
1255
1256 assert!(result.success, "{result:?}");
1257 assert_eq!(fs::read(job_dir.path().join("Game.n64")).unwrap(), b"rom");
1258 assert_eq!(fs::read(job_dir.path().join("disk.d64")).unwrap(), b"disk");
1259 }
1260
1261 #[tokio::test]
1262 async fn direct_unpack_still_extracts_nested_archives() {
1263 let job_dir = tempfile::tempdir().unwrap();
1264 let output = tempfile::tempdir().unwrap();
1265 let nested = output.path().join("nested.zip");
1266 write_zip(&nested, &[("payload.txt", b"nested payload")]);
1267
1268 let config = PostProcConfig {
1269 output_dir: Some(output.path().to_path_buf()),
1270 skip_extract: true,
1271 ..Default::default()
1272 };
1273 let result = run_pipeline(job_dir.path(), &config).await;
1274
1275 assert!(result.success, "{result:?}");
1276 assert_eq!(
1277 fs::read(output.path().join("payload.txt")).unwrap(),
1278 b"nested payload"
1279 );
1280 assert!(!nested.exists());
1281 }
1282
1283 #[tokio::test]
1284 async fn direct_unpack_still_extracts_non_rar_archives_in_job_dir() {
1285 let job_dir = tempfile::tempdir().unwrap();
1288 let output = tempfile::tempdir().unwrap();
1289 fs::write(job_dir.path().join("movie.part01.rar"), b"already unpacked").unwrap();
1290 fs::write(output.path().join("movie.mkv"), b"movie").unwrap();
1291 let subs = job_dir.path().join("Subs.zip");
1292 write_zip(&subs, &[("movie.en.srt", b"subtitles")]);
1293
1294 let config = PostProcConfig {
1295 output_dir: Some(output.path().to_path_buf()),
1296 skip_extract: true,
1297 ..Default::default()
1298 };
1299 let result = run_pipeline(job_dir.path(), &config).await;
1300
1301 assert!(result.success, "{result:?}");
1302 assert_eq!(
1303 fs::read(output.path().join("movie.en.srt")).unwrap(),
1304 b"subtitles"
1305 );
1306 assert_eq!(fs::read(output.path().join("movie.mkv")).unwrap(), b"movie");
1307 assert!(!subs.exists());
1308 }
1309
1310 #[tokio::test]
1311 async fn keep_archives_preserves_unpacked_rar_set_and_removes_par2() {
1312 let job_dir = tempfile::tempdir().unwrap();
1315 let output = tempfile::tempdir().unwrap();
1316 let volumes = ["movie.part01.rar", "movie.part02.rar", "movie.r00"];
1317 for volume in volumes {
1318 fs::write(job_dir.path().join(volume), b"Rar!\x1a\x07\x01\x00 volume").unwrap();
1319 }
1320 fs::write(job_dir.path().join("movie.par2"), b"par2").unwrap();
1321 fs::write(output.path().join("movie.mkv"), b"movie").unwrap();
1322
1323 let config = PostProcConfig {
1324 output_dir: Some(output.path().to_path_buf()),
1325 skip_extract: true,
1326 delete_archives: false,
1327 ..Default::default()
1328 };
1329 let result = run_pipeline(job_dir.path(), &config).await;
1330
1331 assert!(result.success, "{result:?}");
1332 for volume in volumes {
1333 assert!(job_dir.path().join(volume).exists(), "{volume} was deleted");
1334 }
1335 assert!(!job_dir.path().join("movie.par2").exists());
1336 assert_eq!(fs::read(output.path().join("movie.mkv")).unwrap(), b"movie");
1337 }
1338
1339 #[tokio::test]
1340 async fn keep_archives_preserves_extracted_zip_and_nested_archives() {
1341 let source = tempfile::tempdir().unwrap();
1342 let output = tempfile::tempdir().unwrap();
1343 let inner = source.path().join("inner.zip");
1344 write_zip(&inner, &[("payload.txt", b"nested payload")]);
1345 let outer = source.path().join("outer.zip");
1346 write_zip(&outer, &[("inner.zip", &fs::read(&inner).unwrap())]);
1347 fs::remove_file(inner).unwrap();
1348
1349 let config = PostProcConfig {
1350 output_dir: Some(output.path().to_path_buf()),
1351 delete_archives: false,
1352 ..Default::default()
1353 };
1354 let result = run_pipeline(source.path(), &config).await;
1355
1356 assert!(result.success, "{result:?}");
1357 assert_eq!(
1358 fs::read(output.path().join("payload.txt")).unwrap(),
1359 b"nested payload"
1360 );
1361 assert!(outer.exists());
1362 assert!(output.path().join("inner.zip").exists());
1363 }
1364
1365 #[tokio::test]
1366 async fn direct_unpack_keeps_non_rar_archive_that_fails_to_extract() {
1367 let job_dir = tempfile::tempdir().unwrap();
1368 let output = tempfile::tempdir().unwrap();
1369 fs::write(output.path().join("movie.mkv"), b"movie").unwrap();
1370 let broken = job_dir.path().join("Subs.zip");
1371 fs::write(&broken, b"not a zip").unwrap();
1372
1373 let config = PostProcConfig {
1374 output_dir: Some(output.path().to_path_buf()),
1375 skip_extract: true,
1376 ..Default::default()
1377 };
1378 let result = run_pipeline(job_dir.path(), &config).await;
1379
1380 assert!(!result.success, "{result:?}");
1381 assert!(broken.exists(), "unextracted archive must not be deleted");
1382 }
1383
1384 #[tokio::test]
1385 async fn test_pipeline_stage_order() {
1386 let dir = make_test_dir(&[]);
1389 let config = PostProcConfig {
1390 cleanup_after_extract: false,
1391 ..Default::default()
1392 };
1393 let result = run_pipeline(dir.path(), &config).await;
1394
1395 let stage_names: Vec<&str> = result.stages.iter().map(|s| s.name.as_str()).collect();
1397 assert!(stage_names.contains(&"Verify"), "Should have Verify stage");
1398 assert!(
1399 stage_names.contains(&"Extract"),
1400 "Should have Extract stage"
1401 );
1402
1403 let verify_idx = stage_names.iter().position(|&n| n == "Verify").unwrap();
1405 let extract_idx = stage_names.iter().position(|&n| n == "Extract").unwrap();
1406 assert!(
1407 verify_idx < extract_idx,
1408 "Verify ({verify_idx}) should come before Extract ({extract_idx})"
1409 );
1410 }
1411
1412 #[tokio::test]
1413 async fn test_pipeline_skips_verify_with_zero_failures() {
1414 let dir = make_test_dir(&["movie.par2", "movie.vol00+01.par2", "movie.mkv"]);
1417 let config = PostProcConfig {
1418 cleanup_after_extract: false,
1419 ..Default::default()
1420 };
1421 let result = run_pipeline(dir.path(), &config).await;
1422 assert!(result.success);
1423
1424 let verify_stage = result.stages.iter().find(|s| s.name == "Verify").unwrap();
1425 assert_eq!(
1426 verify_stage.status,
1427 StageStatus::Skipped,
1428 "Verify should be skipped when articles_failed == 0"
1429 );
1430 assert!(
1431 verify_stage
1432 .message
1433 .as_deref()
1434 .unwrap_or("")
1435 .contains("zero article failures"),
1436 "Skip message should indicate zero failures"
1437 );
1438 }
1439
1440 #[tokio::test]
1441 async fn test_pipeline_no_par2_with_content_failures_is_terminal() {
1442 let dir = make_test_dir(&["movie.mkv"]);
1445 let config = PostProcConfig {
1446 cleanup_after_extract: false,
1447 articles_failed: 5,
1448 content_articles_failed: 5,
1449 ..Default::default()
1450 };
1451 let result = run_pipeline(dir.path(), &config).await;
1452 assert!(!result.success);
1453 assert_eq!(
1454 result.failure_code,
1455 Some(JobFailureCode::ArticlesUnavailable)
1456 );
1457
1458 let verify_stage = result.stages.iter().find(|s| s.name == "Verify").unwrap();
1459 assert_eq!(
1460 verify_stage.status,
1461 StageStatus::Failed,
1462 "No-PAR content damage is unrecoverable"
1463 );
1464 assert!(
1465 verify_stage
1466 .message
1467 .as_deref()
1468 .unwrap_or("")
1469 .contains("no PAR2 recovery set"),
1470 "Failure should explain that recovery data is unavailable"
1471 );
1472 assert!(result.stages.iter().all(|stage| stage.name != "Extract"));
1473 }
1474
1475 #[tokio::test]
1476 async fn test_pipeline_runs_verify_then_repair_when_failures() {
1477 let dir = make_test_dir(&["movie.par2", "movie.vol00+01.par2", "movie.mkv"]);
1481 let config = PostProcConfig {
1482 cleanup_after_extract: false,
1483 articles_failed: 3,
1484 ..Default::default()
1485 };
1486 let result = run_pipeline(dir.path(), &config).await;
1487
1488 let stage_names: Vec<&str> = result.stages.iter().map(|s| s.name.as_str()).collect();
1490 assert!(
1491 stage_names.contains(&"Verify"),
1492 "Should have Verify stage (native par2), got: {stage_names:?}"
1493 );
1494 assert!(
1497 stage_names.contains(&"Repair"),
1498 "Should have Repair stage when articles_failed > 0, got: {stage_names:?}"
1499 );
1500 }
1501
1502 fn make_par2_file_set(tmp: &Path, files: &[(&str, &[u8])]) -> rust_par2::Par2FileSet {
1509 use rust_par2::{Par2File, Par2FileSet};
1510 let mut map = std::collections::HashMap::new();
1511 let mut file_order = Vec::with_capacity(files.len());
1512 for (i, (name, content)) in files.iter().enumerate() {
1513 let tmp_path = tmp.join(format!("_par2_tmp_{i}"));
1515 fs::write(&tmp_path, content).unwrap();
1516 let hash_16k = rust_par2::compute_hash_16k(&tmp_path).unwrap();
1517 let _ = fs::remove_file(&tmp_path);
1518
1519 let file_id = [i as u8; 16];
1520 file_order.push(file_id);
1521 map.insert(
1522 file_id,
1523 Par2File {
1524 file_id,
1525 hash: [0u8; 16],
1526 hash_16k,
1527 size: content.len() as u64,
1528 filename: name.to_string(),
1529 slices: vec![],
1530 },
1531 );
1532 }
1533 Par2FileSet {
1534 recovery_set_id: [0u8; 16],
1535 slice_size: 16384,
1536 file_order,
1537 files: map,
1538 recovery_block_count: 0,
1539 creator: None,
1540 }
1541 }
1542
1543 #[test]
1544 fn test_rename_to_par2_names_renames_mismatched() {
1545 let dir = tempfile::tempdir().unwrap();
1546
1547 let content_a = b"AAAA test data for part01";
1549 let content_b = b"BBBB test data for part02";
1550 fs::write(dir.path().join("Movie.Name.part01.rar"), content_a).unwrap();
1551 fs::write(dir.path().join("Movie.Name.part02.rar"), content_b).unwrap();
1552
1553 let file_set = make_par2_file_set(
1555 dir.path(),
1556 &[
1557 ("xY7kQ3.part01.rar", content_a),
1558 ("xY7kQ3.part02.rar", content_b),
1559 ],
1560 );
1561
1562 rename_to_par2_names(&file_set, dir.path());
1563
1564 assert!(
1566 dir.path().join("xY7kQ3.part01.rar").exists(),
1567 "part01 should be renamed to obfuscated name"
1568 );
1569 assert!(
1570 dir.path().join("xY7kQ3.part02.rar").exists(),
1571 "part02 should be renamed to obfuscated name"
1572 );
1573 assert!(
1574 !dir.path().join("Movie.Name.part01.rar").exists(),
1575 "old readable name should no longer exist"
1576 );
1577 assert!(
1578 !dir.path().join("Movie.Name.part02.rar").exists(),
1579 "old readable name should no longer exist"
1580 );
1581 }
1582
1583 #[test]
1584 fn test_rename_to_par2_names_skips_when_already_correct() {
1585 let dir = tempfile::tempdir().unwrap();
1586
1587 let content = b"test data already correct";
1589 fs::write(dir.path().join("xY7kQ3.part01.rar"), content).unwrap();
1590
1591 let file_set = make_par2_file_set(dir.path(), &[("xY7kQ3.part01.rar", content)]);
1592
1593 rename_to_par2_names(&file_set, dir.path());
1594
1595 assert!(dir.path().join("xY7kQ3.part01.rar").exists());
1597 }
1598
1599 #[test]
1600 fn test_rename_to_par2_names_skips_par2_files() {
1601 let dir = tempfile::tempdir().unwrap();
1602
1603 let content = b"par2 file content";
1605 fs::write(dir.path().join("Movie.Name.par2"), content).unwrap();
1606 fs::write(dir.path().join("Movie.Name.part01.rar"), b"rar data").unwrap();
1607
1608 let file_set = make_par2_file_set(dir.path(), &[("obfuscated.par2", content)]);
1609
1610 rename_to_par2_names(&file_set, dir.path());
1611
1612 assert!(
1614 dir.path().join("Movie.Name.par2").exists(),
1615 "PAR2 files should be skipped"
1616 );
1617 }
1618
1619 #[test]
1620 fn test_rename_to_par2_names_no_match() {
1621 let dir = tempfile::tempdir().unwrap();
1622
1623 fs::write(dir.path().join("Movie.Name.part01.rar"), b"unrelated data").unwrap();
1625
1626 let file_set = make_par2_file_set(
1627 dir.path(),
1628 &[("xY7kQ3.part01.rar", b"different data" as &[u8])],
1629 );
1630
1631 rename_to_par2_names(&file_set, dir.path());
1632
1633 assert!(dir.path().join("Movie.Name.part01.rar").exists());
1635 assert!(!dir.path().join("xY7kQ3.part01.rar").exists());
1636 }
1637}