#[path = "support/scratch.rs"]
mod scratch;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
const PASSWORD: &str = "matrix-secret";
fn fixtures(relative: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../rars/tests/fixtures")
.join(relative)
}
struct Noise(u64);
impl Noise {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
fn bytes(&mut self, len: usize) -> Vec<u8> {
(0..len).map(|_| (self.next() >> 24) as u8).collect()
}
}
fn mixed_payload(len: usize) -> Vec<u8> {
let sources = [
fs::read(fixtures("rar15_40/ppmd/binary_64k.bin")).unwrap(),
fs::read(fixtures("rar15_40/ppmd/escape_64k.bin")).unwrap(),
fs::read(fixtures("rar15_40/ppmd/lorem_127k.txt")).unwrap(),
];
let mut noise = Noise(0x2545_F491_4F6C_DD1D);
let mut out = Vec::with_capacity(len);
let mut round = 0usize;
while out.len() < len {
let source = &sources[round % sources.len()];
let offset = (noise.next() % (source.len() - 4096) as u64) as usize;
out.extend_from_slice(&source[offset..offset + 4096]);
out.extend_from_slice(&noise.bytes(1024));
round += 1;
}
out.truncate(len);
out
}
#[derive(Clone, Copy, PartialEq)]
enum Inputs {
Standard,
PpmdStress,
Single,
MultiBlock,
}
impl Inputs {
fn files(self) -> Vec<(&'static str, Vec<u8>)> {
match self {
Inputs::Standard => {
let text = fs::read(fixtures("rar15_40/ppmd/lorem_127k.txt")).unwrap();
let code = fs::read(fixtures("rar15_40/ppmd/binary_64k.bin")).unwrap();
vec![
("text.txt", text[..49_152].to_vec()),
("code.bin", code),
("noise.bin", Noise(0x9E37_79B9_7F4A_7C15).bytes(16_384)),
]
}
Inputs::PpmdStress => vec![("mixed.bin", mixed_payload(320 * 1024))],
Inputs::Single => vec![("one.bin", mixed_payload(192 * 1024))],
Inputs::MultiBlock => vec![("wide.bin", mixed_payload(512 * 1024))],
}
}
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum Flavour {
Unrar,
SevenZip,
}
struct Decoder {
program: &'static str,
flavour: Flavour,
}
impl Decoder {
fn command(&self, password: Option<&str>) -> Command {
let mut command = Command::new(self.program);
command.stdin(Stdio::null());
match self.flavour {
Flavour::Unrar => {
command.arg(match password {
Some(password) => format!("-p{password}"),
None => "-p-".to_string(),
});
}
Flavour::SevenZip => {
command.args(["-bso0", "-bsp0"]);
command.arg(format!("-p{}", password.unwrap_or_default()));
}
}
command
}
fn test(&self, archive: &Path, password: Option<&str>) -> Output {
let mut command = self.command(password);
command.arg("t").arg(archive);
command.output().unwrap()
}
fn extract(&self, archive: &Path, into: &Path, password: Option<&str>) -> Output {
let mut command = self.command(password);
command.arg("x").arg("-y");
match self.flavour {
Flavour::Unrar => {
command.arg(archive).arg(format!("{}/", into.display()));
}
Flavour::SevenZip => {
command.arg(format!("-o{}", into.display())).arg(archive);
}
}
command.output().unwrap()
}
fn banner(&self) -> String {
let output = Command::new(self.program)
.stdin(Stdio::null())
.output()
.map(|output| {
let text = String::from_utf8_lossy(&output.stdout).into_owned();
if text.trim().is_empty() {
String::from_utf8_lossy(&output.stderr).into_owned()
} else {
text
}
})
.unwrap_or_default();
output
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or("version unknown")
.to_string()
}
fn is_installed(&self) -> bool {
match Command::new(self.program)
.arg("--help")
.stdin(Stdio::null())
.output()
{
Ok(_) => true,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
Err(error) => panic!("failed to run {}: {error}", self.program),
}
}
}
const DECODERS: &[Decoder] = &[
Decoder {
program: "unrar",
flavour: Flavour::Unrar,
},
Decoder {
program: "rar",
flavour: Flavour::Unrar,
},
Decoder {
program: "7zz",
flavour: Flavour::SevenZip,
},
];
fn vendor_archive(format: &str) -> PathBuf {
fixtures(match format {
"rar14" => "rar13/README.RAR",
"rar15" => "rar15_40/rar154/readme_154_normal.rar",
"rar20" => "rar15_40/rar250/BIGLZ.RAR",
"rar29" | "rar30" | "rar40" => "rar15_40/ppmd/ppmd_lorem_rar300.rar",
"rar50" | "rar70" => "rar50/m3_default.rar",
other => panic!("no vendor archive for {other}"),
})
}
fn calibrated(format: &str) -> Vec<&'static Decoder> {
let vendor = vendor_archive(format);
DECODERS
.iter()
.filter(|decoder| decoder.is_installed())
.filter(|decoder| {
let output = decoder.test(&vendor, None);
if !output.status.success() {
eprintln!(
"{format}: ignoring {} — it cannot test WinRAR's own {}",
decoder.program,
vendor.file_name().unwrap().to_string_lossy()
);
}
output.status.success()
})
.inspect(|decoder| {
eprintln!(
"{format}: judged by {} — {}",
decoder.program,
decoder.banner()
);
})
.collect()
}
#[derive(Clone, Copy, PartialEq)]
enum Judge {
Everything,
ExtractedBytes,
}
struct Cell {
name: &'static str,
flags: &'static [&'static str],
inputs: Inputs,
formats: &'static [&'static str],
judge: Judge,
}
const ALL: &[&str] = &[
"rar14", "rar15", "rar20", "rar29", "rar30", "rar40", "rar50", "rar70",
];
const FILTERED: &[&str] = &["rar29", "rar30", "rar40"];
const MODERN: &[&str] = &["rar50", "rar70"];
const CELLS: &[Cell] = &[
Cell {
name: "store",
flags: &["--store"],
inputs: Inputs::Standard,
formats: ALL,
judge: Judge::Everything,
},
Cell {
name: "level-1",
flags: &["--level", "1"],
inputs: Inputs::Standard,
formats: ALL,
judge: Judge::Everything,
},
Cell {
name: "level-5",
flags: &["--level", "5"],
inputs: Inputs::Standard,
formats: ALL,
judge: Judge::Everything,
},
Cell {
name: "level-5-solid",
flags: &["--level", "5", "--solid"],
inputs: Inputs::Standard,
formats: ALL,
judge: Judge::Everything,
},
Cell {
name: "level-5-no-filter",
flags: &["--level", "5", "--no-filter"],
inputs: Inputs::Standard,
formats: ALL,
judge: Judge::Everything,
},
Cell {
name: "dict-4m",
flags: &["--level", "5", "--dict-size", "4m"],
inputs: Inputs::Standard,
formats: &[
"rar15", "rar20", "rar29", "rar30", "rar40", "rar50", "rar70",
],
judge: Judge::Everything,
},
Cell {
name: "comment",
flags: &["--level", "5", "--comment", "an archive comment"],
inputs: Inputs::Standard,
formats: ALL,
judge: Judge::Everything,
},
Cell {
name: "file-comment",
flags: &["--level", "5", "--file-comment", "a file comment"],
inputs: Inputs::Standard,
formats: &["rar14", "rar50", "rar70"],
judge: Judge::Everything,
},
Cell {
name: "file-comment-old-style",
flags: &["--level", "5", "--file-comment", "a file comment"],
inputs: Inputs::Standard,
formats: &["rar15", "rar20", "rar29"],
judge: Judge::ExtractedBytes,
},
Cell {
name: "store-encrypted",
flags: &["--store", "-p", PASSWORD],
inputs: Inputs::Standard,
formats: ALL,
judge: Judge::Everything,
},
Cell {
name: "level-5-encrypted",
flags: &["--level", "5", "-p", PASSWORD],
inputs: Inputs::Standard,
formats: ALL,
judge: Judge::Everything,
},
Cell {
name: "level-5-solid-encrypted",
flags: &["--level", "5", "--solid", "-p", PASSWORD],
inputs: Inputs::Standard,
formats: ALL,
judge: Judge::Everything,
},
Cell {
name: "encrypted-headers",
flags: &["--level", "5", "-p", PASSWORD, "--encrypt-headers"],
inputs: Inputs::Standard,
formats: &["rar30", "rar40", "rar50", "rar70"],
judge: Judge::Everything,
},
Cell {
name: "auto-filter",
flags: &["--level", "5", "--auto-filter"],
inputs: Inputs::Standard,
formats: &["rar29", "rar30", "rar40", "rar50", "rar70"],
judge: Judge::Everything,
},
Cell {
name: "delta-filter",
flags: &["--level", "5", "--delta-filter", "4"],
inputs: Inputs::Standard,
formats: &["rar29", "rar30", "rar40", "rar50", "rar70"],
judge: Judge::Everything,
},
Cell {
name: "e8-filter",
flags: &["--level", "5", "--e8-filter"],
inputs: Inputs::Standard,
formats: &["rar29", "rar30", "rar40", "rar50", "rar70"],
judge: Judge::Everything,
},
Cell {
name: "e8e9-filter",
flags: &["--level", "5", "--e8e9-filter"],
inputs: Inputs::Standard,
formats: &["rar29", "rar30", "rar40", "rar50", "rar70"],
judge: Judge::Everything,
},
Cell {
name: "itanium-filter",
flags: &["--level", "5", "--itanium-filter"],
inputs: Inputs::Standard,
formats: FILTERED,
judge: Judge::Everything,
},
Cell {
name: "rgb-filter",
flags: &["--level", "5", "--rgb-filter", "1920"],
inputs: Inputs::Standard,
formats: FILTERED,
judge: Judge::Everything,
},
Cell {
name: "audio-filter",
flags: &["--level", "5", "--audio-filter", "2"],
inputs: Inputs::Standard,
formats: FILTERED,
judge: Judge::Everything,
},
Cell {
name: "arm-filter",
flags: &["--level", "5", "--arm-filter"],
inputs: Inputs::Standard,
formats: MODERN,
judge: Judge::Everything,
},
Cell {
name: "filter-solid",
flags: &["--level", "5", "--solid", "--e8e9-filter"],
inputs: Inputs::Standard,
formats: FILTERED,
judge: Judge::Everything,
},
Cell {
name: "ppmd",
flags: &["--ppmd"],
inputs: Inputs::Standard,
formats: FILTERED,
judge: Judge::Everything,
},
Cell {
name: "ppmd-solid",
flags: &["--ppmd", "--solid"],
inputs: Inputs::Standard,
formats: FILTERED,
judge: Judge::Everything,
},
Cell {
name: "ppmd-mixed",
flags: &["--ppmd"],
inputs: Inputs::PpmdStress,
formats: FILTERED,
judge: Judge::Everything,
},
Cell {
name: "ppmd-mixed-encrypted",
flags: &["--ppmd", "-p", PASSWORD],
inputs: Inputs::PpmdStress,
formats: FILTERED,
judge: Judge::Everything,
},
Cell {
name: "quick-open",
flags: &["--level", "5", "--quick-open"],
inputs: Inputs::Standard,
formats: MODERN,
judge: Judge::Everything,
},
Cell {
name: "recovery-record",
flags: &["--level", "5", "--recovery-percent", "5"],
inputs: Inputs::Standard,
formats: MODERN,
judge: Judge::Everything,
},
Cell {
name: "archive-name",
flags: &["--level", "5", "--archive-name", "inner.rar"],
inputs: Inputs::Standard,
formats: MODERN,
judge: Judge::Everything,
},
Cell {
name: "multi-block",
flags: &["--level", "5", "--no-filter"],
inputs: Inputs::MultiBlock,
formats: ALL,
judge: Judge::Everything,
},
Cell {
name: "multi-block-filtered",
flags: &["--level", "5", "--e8e9-filter"],
inputs: Inputs::MultiBlock,
formats: &["rar29", "rar30", "rar40", "rar50", "rar70"],
judge: Judge::Everything,
},
Cell {
name: "volumes",
flags: &["--level", "5", "--volume-size", "64k"],
inputs: Inputs::Single,
formats: ALL,
judge: Judge::Everything,
},
Cell {
name: "volumes-encrypted",
flags: &["--level", "5", "--volume-size", "64k", "-p", PASSWORD],
inputs: Inputs::Single,
formats: &[
"rar15", "rar20", "rar29", "rar30", "rar40", "rar50", "rar70",
],
judge: Judge::Everything,
},
];
fn password_of(cell: &Cell) -> Option<&'static str> {
cell.flags
.iter()
.position(|flag| *flag == "-p")
.map(|index| cell.flags[index + 1])
}
struct Limitation {
decoder: Flavour,
formats: &'static [&'static str],
cells: &'static [&'static str],
proof: &'static str,
}
const LIMITATIONS: &[Limitation] = &[Limitation {
decoder: Flavour::SevenZip,
formats: &["rar15", "rar20"],
cells: &[
"store-encrypted",
"level-5-encrypted",
"level-5-solid-encrypted",
"volumes-encrypted",
],
proof: "rar15_40/rar154/readme_154_password.rar",
}];
struct Known {
formats: &'static [&'static str],
cells: &'static [&'static str],
decoder: &'static str,
task: &'static str,
}
const KNOWN_BAD: &[Known] = &[];
impl Known {
fn matches(&self, format: &str, cell: &str, decoder: &str) -> bool {
self.decoder == decoder && self.formats.contains(&format) && self.cells.contains(&cell)
}
}
fn workspace(label: &str) -> scratch::Scratch {
scratch::case(&format!("rars-matrix-{label}"))
}
fn first_volume(directory: &Path, stem: &str) -> PathBuf {
let single = directory.join(format!("{stem}.rar"));
if single.exists() {
return single;
}
let mut parts: Vec<PathBuf> = fs::read_dir(directory)
.unwrap()
.map(|entry| entry.unwrap().path())
.filter(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with(&format!("{stem}.part")))
})
.collect();
parts.sort();
parts
.into_iter()
.next()
.unwrap_or_else(|| panic!("the writer produced no archive for {stem}"))
}
fn run_matrix(format: &str) {
let decoders = calibrated(format);
if decoders.is_empty() {
let message = format!(
"no external decoder can read {format}: install unrar, or the official 7zz from \
github.com/ip7z/7zip (a distribution 7zip package usually ships without the RAR \
decompressor)"
);
assert!(
std::env::var_os("RARS_REQUIRE_EXTERNAL_DECODERS").is_none(),
"{message}"
);
let _ = writeln!(
std::io::stderr(),
"SKIPPED the {format} write matrix: {message}"
);
return;
}
let mut failures = Vec::new();
for cell in CELLS.iter().filter(|cell| cell.formats.contains(&format)) {
let workspace = workspace(&format!("{format}-{}", cell.name));
let files = cell.inputs.files();
for (name, bytes) in &files {
fs::write(workspace.join(name), bytes).unwrap();
}
let mut command = Command::new(env!("CARGO_BIN_EXE_rars"));
command
.current_dir(&workspace)
.stdin(Stdio::null())
.arg("add")
.args(["--format", format])
.args(cell.flags)
.args(["--progress", "never"])
.arg("out.rar")
.args(files.iter().map(|(name, _)| *name));
let written = command.output().unwrap();
assert!(
written.status.success(),
"{format}/{}: the writer refused a combination the matrix claims it supports\n{}",
cell.name,
String::from_utf8_lossy(&written.stderr)
);
let archive = first_volume(&workspace, "out");
let password = password_of(cell);
for decoder in &decoders {
if LIMITATIONS.iter().any(|limit| {
limit.decoder == decoder.flavour
&& limit.formats.contains(&format)
&& limit.cells.contains(&cell.name)
}) {
continue;
}
let verdict = judge(decoder, cell, &archive, password, &files);
let known = KNOWN_BAD
.iter()
.find(|known| known.matches(format, cell.name, decoder.program));
match (verdict, known) {
(Ok(()), None) => {}
(Err(complaint), None) => failures.push(format!(
"{format}/{} under {}: {complaint}",
cell.name, decoder.program
)),
(Err(_), Some(_)) => {}
(Ok(()), Some(known)) => failures.push(format!(
"{format}/{} under {}: it passes now. Delete its KNOWN_BAD row, and {} with it \
if that was the last one.",
cell.name, decoder.program, known.task
)),
}
}
}
assert!(
failures.is_empty(),
"{} of {} {format} archives did not survive an external decoder:\n\n{}",
failures.len(),
CELLS.iter().filter(|c| c.formats.contains(&format)).count(),
failures.join("\n\n")
);
}
fn judge(
decoder: &Decoder,
cell: &Cell,
archive: &Path,
password: Option<&str>,
files: &[(&'static str, Vec<u8>)],
) -> Result<(), String> {
if cell.judge == Judge::Everything {
let tested = decoder.test(archive, password);
if !tested.status.success() {
return Err(format!(
"it rejected the archive\n{}",
indent(&tested.stdout, &tested.stderr)
));
}
}
let extracted = archive
.parent()
.unwrap()
.join(format!("x-{}", decoder.program));
fs::create_dir_all(&extracted).unwrap();
let unpacked = decoder.extract(archive, &extracted, password);
if !unpacked.status.success() && cell.judge == Judge::Everything {
return Err(format!(
"it failed to extract\n{}",
indent(&unpacked.stdout, &unpacked.stderr)
));
}
for (name, expected) in files {
let actual = match fs::read(extracted.join(name)) {
Ok(actual) => actual,
Err(error) => return Err(format!("it did not write {name}: {error}")),
};
if actual.len() != expected.len() {
return Err(format!(
"it extracted {name} at {} bytes, not {}",
actual.len(),
expected.len()
));
}
if let Some(offset) = actual
.iter()
.zip(expected)
.position(|(actual, expected)| actual != expected)
{
return Err(format!("it extracted {name} wrong from byte {offset}"));
}
}
Ok(())
}
fn indent(stdout: &[u8], stderr: &[u8]) -> String {
let mut out = String::new();
for line in String::from_utf8_lossy(stdout)
.lines()
.chain(String::from_utf8_lossy(stderr).lines())
.filter(|line| !line.trim().is_empty())
{
out.push_str(" ");
out.push_str(line);
out.push('\n');
}
out
}
#[test]
fn every_claimed_decoder_limitation_still_holds() {
for limit in LIMITATIONS {
let Some(decoder) = DECODERS
.iter()
.find(|decoder| decoder.flavour == limit.decoder && decoder.is_installed())
else {
continue;
};
let proof = fixtures(limit.proof);
let output = decoder.test(&proof, Some("password"));
assert!(
!output.status.success(),
"{} reads {} now, so it can judge {:?} on {:?} after all",
decoder.program,
limit.proof,
limit.cells,
limit.formats
);
}
}
#[test]
fn rar14_archives_survive_an_external_decoder() {
run_matrix("rar14");
}
#[test]
fn rar15_archives_survive_an_external_decoder() {
run_matrix("rar15");
}
#[test]
fn rar20_archives_survive_an_external_decoder() {
run_matrix("rar20");
}
#[test]
fn rar29_archives_survive_an_external_decoder() {
run_matrix("rar29");
}
#[test]
fn rar30_archives_survive_an_external_decoder() {
run_matrix("rar30");
}
#[test]
fn rar40_archives_survive_an_external_decoder() {
run_matrix("rar40");
}
#[test]
fn rar50_archives_survive_an_external_decoder() {
run_matrix("rar50");
}
#[test]
fn rar70_archives_survive_an_external_decoder() {
run_matrix("rar70");
}