use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::time::Instant;
use nzb_core::models::{StageResult, StageStatus};
use tracing::{debug, error, info, warn};
use crate::detect::{ArchiveType, find_archives, find_cleanup_files, find_par2_files};
use crate::par2::par2_repair;
use crate::unpack::{extract_7z, extract_rar, extract_zip};
fn increment_counter(name: &'static str) {
opentelemetry::global::meter_provider()
.meter("rustnzb")
.u64_counter(name)
.build()
.add(1, &[]);
}
enum VerifyRepairOutcome {
AllCorrect {
intact_count: usize,
},
Damaged {
intact: usize,
damaged: usize,
missing: usize,
blocks_needed: u32,
blocks_available: u32,
repair_result: Result<rust_par2::RepairResult, rust_par2::RepairError>,
},
}
#[derive(Debug)]
pub struct PostProcResult {
pub success: bool,
pub stages: Vec<StageResult>,
pub error: Option<String>,
}
#[derive(Debug, Clone)]
pub struct PostProcConfig {
pub cleanup_after_extract: bool,
pub output_dir: Option<PathBuf>,
pub articles_failed: usize,
pub content_articles_failed: usize,
pub skip_extract: bool,
pub password: Option<String>,
pub max_nested_archive_depth: u8,
}
impl Default for PostProcConfig {
fn default() -> Self {
Self {
cleanup_after_extract: true,
output_dir: None,
articles_failed: 0,
content_articles_failed: 0,
skip_extract: false,
password: None,
max_nested_archive_depth: 5,
}
}
}
pub async fn run_pipeline(job_dir: &Path, config: &PostProcConfig) -> PostProcResult {
let mut stages: Vec<StageResult> = Vec::new();
let mut pipeline_ok = true;
info!(dir = %job_dir.display(), "Starting post-processing pipeline");
let par2_files = find_par2_files(job_dir);
info!(
par2_files = par2_files.len(),
"PAR2 files discovered for post-processing"
);
if par2_files.is_empty() {
if config.content_articles_failed > 0 {
pipeline_ok = false;
stages.push(StageResult {
name: "Verify".to_string(),
status: StageStatus::Failed,
message: Some(format!(
"{} content article(s) missing and no PAR2 recovery set is available",
config.content_articles_failed
)),
duration_secs: 0.0,
});
} else {
stages.push(StageResult {
name: "Verify".to_string(),
status: StageStatus::Skipped,
message: Some("No par2 files found".to_string()),
duration_secs: 0.0,
});
}
} else if config.articles_failed == 0 {
info!("Skipping PAR2 verification — zero article failures (CRC-verified)");
stages.push(StageResult {
name: "Verify".to_string(),
status: StageStatus::Skipped,
message: Some("Skipped — zero article failures".to_string()),
duration_secs: 0.0,
});
} else {
let verify_start = Instant::now();
let index_par2 = par2_files[0].clone();
match rust_par2::parse(&index_par2) {
Ok(file_set) => {
rename_to_par2_names(&file_set, job_dir);
let dir = job_dir.to_path_buf();
let verify_repair_result = tokio::task::spawn_blocking(move || {
let verify_result = rust_par2::verify(&file_set, &dir);
if verify_result.all_correct() {
VerifyRepairOutcome::AllCorrect {
intact_count: verify_result.intact.len(),
}
} else {
let intact = verify_result.intact.len();
let damaged = verify_result.damaged.len();
let missing = verify_result.missing.len();
let blocks_needed = verify_result.blocks_needed();
let blocks_available = verify_result.recovery_blocks_available;
info!(
intact,
damaged,
missing,
blocks_needed,
"Native PAR2 verify: damage detected, attempting native repair"
);
info!("Running native PAR2 repair (with pre-computed verify)");
let repair_result =
rust_par2::repair_from_verify(&file_set, &dir, &verify_result);
VerifyRepairOutcome::Damaged {
intact,
damaged,
missing,
blocks_needed,
blocks_available,
repair_result,
}
}
})
.await;
let verify_duration = verify_start.elapsed().as_secs_f64();
match verify_repair_result {
Ok(VerifyRepairOutcome::AllCorrect { intact_count }) => {
increment_counter("par2.verify_success");
info!(
files = intact_count,
duration_secs = verify_duration,
"Native PAR2 verify: all files correct"
);
stages.push(StageResult {
name: "Verify".to_string(),
status: StageStatus::Success,
message: Some(format!(
"All {intact_count} files correct (native verify, {verify_duration:.3}s)",
)),
duration_secs: verify_duration,
});
}
Ok(VerifyRepairOutcome::Damaged {
intact,
damaged,
missing,
blocks_needed,
blocks_available,
repair_result,
}) => {
stages.push(StageResult {
name: "Verify".to_string(),
status: StageStatus::Success,
message: Some(format!(
"{intact} intact, {damaged} damaged, {missing} missing — {blocks_needed} blocks needed (native verify)",
)),
duration_secs: verify_duration,
});
match repair_result {
Ok(result) => {
increment_counter(if result.success {
"par2.repair_success"
} else {
"par2.repair_failure"
});
info!(
blocks_repaired = result.blocks_repaired,
files_repaired = result.files_repaired,
"Native PAR2 repair complete"
);
if !result.success {
pipeline_ok = false;
}
stages.push(StageResult {
name: "Repair".to_string(),
status: if result.success {
StageStatus::Success
} else {
StageStatus::Failed
},
message: Some(result.message),
duration_secs: verify_duration,
});
}
Err(e) => {
increment_counter("par2.repair_failure");
error!(
error = %e,
blocks_needed,
blocks_available,
damaged,
missing,
"Native PAR2 repair failed"
);
pipeline_ok = false;
stages.push(StageResult {
name: "Repair".to_string(),
status: StageStatus::Failed,
message: Some(format!("Repair failed: {e}")),
duration_secs: verify_duration,
});
}
}
}
Err(e) => {
error!(error = %e, "Verify/repair task panicked");
pipeline_ok = false;
stages.push(StageResult {
name: "Verify".to_string(),
status: StageStatus::Failed,
message: Some(format!("Verify task panicked: {e}")),
duration_secs: verify_duration,
});
}
}
}
Err(e) => {
debug!(error = %e, "Native PAR2 parse failed");
let verify_duration = verify_start.elapsed().as_secs_f64();
if config.articles_failed == 0 {
stages.push(StageResult {
name: "Verify".to_string(),
status: StageStatus::Skipped,
message: Some(format!(
"PAR2 parse failed ({e}), but zero article failures"
)),
duration_secs: verify_duration,
});
} else {
stages.push(StageResult {
name: "Verify".to_string(),
status: StageStatus::Skipped,
message: Some(format!("PAR2 parse failed ({e}), attempting repair")),
duration_secs: verify_duration,
});
let repair_result = run_repair_stage(job_dir).await;
increment_counter(if repair_result.status == StageStatus::Failed {
"par2.repair_failure"
} else {
"par2.repair_success"
});
if repair_result.status == StageStatus::Failed {
pipeline_ok = false;
}
stages.push(repair_result);
}
}
}
}
let should_extract = pipeline_ok;
let mut extracted_archives = Vec::new();
if should_extract {
let output_dir = config.output_dir.as_deref().unwrap_or(job_dir);
let source_dir = if config.skip_extract {
info!("Outer extraction completed by direct unpack; checking for nested archives");
output_dir
} else {
job_dir
};
let (result, processed_archives) = run_extract_stage(
source_dir,
output_dir,
config.password.as_deref(),
config.max_nested_archive_depth,
)
.await;
extracted_archives = processed_archives;
if result.status == StageStatus::Failed {
pipeline_ok = false;
} else if result.status == StageStatus::Success {
pipeline_ok = true;
}
stages.push(result);
}
if pipeline_ok && config.cleanup_after_extract {
let result = run_cleanup_stage(job_dir, &extracted_archives);
stages.push(result);
}
let error = if pipeline_ok {
None
} else {
let msgs: Vec<String> = stages
.iter()
.filter(|s| s.status == StageStatus::Failed)
.filter_map(|s| s.message.clone())
.collect();
Some(msgs.join("; "))
};
info!(
success = pipeline_ok,
stages = stages.len(),
"Post-processing pipeline finished"
);
PostProcResult {
success: pipeline_ok,
stages,
error,
}
}
fn rename_to_par2_names(file_set: &rust_par2::Par2FileSet, dir: &Path) {
let mut expected: std::collections::HashMap<[u8; 16], &str> = std::collections::HashMap::new();
for par2_file in file_set.files.values() {
expected.insert(par2_file.hash_16k, &par2_file.filename);
}
let any_match = file_set
.files
.values()
.any(|f| dir.join(&f.filename).exists());
if any_match {
return;
}
let entries: Vec<_> = match std::fs::read_dir(dir) {
Ok(rd) => rd.filter_map(|e| e.ok()).collect(),
Err(_) => return,
};
let mut renamed = 0u32;
for entry in &entries {
let path = entry.path();
if !path.is_file() {
continue;
}
let current_name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
if current_name.to_lowercase().ends_with(".par2") {
continue;
}
let hash = match rust_par2::compute_hash_16k(&path) {
Ok(h) => h,
Err(_) => continue,
};
if let Some(&par2_name) = expected.get(&hash)
&& current_name != par2_name
{
let new_path = dir.join(par2_name);
if !new_path.exists() {
if let Err(e) = std::fs::rename(&path, &new_path) {
warn!(
from = %current_name,
to = %par2_name,
"Failed to rename file to PAR2 expected name: {e}"
);
} else {
renamed += 1;
debug!(
from = %current_name,
to = %par2_name,
"Renamed file to match PAR2 metadata"
);
}
}
}
}
if renamed > 0 {
info!(
renamed,
"PAR2-guided deobfuscation: renamed files to match PAR2 expected names"
);
}
}
async fn run_repair_stage(job_dir: &Path) -> StageResult {
let start = Instant::now();
let par2_files = find_par2_files(job_dir);
if par2_files.is_empty() {
return StageResult {
name: "Repair".to_string(),
status: StageStatus::Skipped,
message: Some("No par2 files found".to_string()),
duration_secs: start.elapsed().as_secs_f64(),
};
}
let index_par2 = &par2_files[0];
info!(file = %index_par2.display(), "Running native par2 repair");
match par2_repair(index_par2).await {
Ok(result) => {
let status = if result.repaired || result.success {
StageStatus::Success
} else {
StageStatus::Failed
};
StageResult {
name: "Repair".to_string(),
status,
message: Some(result.message),
duration_secs: start.elapsed().as_secs_f64(),
}
}
Err(e) => {
error!(error = %e, "par2 repair failed with error");
StageResult {
name: "Repair".to_string(),
status: StageStatus::Failed,
message: Some(format!("par2 repair error: {e}")),
duration_secs: start.elapsed().as_secs_f64(),
}
}
}
}
async fn run_extract_stage(
source_dir: &Path,
output_dir: &Path,
password: Option<&str>,
max_nested_archive_depth: u8,
) -> (StageResult, Vec<PathBuf>) {
let start = Instant::now();
let mut all_ok = true;
let mut messages: Vec<String> = Vec::new();
let mut processed: HashSet<PathBuf> = if source_dir == output_dir {
HashSet::new()
} else {
find_archives(output_dir)
.into_iter()
.map(|(_, path)| path)
.collect()
};
let mut extracted_archives = Vec::new();
let mut scan_dir = source_dir;
let mut extracted_any = false;
for depth in 0..=max_nested_archive_depth {
let archives: Vec<_> = find_archives(scan_dir)
.into_iter()
.filter(|(_, path)| processed.insert(path.clone()))
.collect();
if archives.is_empty() {
break;
}
extracted_any = true;
extracted_archives.extend(archives.iter().map(|(_, path)| path.clone()));
for (archive_type, path) in &archives {
info!(depth, kind = %archive_type, file = %path.display(), "Extracting archive");
let result = match archive_type {
ArchiveType::Rar => extract_rar(path, output_dir, password).await,
ArchiveType::SevenZip => extract_7z(path, output_dir, password).await,
ArchiveType::Zip => extract_zip(path, output_dir).await,
};
match result {
Ok(unpack_result) if unpack_result.success => {
messages.push(format!("depth {depth} {archive_type}: OK"));
}
Ok(unpack_result) => {
all_ok = false;
let detail = unpack_result
.error_output
.trim()
.lines()
.find(|line| !line.trim().is_empty());
messages.push(match detail {
Some(detail) => format!("depth {depth} {archive_type}: failed ({detail})"),
None => format!("depth {depth} {archive_type}: failed"),
});
}
Err(e) => {
all_ok = false;
error!(depth, kind = %archive_type, file = %path.display(), error = %e, "Extraction error");
messages.push(format!("depth {depth} {archive_type}: {e}"));
}
}
}
if !all_ok {
break;
}
scan_dir = output_dir;
}
if all_ok
&& !find_archives(scan_dir)
.into_iter()
.all(|(_, path)| processed.contains(&path))
{
all_ok = false;
messages.push(format!(
"nested archive depth limit ({max_nested_archive_depth}) reached; source files retained"
));
}
if !extracted_any {
info!("No archives found — skipping extraction");
return (
StageResult {
name: "Extract".to_string(),
status: StageStatus::Skipped,
message: Some("No archives found".to_string()),
duration_secs: start.elapsed().as_secs_f64(),
},
Vec::new(),
);
}
(
StageResult {
name: "Extract".to_string(),
status: if all_ok {
StageStatus::Success
} else {
StageStatus::Failed
},
message: Some(messages.join("; ")),
duration_secs: start.elapsed().as_secs_f64(),
},
extracted_archives,
)
}
fn run_cleanup_stage(job_dir: &Path, extracted_archives: &[PathBuf]) -> StageResult {
let start = Instant::now();
let mut files = find_cleanup_files(job_dir);
files.extend(
extracted_archives
.iter()
.filter(|path| path.is_file())
.cloned(),
);
files.sort();
files.dedup();
if files.is_empty() {
return StageResult {
name: "Cleanup".to_string(),
status: StageStatus::Skipped,
message: Some("No files to clean up".to_string()),
duration_secs: start.elapsed().as_secs_f64(),
};
}
let mut removed = 0u32;
let mut errors = 0u32;
for path in &files {
match std::fs::remove_file(path) {
Ok(()) => {
removed += 1;
}
Err(e) => {
warn!(file = %path.display(), error = %e, "Failed to remove cleanup file");
errors += 1;
}
}
}
let status = if errors == 0 {
StageStatus::Success
} else {
StageStatus::Failed
};
StageResult {
name: "Cleanup".to_string(),
status,
message: Some(format!("Removed {removed} files, {errors} errors")),
duration_secs: start.elapsed().as_secs_f64(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::io::Write;
fn make_test_dir(files: &[&str]) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
for name in files {
let path = dir.path().join(name);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, b"").unwrap();
}
dir
}
#[test]
fn test_post_proc_result_default() {
let result = PostProcResult {
success: true,
stages: vec![],
error: None,
};
assert!(result.success);
assert!(result.stages.is_empty());
assert!(result.error.is_none());
}
#[test]
fn test_config_default() {
let config = PostProcConfig::default();
assert!(config.cleanup_after_extract);
assert!(config.output_dir.is_none());
assert_eq!(config.articles_failed, 0);
}
#[tokio::test]
async fn test_pipeline_no_files() {
let dir = make_test_dir(&[]);
let config = PostProcConfig::default();
let result = run_pipeline(dir.path(), &config).await;
assert!(result.success, "Pipeline should succeed for empty dir");
let verify_stage = result.stages.iter().find(|s| s.name == "Verify");
assert!(verify_stage.is_some(), "Verify stage should be present");
assert_eq!(verify_stage.unwrap().status, StageStatus::Skipped);
let extract_stage = result.stages.iter().find(|s| s.name == "Extract");
assert!(extract_stage.is_some(), "Extract stage should be present");
assert_eq!(extract_stage.unwrap().status, StageStatus::Skipped);
}
#[tokio::test]
async fn test_pipeline_only_text_files() {
let dir = make_test_dir(&["readme.txt", "info.nfo"]);
let config = PostProcConfig::default();
let result = run_pipeline(dir.path(), &config).await;
assert!(result.success);
for stage in &result.stages {
assert_eq!(
stage.status,
StageStatus::Skipped,
"Stage '{}' should be skipped",
stage.name
);
}
}
#[test]
fn test_cleanup_removes_files() {
let dir = make_test_dir(&[
"movie.par2",
"movie.vol00+01.par2",
"movie.rar",
"movie.r00",
"movie.mkv", ]);
let result = run_cleanup_stage(dir.path(), &[]);
assert_eq!(result.status, StageStatus::Success);
assert!(dir.path().join("movie.mkv").exists());
assert!(!dir.path().join("movie.par2").exists());
assert!(!dir.path().join("movie.vol00+01.par2").exists());
assert!(!dir.path().join("movie.rar").exists());
assert!(!dir.path().join("movie.r00").exists());
}
fn write_zip(path: &Path, entries: &[(&str, &[u8])]) {
let file = fs::File::create(path).unwrap();
let mut writer = zip::ZipWriter::new(file);
for (name, contents) in entries {
writer
.start_file(*name, zip::write::SimpleFileOptions::default())
.unwrap();
writer.write_all(contents).unwrap();
}
writer.finish().unwrap();
}
#[tokio::test]
async fn nested_zip_archives_are_extracted_recursively() {
let source = tempfile::tempdir().unwrap();
let output = tempfile::tempdir().unwrap();
let inner = source.path().join("inner.zip");
write_zip(&inner, &[("payload.txt", b"nested payload")]);
let outer = source.path().join("outer.zip");
write_zip(&outer, &[("inner.zip", &fs::read(&inner).unwrap())]);
fs::remove_file(inner).unwrap();
let (result, _) = run_extract_stage(source.path(), output.path(), None, 1).await;
assert_eq!(result.status, StageStatus::Success, "{result:?}");
assert_eq!(
fs::read(output.path().join("payload.txt")).unwrap(),
b"nested payload"
);
}
#[tokio::test]
async fn nested_archive_depth_limit_fails_without_discarding_inner_archive() {
let source = tempfile::tempdir().unwrap();
let output = tempfile::tempdir().unwrap();
let inner = source.path().join("inner.zip");
write_zip(&inner, &[("payload.txt", b"nested payload")]);
let outer = source.path().join("outer.zip");
write_zip(&outer, &[("inner.zip", &fs::read(&inner).unwrap())]);
fs::remove_file(inner).unwrap();
let (result, _) = run_extract_stage(source.path(), output.path(), None, 0).await;
assert_eq!(result.status, StageStatus::Failed, "{result:?}");
assert!(output.path().join("inner.zip").exists());
assert!(!output.path().join("payload.txt").exists());
}
#[tokio::test]
async fn recursive_cleanup_removes_nested_archives_from_output_directory() {
let source = tempfile::tempdir().unwrap();
let output = tempfile::tempdir().unwrap();
let inner = source.path().join("inner.zip");
write_zip(&inner, &[("payload.txt", b"nested payload")]);
let outer = source.path().join("outer.zip");
write_zip(&outer, &[("inner.zip", &fs::read(&inner).unwrap())]);
fs::remove_file(inner).unwrap();
let config = PostProcConfig {
output_dir: Some(output.path().to_path_buf()),
..Default::default()
};
let result = run_pipeline(source.path(), &config).await;
assert!(result.success, "{result:?}");
assert_eq!(
fs::read(output.path().join("payload.txt")).unwrap(),
b"nested payload"
);
assert!(!output.path().join("inner.zip").exists());
assert!(!source.path().join("outer.zip").exists());
}
#[tokio::test]
async fn cleanup_keeps_unrelated_archives_in_the_output_directory() {
let source = tempfile::tempdir().unwrap();
let output = tempfile::tempdir().unwrap();
let inner = source.path().join("inner.zip");
write_zip(&inner, &[("payload.txt", b"nested payload")]);
let outer = source.path().join("outer.zip");
write_zip(&outer, &[("inner.zip", &fs::read(&inner).unwrap())]);
fs::remove_file(inner).unwrap();
let unrelated = output.path().join("keep-me.zip");
write_zip(&unrelated, &[("unrelated.txt", b"keep")]);
let config = PostProcConfig {
output_dir: Some(output.path().to_path_buf()),
..Default::default()
};
let result = run_pipeline(source.path(), &config).await;
assert!(result.success, "{result:?}");
assert!(unrelated.exists());
assert!(!output.path().join("inner.zip").exists());
}
#[tokio::test]
async fn direct_unpack_still_extracts_nested_archives() {
let job_dir = tempfile::tempdir().unwrap();
let output = tempfile::tempdir().unwrap();
let nested = output.path().join("nested.zip");
write_zip(&nested, &[("payload.txt", b"nested payload")]);
let config = PostProcConfig {
output_dir: Some(output.path().to_path_buf()),
skip_extract: true,
..Default::default()
};
let result = run_pipeline(job_dir.path(), &config).await;
assert!(result.success, "{result:?}");
assert_eq!(
fs::read(output.path().join("payload.txt")).unwrap(),
b"nested payload"
);
assert!(!nested.exists());
}
#[tokio::test]
async fn test_pipeline_stage_order() {
let dir = make_test_dir(&[]);
let config = PostProcConfig {
cleanup_after_extract: false,
..Default::default()
};
let result = run_pipeline(dir.path(), &config).await;
let stage_names: Vec<&str> = result.stages.iter().map(|s| s.name.as_str()).collect();
assert!(stage_names.contains(&"Verify"), "Should have Verify stage");
assert!(
stage_names.contains(&"Extract"),
"Should have Extract stage"
);
let verify_idx = stage_names.iter().position(|&n| n == "Verify").unwrap();
let extract_idx = stage_names.iter().position(|&n| n == "Extract").unwrap();
assert!(
verify_idx < extract_idx,
"Verify ({verify_idx}) should come before Extract ({extract_idx})"
);
}
#[tokio::test]
async fn test_pipeline_skips_verify_with_zero_failures() {
let dir = make_test_dir(&["movie.par2", "movie.vol00+01.par2", "movie.mkv"]);
let config = PostProcConfig {
cleanup_after_extract: false,
..Default::default()
};
let result = run_pipeline(dir.path(), &config).await;
assert!(result.success);
let verify_stage = result.stages.iter().find(|s| s.name == "Verify").unwrap();
assert_eq!(
verify_stage.status,
StageStatus::Skipped,
"Verify should be skipped when articles_failed == 0"
);
assert!(
verify_stage
.message
.as_deref()
.unwrap_or("")
.contains("zero article failures"),
"Skip message should indicate zero failures"
);
}
#[tokio::test]
async fn test_pipeline_no_par2_with_content_failures_is_terminal() {
let dir = make_test_dir(&["movie.mkv"]);
let config = PostProcConfig {
cleanup_after_extract: false,
articles_failed: 5,
content_articles_failed: 5,
..Default::default()
};
let result = run_pipeline(dir.path(), &config).await;
assert!(!result.success);
let verify_stage = result.stages.iter().find(|s| s.name == "Verify").unwrap();
assert_eq!(
verify_stage.status,
StageStatus::Failed,
"No-PAR content damage is unrecoverable"
);
assert!(
verify_stage
.message
.as_deref()
.unwrap_or("")
.contains("no PAR2 recovery set"),
"Failure should explain that recovery data is unavailable"
);
assert!(result.stages.iter().all(|stage| stage.name != "Extract"));
}
#[tokio::test]
async fn test_pipeline_runs_verify_then_repair_when_failures() {
let dir = make_test_dir(&["movie.par2", "movie.vol00+01.par2", "movie.mkv"]);
let config = PostProcConfig {
cleanup_after_extract: false,
articles_failed: 3,
..Default::default()
};
let result = run_pipeline(dir.path(), &config).await;
let stage_names: Vec<&str> = result.stages.iter().map(|s| s.name.as_str()).collect();
assert!(
stage_names.contains(&"Verify"),
"Should have Verify stage (native par2), got: {stage_names:?}"
);
assert!(
stage_names.contains(&"Repair"),
"Should have Repair stage when articles_failed > 0, got: {stage_names:?}"
);
}
fn make_par2_file_set(tmp: &Path, files: &[(&str, &[u8])]) -> rust_par2::Par2FileSet {
use rust_par2::{Par2File, Par2FileSet};
let mut map = std::collections::HashMap::new();
let mut file_order = Vec::with_capacity(files.len());
for (i, (name, content)) in files.iter().enumerate() {
let tmp_path = tmp.join(format!("_par2_tmp_{i}"));
fs::write(&tmp_path, content).unwrap();
let hash_16k = rust_par2::compute_hash_16k(&tmp_path).unwrap();
let _ = fs::remove_file(&tmp_path);
let file_id = [i as u8; 16];
file_order.push(file_id);
map.insert(
file_id,
Par2File {
file_id,
hash: [0u8; 16],
hash_16k,
size: content.len() as u64,
filename: name.to_string(),
slices: vec![],
},
);
}
Par2FileSet {
recovery_set_id: [0u8; 16],
slice_size: 16384,
file_order,
files: map,
recovery_block_count: 0,
creator: None,
}
}
#[test]
fn test_rename_to_par2_names_renames_mismatched() {
let dir = tempfile::tempdir().unwrap();
let content_a = b"AAAA test data for part01";
let content_b = b"BBBB test data for part02";
fs::write(dir.path().join("Movie.Name.part01.rar"), content_a).unwrap();
fs::write(dir.path().join("Movie.Name.part02.rar"), content_b).unwrap();
let file_set = make_par2_file_set(
dir.path(),
&[
("xY7kQ3.part01.rar", content_a),
("xY7kQ3.part02.rar", content_b),
],
);
rename_to_par2_names(&file_set, dir.path());
assert!(
dir.path().join("xY7kQ3.part01.rar").exists(),
"part01 should be renamed to obfuscated name"
);
assert!(
dir.path().join("xY7kQ3.part02.rar").exists(),
"part02 should be renamed to obfuscated name"
);
assert!(
!dir.path().join("Movie.Name.part01.rar").exists(),
"old readable name should no longer exist"
);
assert!(
!dir.path().join("Movie.Name.part02.rar").exists(),
"old readable name should no longer exist"
);
}
#[test]
fn test_rename_to_par2_names_skips_when_already_correct() {
let dir = tempfile::tempdir().unwrap();
let content = b"test data already correct";
fs::write(dir.path().join("xY7kQ3.part01.rar"), content).unwrap();
let file_set = make_par2_file_set(dir.path(), &[("xY7kQ3.part01.rar", content)]);
rename_to_par2_names(&file_set, dir.path());
assert!(dir.path().join("xY7kQ3.part01.rar").exists());
}
#[test]
fn test_rename_to_par2_names_skips_par2_files() {
let dir = tempfile::tempdir().unwrap();
let content = b"par2 file content";
fs::write(dir.path().join("Movie.Name.par2"), content).unwrap();
fs::write(dir.path().join("Movie.Name.part01.rar"), b"rar data").unwrap();
let file_set = make_par2_file_set(dir.path(), &[("obfuscated.par2", content)]);
rename_to_par2_names(&file_set, dir.path());
assert!(
dir.path().join("Movie.Name.par2").exists(),
"PAR2 files should be skipped"
);
}
#[test]
fn test_rename_to_par2_names_no_match() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("Movie.Name.part01.rar"), b"unrelated data").unwrap();
let file_set = make_par2_file_set(
dir.path(),
&[("xY7kQ3.part01.rar", b"different data" as &[u8])],
);
rename_to_par2_names(&file_set, dir.path());
assert!(dir.path().join("Movie.Name.part01.rar").exists());
assert!(!dir.path().join("xY7kQ3.part01.rar").exists());
}
}