use crate::cli::RepairArgs;
use crate::password::read_archive_path_prompting;
use crate::volumes::{infer_part_index, parse_rar3_rev_volume, path_has_extension};
use crate::{resolve_password_args, CliError, CliResult};
use rars::crc32::crc32;
use rars::Error;
use std::fs;
use std::path::{Path, PathBuf};
const RAR50_SIGNATURE: &[u8] = b"Rar!\x1a\x07\x01\x00";
pub(crate) fn cmd_repair(args: RepairArgs) -> CliResult<()> {
let mut password = resolve_password_args(&args.password)?;
let paths = args.paths;
if paths.len() > 2 {
if password.is_some() {
return Err("REV repair does not use archive passwords".into());
}
return cmd_repair_volumes(&paths);
}
let archive = read_archive_path_prompting(&paths[0], &mut password);
match archive {
Ok(archive) => {
let report = crate::write_archive_file(&paths[1], |output| {
archive
.repair_recovery_to_with_report(
output,
crate::password::password_bytes(&password),
)
.map_err(|err| {
CliError::general(format!(
"failed to repair archive '{}': {err}",
paths[0].display()
))
})
})?;
print_repair_report(&paths[1], report);
}
Err(parse_error) => {
if !path_starts_with(&paths[0], RAR50_SIGNATURE)? {
return Err(parse_error);
}
let bytes = fs::read(&paths[0])?;
let options = match crate::password::password_bytes(&password) {
Some(password) => rars::ArchiveReadOptions::with_password(password),
None => rars::ArchiveReadOptions::new(),
};
let repaired = rars::rar50::repair_inline_recovery_bytes_with_options(&bytes, options)
.map_err(|repair_error| {
format!(
"failed to parse archive '{}': {}; raw inline recovery repair also failed: {}",
paths[0].display(), parse_error, repair_error
)
})?;
let report = crate::write_archive_file(&paths[1], |output| {
output.write_all(&repaired.data)?;
Ok(repaired.report)
})?;
print_repair_report(&paths[1], report);
}
}
Ok(())
}
fn print_repair_report(path: &Path, report: rars::RecoveryRepairReport) {
let path = path.display();
let mut actions = Vec::new();
if report.data_repaired {
actions.push("repaired protected data");
}
if report.recovery_record_rebuilt {
actions.push("rebuilt recovery record");
}
if report.end_record_rebuilt {
actions.push("rebuilt archive end");
}
if actions.is_empty() {
println!("no repair needed; wrote {path}");
} else {
println!("{}; wrote {path}", actions.join(", "));
}
}
fn path_starts_with(path: &Path, prefix: &[u8]) -> CliResult<bool> {
let mut file = fs::File::open(path)?;
let mut buf = vec![0; prefix.len()];
let read = std::io::Read::read(&mut file, &mut buf)?;
Ok(read == prefix.len() && buf == prefix)
}
fn cmd_repair_volumes(paths: &[PathBuf]) -> CliResult<()> {
let input_paths = &paths[..paths.len() - 1];
for path in input_paths {
if path_has_extension(path, "rev") {
let bytes = fs::read(path)?;
if rars::rar50::Rev5Volume::parse(&bytes).is_ok() {
return cmd_repair_rev5(paths);
}
}
}
cmd_repair_rev3(paths)
}
fn cmd_repair_rev5(paths: &[PathBuf]) -> CliResult<()> {
let out_dir = PathBuf::from(paths.last().expect("outdir"));
fs::create_dir_all(&out_dir)?;
let input_paths = &paths[..paths.len() - 1];
let mut data_inputs = Vec::new();
let mut recovery = Vec::new();
for path in input_paths {
let bytes = fs::read(path)?;
match rars::rar50::Rev5Volume::parse(&bytes) {
Ok(rev) => recovery.push(rev),
Err(Error::UnsupportedSignature) => data_inputs.push((PathBuf::from(path), bytes)),
Err(error) => {
return Err(CliError::general(format!(
"failed to parse REV volume '{}': {error}",
path.display()
)))
}
}
}
let first = recovery
.first()
.ok_or("RAR 5 REV repair requires at least one .rev file")?;
let mut slots: Vec<Option<&[u8]>> = vec![None; usize::from(first.data_count)];
for (path, bytes) in &data_inputs {
if let Some(index) = infer_part_index(path, first.data_count) {
slots[index] = Some(bytes.as_slice());
continue;
}
if let Some((index, _)) =
first.data_volumes.iter().enumerate().find(|(_, meta)| {
bytes.len() as u64 == meta.file_size && crc32(bytes) == meta.crc32
})
{
slots[index] = Some(bytes.as_slice());
}
}
rars::rar50::repair_rev5_volumes_to(&slots, &recovery, |index, bytes| {
let path =
repaired_volume_path(&out_dir, &data_inputs, index, usize::from(first.data_count));
write_repaired_volume(&path, bytes)?;
println!("repaired {}", path.display());
Ok(())
})
.map_err(|err| CliError::general(format!("failed to repair RAR 5 REV volume set: {err}")))?;
Ok(())
}
fn cmd_repair_rev3(paths: &[PathBuf]) -> CliResult<()> {
let out_dir = PathBuf::from(paths.last().expect("outdir"));
fs::create_dir_all(&out_dir)?;
let input_paths = &paths[..paths.len() - 1];
let mut data_inputs = Vec::new();
let mut recovery_inputs = Vec::new();
let mut counts = None;
for path in input_paths {
let bytes = fs::read(path)?;
if path_has_extension(path, "rev") {
let (recovery_index, rec_count, dat_count, payload) =
parse_rar3_rev_volume(Path::new(path), &bytes).ok_or_else(|| {
CliError::general(format!(
"failed to parse RAR 3 REV volume name '{}'",
path.display()
))
})?;
if counts
.replace((rec_count, dat_count))
.is_some_and(|previous| previous != (rec_count, dat_count))
{
return Err("RAR 3 REV volume metadata differs across files".into());
}
recovery_inputs.push((recovery_index, payload));
} else {
data_inputs.push((PathBuf::from(path), bytes));
}
}
let (recovery_count, data_count) =
counts.ok_or("RAR 3 REV repair requires at least one .rev file")?;
let mut slots: Vec<Option<&[u8]>> = vec![None; data_count];
for (path, bytes) in &data_inputs {
if let Some(index) = infer_part_index(path, data_count as u16) {
slots[index] = Some(bytes.as_slice());
}
}
let recovery: Vec<(usize, &[u8])> = recovery_inputs
.iter()
.map(|(index, bytes)| (*index, bytes.as_slice()))
.collect();
rars::rar15_40::repair_rev3_volumes_to(&slots, recovery_count, &recovery, |index, bytes| {
let path = repaired_volume_path(&out_dir, &data_inputs, index, data_count);
write_repaired_volume(&path, bytes)?;
println!("repaired {}", path.display());
Ok(())
})
.map_err(|err| CliError::general(format!("failed to repair RAR 3 REV volume set: {err}")))?;
Ok(())
}
fn write_repaired_volume(path: &Path, bytes: &[u8]) -> rars::Result<()> {
crate::write_archive_file(path, |output| {
output.write_all(bytes)?;
Ok(())
})
.map_err(|error| Error::from(std::io::Error::other(error)))
}
fn repaired_volume_path(
out_dir: &Path,
data_inputs: &[(PathBuf, Vec<u8>)],
index: usize,
data_count: usize,
) -> PathBuf {
let Some(first_path) = data_inputs.first().map(|(path, _)| path) else {
return out_dir.join(format!("repaired.part{}.rar", index + 1));
};
let Some(file_name) = first_path.file_name().and_then(|name| name.to_str()) else {
return out_dir.join(format!("repaired.part{}.rar", index + 1));
};
let lower = file_name.to_ascii_lowercase();
if let Some(part_pos) = lower.rfind(".part") {
if lower.ends_with(".rar") {
let digits = &file_name[part_pos + ".part".len()..file_name.len() - 4];
if !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()) {
let width = digits.len().max(data_count.to_string().len());
return out_dir.join(format!(
"{}.part{:0width$}.rar",
&file_name[..part_pos],
index + 1,
width = width
));
}
}
}
if lower.ends_with(".rar")
|| lower.rsplit_once('.').is_some_and(|(_, ext)| {
ext.len() == 3
&& ext.starts_with('r')
&& ext[1..].bytes().all(|byte| byte.is_ascii_digit())
})
{
let first_out = out_dir.join(file_name);
let first_out = if lower.ends_with(".rar") {
first_out
} else {
first_out.with_extension("rar")
};
return crate::volumes::volume_part_path(&first_out, index)
.unwrap_or_else(|_| out_dir.join(format!("repaired.part{}.rar", index + 1)));
}
out_dir.join(format!("repaired.part{}.rar", index + 1))
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture(family: &str, name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../rars/tests/fixtures")
.join(family)
.join(name)
}
fn repair_set(paths: Vec<PathBuf>) -> CliResult<()> {
cmd_repair(RepairArgs {
password: crate::cli::PasswordArgs {
password: None,
password_file: None,
},
paths,
})
}
#[test]
fn legacy_repair_recovers_missing_first_part_without_name_collisions() {
let root = crate::scratch::case("repair-missing-first-old-name");
let mut paths = Vec::new();
for index in 1..4 {
let path = root.join(format!("set.r{:02}", index - 1));
fs::copy(
fixture(
"rar15_40",
&format!("rar300/rev_oldstyle.part{}.rar", index + 1),
),
&path,
)
.unwrap();
paths.push(path);
}
let rev = root.join("set_4_2_1.rev");
fs::copy(
fixture("rar15_40", "rar300/rev_oldstyle.part4_2_1.rev"),
&rev,
)
.unwrap();
paths.push(rev);
let output = root.join("out");
paths.push(output.clone());
repair_set(paths).unwrap();
for index in 0..4 {
let name = if index == 0 {
"set.rar".to_owned()
} else {
format!("set.r{:02}", index - 1)
};
assert_eq!(
fs::read(output.join(name)).unwrap(),
fs::read(fixture(
"rar15_40",
&format!("rar300/rev_oldstyle.part{}.rar", index + 1)
))
.unwrap()
);
}
assert_eq!(fs::read_dir(output).unwrap().count(), 4);
}
#[test]
fn volume_output_names_preserve_part_width_and_use_safe_fallbacks() {
let output = Path::new("out");
for (input, index, count, expected) in [
("Set.PART009.RAR", 1, 1200, "Set.part0002.rar"),
("set.partx.rar", 1, 2, "set.partx.r00"),
("set.part.rar", 0, 2, "set.part.rar"),
("set.part1.bin", 0, 2, "repaired.part1.rar"),
("set.rar", 101, 200, "repaired.part102.rar"),
("set.r01", 2, 3, "set.r01"),
("set.rXX", 0, 2, "repaired.part1.rar"),
("set.rev", 0, 2, "repaired.part1.rar"),
("set.binlong", 0, 2, "repaired.part1.rar"),
("set", 0, 2, "repaired.part1.rar"),
("/", 0, 2, "repaired.part1.rar"),
] {
assert_eq!(
repaired_volume_path(output, &[(PathBuf::from(input), Vec::new())], index, count),
output.join(expected)
);
}
assert_eq!(
repaired_volume_path(output, &[], 1, 2),
output.join("repaired.part2.rar")
);
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
let path = PathBuf::from(std::ffi::OsStr::from_bytes(b"set-\xff.rar"));
assert_eq!(
repaired_volume_path(output, &[(path, Vec::new())], 0, 2),
output.join("repaired.part1.rar")
);
}
}
#[test]
fn rev3_rejects_missing_malformed_and_conflicting_recovery_inputs() {
let root = crate::scratch::case("repair-rev3-refusals");
for (names, expected) in [
(["set.rar", "set.r00"], "requires at least one .rev"),
(
["set.rar", "bad.rev"],
"failed to parse RAR 3 REV volume name",
),
(
["set_3_2_1.rev", "set_3_3_2.rev"],
"metadata differs across files",
),
(
["set_3_2_1.rev", "set_4_2_2.rev"],
"metadata differs across files",
),
(
["set.rar", "set_3_2_1.rev"],
"failed to repair RAR 3 REV volume set",
),
(
["unknown.bin", "set_3_2_1.rev"],
"failed to repair RAR 3 REV volume set",
),
] {
let mut paths = Vec::new();
for name in names {
let path = root.join(name);
fs::write(&path, []).unwrap();
paths.push(path);
}
let output = root.join("out");
paths.push(output.clone());
assert!(repair_set(paths)
.unwrap_err()
.to_string()
.contains(expected));
assert_eq!(fs::read_dir(output).unwrap().count(), 0);
}
}
#[test]
fn rev5_identifies_renamed_parts_by_crc_and_rejects_bad_recovery() {
let root = crate::scratch::case("repair-rev5-identification");
let mut paths = Vec::new();
for index in [1, 3, 4, 5] {
let path = root.join(format!("unknown-{index}.bin"));
fs::copy(
fixture("rar50", &format!("multivol_rev.part{index}.rar")),
&path,
)
.unwrap();
paths.push(path);
}
let unknown = root.join("not-a-volume.bin");
fs::write(
&unknown,
vec![0; fs::metadata(&paths[0]).unwrap().len() as usize],
)
.unwrap();
paths.push(unknown);
let recovery = root.join("set.rev");
fs::copy(fixture("rar50", "multivol_rev.part1.rev"), &recovery).unwrap();
paths.push(recovery.clone());
let output = root.join("out");
paths.push(output.clone());
repair_set(paths.clone()).unwrap();
for index in 1..=5 {
assert_eq!(
fs::read(output.join(format!("repaired.part{index}.rar"))).unwrap(),
fs::read(fixture("rar50", &format!("multivol_rev.part{index}.rar"))).unwrap()
);
}
let bad = root.join("bad.rev");
let mut bytes = fs::read(&recovery).unwrap();
*bytes.last_mut().unwrap() ^= 0xff;
fs::write(&bad, bytes).unwrap();
paths.insert(paths.len() - 1, bad);
let error = repair_set(paths).unwrap_err().to_string();
assert!(error.contains("failed to parse REV volume"));
}
#[test]
fn repair_refuses_volume_passwords_and_missing_paths() {
let root = crate::scratch::case("repair-input-refusals");
let paths = vec![
root.join("missing.rev"),
root.join("missing.rar"),
root.join("out"),
];
let error = cmd_repair(RepairArgs {
password: crate::cli::PasswordArgs {
password: Some("secret".into()),
password_file: None,
},
paths: paths.clone(),
})
.unwrap_err();
assert!(error.to_string().contains("does not use archive passwords"));
assert!(repair_set(paths).is_err());
for data in [b"".as_slice(), b"Rar!", RAR50_SIGNATURE, b"not rar"] {
let input = root.join("input");
fs::write(&input, data).unwrap();
let output = root.join("existing");
fs::write(&output, b"keep").unwrap();
let error = repair(&input, &output).unwrap_err().to_string();
assert_eq!(
error.contains("raw inline recovery repair also failed"),
data == RAR50_SIGNATURE
);
assert_eq!(fs::read(&output).unwrap(), b"keep");
}
}
#[test]
fn raw_inline_repair_restores_headers_and_preserves_clean_output() {
let root = crate::scratch::case("repair-raw-headers");
for encrypted in [false, true] {
let mut builder = rars::Builder::new(rars::ArchiveVersion::Rar50)
.store(true)
.recovery_percent(Some(20))
.password(encrypted.then(|| b"secret".to_vec()));
builder
.add_bytes(b"file".to_vec(), vec![b'p'; 4096], None, None)
.unwrap();
let original = builder.to_bytes().unwrap();
let raw = rars::rar50::Archive::parse_with_password(
&original,
encrypted.then_some(b"secret".as_slice()),
)
.unwrap();
let mut damaged = original.clone();
damaged[raw.files().next().unwrap().block.offset] ^= 0xff;
assert!(rars::rar50::Archive::parse(&damaged).is_err());
let input = root.join("input.rar");
let output = root.join("output.rar");
fs::write(&input, &damaged).unwrap();
fs::write(&output, b"previous output").unwrap();
cmd_repair(RepairArgs {
password: crate::cli::PasswordArgs {
password: encrypted.then(|| "secret".into()),
password_file: None,
},
paths: vec![input.clone(), output.clone()],
})
.unwrap();
assert_eq!(fs::read(&output).unwrap(), original);
assert_eq!(fs::read(&input).unwrap(), damaged);
cmd_repair(RepairArgs {
password: crate::cli::PasswordArgs {
password: encrypted.then(|| "secret".into()),
password_file: None,
},
paths: vec![output, input.clone()],
})
.unwrap();
assert_eq!(fs::read(input).unwrap(), original);
}
}
#[test]
fn repair_propagates_input_and_output_filesystem_errors() {
let root = crate::scratch::case("repair-filesystem-errors");
let input = root.join("input.rar");
let output = root.join("output.rar");
fs::write(&input, b"invalid archive").unwrap();
fs::write(&output, b"keep").unwrap();
assert!(cmd_repair(RepairArgs {
password: crate::cli::PasswordArgs {
password: None,
password_file: Some(root.join("missing-password")),
},
paths: vec![input.clone(), output.clone()],
})
.is_err());
assert!(repair(&root.join("missing-input"), &output).is_err());
#[cfg(unix)]
assert!(repair(&root, &output).is_err());
assert_eq!(fs::read(&output).unwrap(), b"keep");
for (family, rev, data) in [
("rar50", "multivol_rev.part1.rev", "multivol_rev.part1.rar"),
(
"rar15_40",
"rar300/rev_oldstyle.part4_2_1.rev",
"rar300/rev_oldstyle.part1.rar",
),
] {
let paths = vec![fixture(family, rev), fixture(family, data), output.clone()];
assert!(repair_set(paths).is_err());
assert_eq!(fs::read(&output).unwrap(), b"keep");
let out_dir = root.join(family);
assert!(repair_set(vec![
fixture(family, rev),
root.join("missing-data"),
out_dir.clone()
])
.is_err());
assert_eq!(fs::read_dir(out_dir).unwrap().count(), 0);
}
}
#[test]
fn raw_repair_publication_failure_keeps_input_and_destination() {
let root = crate::scratch::case("repair-raw-publication-error");
let mut builder = rars::Builder::new(rars::ArchiveVersion::Rar50)
.store(true)
.recovery_percent(Some(20));
builder
.add_bytes(b"file".to_vec(), vec![b'p'; 4096], None, None)
.unwrap();
let mut damaged = builder.to_bytes().unwrap();
let offset = rars::rar50::Archive::parse(&damaged)
.unwrap()
.files()
.next()
.unwrap()
.block
.offset;
damaged[offset] ^= 0xff;
let input = root.join("input.rar");
fs::write(&input, &damaged).unwrap();
let output = root.join("destination");
fs::create_dir(&output).unwrap();
fs::write(output.join("keep"), b"keep").unwrap();
assert!(repair(&input, &output).is_err());
assert_eq!(fs::read(&input).unwrap(), damaged);
assert_eq!(fs::read(output.join("keep")).unwrap(), b"keep");
assert_eq!(fs::read_dir(&root).unwrap().count(), 2);
}
#[test]
fn volume_publication_failure_preserves_destinations() {
let root = crate::scratch::case("repair-volume-publication-failure");
for (family, names, first_output) in [
(
"rar50",
vec![
"multivol_rev.part1.rar",
"multivol_rev.part3.rar",
"multivol_rev.part4.rar",
"multivol_rev.part5.rar",
"multivol_rev.part1.rev",
],
"multivol_rev.part1.rar",
),
(
"rar15_40",
vec![
"rar300/rev_oldstyle.part1.rar",
"rar300/rev_oldstyle.part3.rar",
"rar300/rev_oldstyle.part4.rar",
"rar300/rev_oldstyle.part4_2_1.rev",
],
"rev_oldstyle.part1.rar",
),
] {
let out = root.join(family);
fs::create_dir(&out).unwrap();
let blocked = out.join(first_output);
fs::create_dir(&blocked).unwrap();
fs::write(blocked.join("keep"), b"keep").unwrap();
let mut paths: Vec<_> = names.iter().map(|name| fixture(family, name)).collect();
paths.push(out.clone());
let error = repair_set(paths).unwrap_err().to_string();
assert!(error.contains("failed to repair RAR"));
assert_eq!(fs::read(blocked.join("keep")).unwrap(), b"keep");
assert_eq!(fs::read_dir(out).unwrap().count(), 1);
}
let bad = root.join("no-inputs.rev");
fs::copy(fixture("rar50", "multivol_rev.part1.rev"), &bad).unwrap();
let unknown = root.join("unknown.bin");
fs::write(&unknown, b"unknown").unwrap();
assert!(repair_set(vec![bad, unknown, root.join("insufficient")])
.unwrap_err()
.to_string()
.contains("failed to repair RAR 5 REV volume set"));
}
fn repair(input: &Path, output: &Path) -> CliResult<()> {
cmd_repair(RepairArgs {
password: crate::cli::PasswordArgs {
password: None,
password_file: None,
},
paths: vec![input.to_path_buf(), output.to_path_buf()],
})
}
fn destinations(root: &Path, input: &Path) -> Vec<PathBuf> {
let output = root.join("existing.rar");
fs::write(&output, b"existing destination").unwrap();
let paths = vec![output, input.to_path_buf()];
#[cfg(unix)]
let paths = {
let mut paths = paths;
let hard = root.join("hard.rar");
fs::hard_link(input, &hard).unwrap();
let symbolic = root.join("symbolic.rar");
std::os::unix::fs::symlink(input, &symbolic).unwrap();
paths.extend([hard, symbolic]);
paths
};
paths
}
#[test]
fn repair_failure_preserves_existing_files() {
let root = crate::scratch::case("repair-preserves-failure");
let mut builder = rars::Builder::new(rars::ArchiveVersion::Rar50).store(true);
builder
.add_bytes(b"file".to_vec(), b"payload".to_vec(), None, None)
.unwrap();
let original = builder.to_bytes().unwrap();
let input = root.join("input.rar");
fs::write(&input, &original).unwrap();
for output in destinations(&root, &input) {
let previous = fs::read(&output).unwrap();
let error = repair(&input, &output).unwrap_err();
assert!(error.to_string().contains("failed to repair archive"));
assert_eq!(fs::read(&output).unwrap(), previous);
assert_eq!(fs::read(&input).unwrap(), original);
}
assert!(fs::read_dir(&*root).unwrap().all(|entry| !entry
.unwrap()
.file_name()
.to_string_lossy()
.contains(".rars-writing-")));
}
#[test]
fn repair_success_replaces_destination_without_truncating_aliases() {
let root = crate::scratch::case("repair-safe-aliases");
let mut builder = rars::Builder::new(rars::ArchiveVersion::Rar50)
.store(true)
.recovery_percent(Some(20));
builder
.add_bytes(b"file".to_vec(), vec![b'p'; 4096], None, None)
.unwrap();
let original = builder.to_bytes().unwrap();
let archive = rars::ArchiveReader::read_owned(original.clone()).unwrap();
let range = archive
.as_rar50()
.unwrap()
.files()
.next()
.unwrap()
.block
.data_range
.clone();
let mut damaged = original.clone();
damaged[range.start + 16..range.start + 32].fill(0xa5);
let input = root.join("input.rar");
fs::write(&input, &damaged).unwrap();
let paths = destinations(&root, &input);
for output in paths
.iter()
.filter(|path| **path != input)
.chain(std::iter::once(&input))
{
repair(&input, output).unwrap();
assert_eq!(fs::read(output).unwrap(), original);
if output != &input {
assert_eq!(fs::read(&input).unwrap(), damaged);
}
assert!(!fs::symlink_metadata(output)
.unwrap()
.file_type()
.is_symlink());
}
assert!(fs::read_dir(&*root).unwrap().all(|entry| !entry
.unwrap()
.file_name()
.to_string_lossy()
.contains(".rars-writing-")));
}
}