#[cfg(test)]
#[path = "../tests/support/scratch.rs"]
mod scratch;
mod add_plan;
mod cli;
mod error;
mod input;
mod output;
mod password;
mod progress;
mod repair;
use rars::timestamp as time;
mod volumes;
use cli::{AddArgs, Command, ExtractArgs, InfoArgs, PasswordArgs, TestArgs};
use error::{CliError, CliResult};
use input::{collect_inputs, rar15_file_attr, read_inputs_with_progress};
use output::{
create_rar50_redirection as create_rar50_redirection_output, open_output_writer,
output_path_for_entry, output_path_for_rar50_entry, print_ok_entry, rar50_entry_name,
rar50_output_relative_path, restore_output_metadata, warn_rar50_redirections, Backslash,
ExtractedOutput, ExtractionStaging, OverwritePolicy,
};
use password::{
classify_rars_error, ensure_password_for_archives_extract, ensure_password_for_extract,
error_is_password_class, parse_archives_prompting, password_bytes,
read_archive_path_prompting_with_options, resolve_password, Password,
};
use progress::CliProgress;
use rars::rar13::{
self, FileEntry, StoredEntry as Rar13StoredEntry, WriterOptions as Rar13WriterOptions,
};
use rars::rar15_40::{
FileEntry as Rar15FileEntry, StoredEntry as Rar15StoredEntry,
WriterOptions as Rar15WriterOptions,
};
use rars::{
extract_volumes_to_with_options, test_volumes_with_options_and_callback,
Archive as DetectedArchive, ArchiveReadOptions, ArchiveReader, ArchiveVersion, FeatureSet,
MemberCoding,
};
use repair::cmd_repair;
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use time::{current_filetime, format_filetime_utc};
use volumes::{
discover_sibling_volumes, rar50_volume_part_path, sort_volume_paths, volume_part_path,
};
const DOS_ARCHIVE_ATTR: u8 = 0x20;
#[cfg(windows)]
const RAR15_HOST_NATIVE: u8 = 2;
#[cfg(not(windows))]
const RAR15_HOST_NATIVE: u8 = 3;
#[cfg(windows)]
const RAR50_HOST_NATIVE: u64 = 0;
#[cfg(not(windows))]
const RAR50_HOST_NATIVE: u64 = 1;
const RAR50_STRUCTURAL_RR_WARNING: &str =
"warning: RAR 5 recovery writer emits validation-ready RR metadata; WinRAR recovery layout matching is not expected";
fn main() {
if let Err(err) = run() {
eprintln!("error: {err}");
std::process::exit(err.exit_code());
}
}
impl From<rars::Error> for CliError {
fn from(error: rars::Error) -> Self {
let message = format!("{error}{}", rar50_buffered_decode_limit_hint(&error));
if error_is_password_class(&error) {
Self::password(message)
} else {
Self::general(message)
}
}
}
fn run() -> CliResult<()> {
let cli = cli::parse();
configure_threads(cli.threads)?;
match cli.command {
Command::Info(args) => cmd_info(args),
Command::Test(args) => cmd_test(args),
Command::Extract(args) => cmd_extract(args),
Command::Repair(args) => cmd_repair(args),
Command::Add(args) => cmd_add(args, CliProgress::new(cli.progress)),
}
}
fn configure_threads(threads: Option<usize>) -> CliResult<()> {
let default_threads = std::thread::available_parallelism()
.ok()
.map(std::num::NonZeroUsize::get);
let mut builder = rayon::ThreadPoolBuilder::new();
let selected_threads = threads.or(default_threads);
if let Some(threads) = selected_threads {
builder = builder.num_threads(threads);
}
if selected_threads == Some(1) {
builder = builder.use_current_thread();
}
builder
.build_global()
.map_err(|err| CliError::general(format!("failed to configure parallel workers: {err}")))
}
fn extract_archive_to_with_options<F>(
archive: &DetectedArchive,
options: ArchiveReadOptions<'_>,
open: F,
) -> rars::Result<()>
where
F: FnMut(&rars::ExtractedEntryMeta) -> rars::Result<Box<dyn Write>>,
{
archive.extract_to_parallel_buffered_with_options(options, open)
}
fn rar50_buffered_decode_limit_hint(error: &rars::Error) -> String {
let Some((_, required)) = find_rar50_buffered_decode_limit_error(error) else {
return String::new();
};
format!(
"\nhint: retry with --rar50-buffered-decode-limit {required} if you trust this archive and have enough memory"
)
}
fn failed_entry_name(error: &rars::Error) -> Option<&[u8]> {
match error {
rars::Error::AtEntry { name, .. } => Some(name),
rars::Error::AtArchiveOffset { source, .. } | rars::Error::InVolume { source, .. } => {
failed_entry_name(source)
}
_ => None,
}
}
fn find_rar50_buffered_decode_limit_error(error: &rars::Error) -> Option<(u64, u64)> {
match error {
rars::Error::Rar50BufferedDecodeLimitExceeded { limit, required } => {
Some((*limit, *required))
}
rars::Error::AtEntry { source, .. } | rars::Error::AtArchiveOffset { source, .. } => {
find_rar50_buffered_decode_limit_error(source)
}
_ => None,
}
}
fn display_text(text: impl AsRef<str>) -> String {
let mut out = String::new();
for ch in text.as_ref().chars() {
if ch.is_control() {
out.extend(ch.escape_default());
} else {
out.push(ch);
}
}
out
}
fn display_bytes_lossy(bytes: &[u8]) -> String {
display_text(String::from_utf8_lossy(bytes))
}
fn cmd_info(args: InfoArgs) -> CliResult<()> {
let scratch = args.read_options.scratch();
let mut password = resolve_password_args(&args.password)?;
for path in &args.paths {
let archive =
read_archive_path_prompting_with_options(path, &mut password, &args.read_options)?;
let path_display = path.to_string_lossy();
let path = path_display.as_ref();
let family = archive.family();
if args.verbose {
println!("{path}: {family:?} at offset {}", archive.sfx_offset());
} else {
print_terse_header(path, &archive);
}
let options = args
.read_options
.options(password_bytes(&password), scratch.as_ref());
match archive {
DetectedArchive::Rar13(archive) => {
if args.verbose {
info_rar13_verbose(path, &archive, options)?;
} else {
info_rar13_terse(path, &archive, options)?;
}
}
DetectedArchive::Rar15To40(archive) => {
if args.verbose {
info_rar15_40_verbose(path, &archive, options)?;
} else {
info_rar15_40_terse(path, &archive, options)?;
}
}
DetectedArchive::Rar50Plus(archive) => {
if args.verbose {
info_rar50_verbose(path, &archive, options)?;
} else {
info_rar50_terse(path, &archive, options)?;
}
}
_ => {
return Err(CliError::general(format!(
"archive family {family:?} is not handled by info output"
)));
}
}
}
Ok(())
}
fn print_terse_header(path: &str, archive: &DetectedArchive) {
let label = match archive {
DetectedArchive::Rar13(_) => "RAR 1.3",
DetectedArchive::Rar15To40(_) => "RAR 1.5-4.x",
DetectedArchive::Rar50Plus(_) => "RAR 5.0+",
_ => "unknown",
};
let sfx_offset = archive.sfx_offset();
if sfx_offset > 0 {
println!("{path}: {label} (SFX, payload at offset {sfx_offset})");
} else {
println!("{path}: {label}");
}
}
fn render_comment_safe(bytes: &[u8]) -> String {
let end = bytes.iter().rposition(|&b| b != 0).map_or(0, |i| i + 1);
let text = String::from_utf8_lossy(&bytes[..end]);
let trimmed = text.trim_end_matches(['\0', '\r', '\n']);
let mut out = String::with_capacity(trimmed.len());
for ch in trimmed.chars() {
if ch == '\t' || ch == '\n' || !ch.is_control() {
out.push(ch);
} else {
out.push_str(&format!("\\x{:02x}", ch as u32));
}
}
out
}
fn print_comment(indent: &str, bytes: &[u8]) {
let rendered = render_comment_safe(bytes);
if rendered.is_empty() {
return;
}
if rendered.contains('\n') {
println!("{indent}Comment:");
for line in rendered.lines() {
println!("{indent} {line}");
}
} else {
println!("{indent}Comment: {rendered}");
}
}
fn print_entry_table<I>(rows: I)
where
I: IntoIterator<Item = (u64, u64, String)>,
{
let rows: Vec<(u64, u64, String)> = rows.into_iter().collect();
if rows.is_empty() {
return;
}
let size_w = rows
.iter()
.map(|(unp, _, _)| unp.to_string().len())
.max()
.expect("entry table is nonempty")
.max(4);
let pack_w = rows
.iter()
.map(|(_, pack, _)| pack.to_string().len())
.max()
.expect("entry table is nonempty")
.max(6);
println!(" {:>size_w$} {:>pack_w$} Name", "Size", "Packed");
for (unp, pack, name) in &rows {
println!(" {unp:>size_w$} {pack:>pack_w$} {name}");
}
}
fn display_legacy_name(
name: &[u8],
unicode: bool,
encoding: Option<rars::filename::LegacyNameEncoding>,
) -> rars::Result<String> {
Ok(display_bytes_lossy(&rars::filename::decoded_name(
name, unicode, encoding,
)?))
}
fn info_rar13_terse(
path: &str,
archive: &rars::rar13::Archive,
options: ArchiveReadOptions<'_>,
) -> CliResult<()> {
if let Some(comment) = archive
.archive_comment_with_options(options)
.map_err(|err| format!("failed to decode archive comment '{path}': {err}"))?
{
print_comment(" ", &comment);
}
print_entry_table(
archive
.entries
.iter()
.map(|entry| {
Ok((
u64::from(entry.header.unp_size),
u64::from(entry.header.pack_size),
display_legacy_name(&entry.name, false, options.legacy_name_encoding)?,
))
})
.collect::<rars::Result<Vec<_>>>()?,
);
Ok(())
}
fn info_rar13_verbose(
path: &str,
archive: &rars::rar13::Archive,
options: ArchiveReadOptions<'_>,
) -> CliResult<()> {
println!(
" rar13 main: flags={:#04x} head_size={} sfx_offset={}",
archive.main.flags, archive.main.head_size, archive.sfx_offset
);
if archive.main.has_archive_comment() {
println!(
" archive comment extension: {} bytes{}",
archive.main.extra.len(),
if archive.main.has_packed_comment() {
" (packed)"
} else {
""
}
);
let comment = archive
.archive_comment_with_options(options)
.map_err(|err| format!("failed to decode archive comment '{path}': {err}"))?
.expect("archive comment flag checked above");
println!(" comment: {}", display_bytes_lossy(&comment));
}
if let Some(av) = archive
.authenticity_verification()
.map_err(|err| format!("failed to parse authenticity verification in '{path}': {err}"))?
{
println!(
" authenticity verification: structural size={} cipher_body={} status=not-cryptographically-verified",
av.size,
av.cipher_body.len()
);
}
for (index, entry) in archive.entries.iter().enumerate() {
println!(
" #{index}: {} pack={} unp={} method={} flags={:#04x} attr={:#04x} checksum={:#06x}",
display_legacy_name(&entry.name, false, options.legacy_name_encoding)?,
entry.header.pack_size,
entry.header.unp_size,
entry.header.method,
entry.header.flags,
entry.header.file_attr,
entry.header.file_crc
);
if let Some(comment) = entry.file_comment().map_err(|err| {
format!(
"failed to decode file comment '{}' in '{path}': {err}",
display_text(entry.name_lossy())
)
})? {
println!(" comment: {}", display_bytes_lossy(&comment));
}
}
Ok(())
}
fn info_rar15_40_terse(
path: &str,
archive: &rars::rar15_40::Archive,
options: ArchiveReadOptions<'_>,
) -> CliResult<()> {
if let Some(comment) = archive
.archive_comment_with_options(options)
.map_err(|err| format!("failed to decode archive comment '{path}': {err}"))?
{
print_comment(" ", &comment);
}
print_entry_table(
archive
.files()
.map(|file| {
Ok((
file.unp_size,
file.pack_size,
display_legacy_name(
&file.name,
file.unicode_name.is_some(),
options.legacy_name_encoding,
)?,
))
})
.collect::<rars::Result<Vec<_>>>()?,
);
let sub_count = archive.new_subs().count();
if sub_count > 0 {
let kinds: Vec<String> = archive
.new_subs()
.map(|sub| format!("{:?}", sub.kind))
.collect();
println!(" Subblocks: {}", kinds.join(", "));
}
Ok(())
}
fn info_rar15_40_verbose(
path: &str,
archive: &rars::rar15_40::Archive,
options: ArchiveReadOptions<'_>,
) -> CliResult<()> {
println!(
" rar15-40 main: flags={:#06x} head_size={} sfx_offset={}",
archive.main.flags, archive.main.head_size, archive.sfx_offset
);
if let Some(comment) = archive
.archive_comment_with_options(options)
.map_err(|err| format!("failed to decode archive comment '{path}': {err}"))?
{
println!(" comment: {}", display_bytes_lossy(&comment));
}
for (index, file) in archive.files().enumerate() {
println!(
" #{index}: {} pack={} unp={} method={:#04x} flags={:#06x} attr={:#010x} crc={:#010x} ver={}",
display_legacy_name(&file.name, file.unicode_name.is_some(), options.legacy_name_encoding)?,
file.pack_size,
file.unp_size,
file.method,
file.block.flags,
file.attr,
file.file_crc,
file.unp_ver
);
if let Some(comment) = file.file_comment().map_err(|err| {
format!(
"failed to decode file comment '{}' in '{path}': {err}",
display_text(file.name_lossy())
)
})? {
println!(" comment: {}", display_bytes_lossy(&comment));
}
}
for sub in archive.new_subs() {
println!(
" subblock: {:?} {} pack={} unp={} method={:#04x} flags={:#06x}",
sub.kind,
display_text(sub.name_lossy()),
sub.file.pack_size,
sub.file.unp_size,
sub.file.method,
sub.file.block.flags
);
}
Ok(())
}
fn info_rar50_terse(
path: &str,
archive: &rars::rar50::Archive,
options: ArchiveReadOptions<'_>,
) -> CliResult<()> {
if let Some(metadata) = archive.main.archive_metadata() {
if let Some(name) = &metadata.name {
println!(" Archive name: {}", display_bytes_lossy(name));
}
if let Some(creation_time) = metadata.creation_time {
println!(" Created: {}", format_filetime_utc(creation_time));
}
}
let archive_comment = archive
.archive_comment_with_options(options)
.map_err(|err| format!("failed to decode archive comment '{path}': {err}"))?;
if let Some(comment) = &archive_comment {
print_comment(" ", comment);
}
print_entry_table(archive.files().map(|file| {
let mut name = display_text(file.name_lossy());
if let Some(redirection) = &file.redirection {
name.push_str(" → ");
name.push_str(&display_bytes_lossy(&redirection.target_name));
}
(file.unpacked_size, file.packed_size(), name)
}));
let suppressed_cmt = archive_comment.is_some();
let services: Vec<&[u8]> = archive
.services()
.filter_map(|service| {
if suppressed_cmt && service.name == b"CMT" {
None
} else {
Some(service.name.as_slice())
}
})
.collect();
if !services.is_empty() {
let names: Vec<String> = services.iter().map(|s| service_label(s)).collect();
println!(" Services: {}", names.join(", "));
}
Ok(())
}
fn service_label(name: &[u8]) -> String {
match name {
b"QO" => "quick-open".to_string(),
b"RR" => "recovery".to_string(),
b"CMT" => "comment".to_string(),
b"ACL" => "acl".to_string(),
b"STM" => "stream".to_string(),
other => display_bytes_lossy(other),
}
}
fn info_rar50_verbose(
path: &str,
archive: &rars::rar50::Archive,
options: ArchiveReadOptions<'_>,
) -> CliResult<()> {
println!(
" rar50 main: flags={:#06x} header_size={} sfx_offset={}",
archive.main.archive_flags, archive.main.block.header_size, archive.sfx_offset
);
if let Some(metadata) = archive.main.archive_metadata() {
if let Some(name) = &metadata.name {
println!(" archive name: {}", display_bytes_lossy(name));
}
if let Some(creation_time) = metadata.creation_time {
println!(
" archive creation time: {} ({creation_time:#018x})",
format_filetime_utc(creation_time)
);
}
}
let archive_comment = archive
.archive_comment_with_options(options)
.map_err(|err| format!("failed to decode archive comment '{path}': {err}"))?;
if let Some(ref comment) = archive_comment {
println!(" comment: {}", display_bytes_lossy(comment));
}
for (index, file) in archive.files().enumerate() {
let compression_info = file.decoded_compression_info().map_err(|err| {
format!(
"failed to decode RAR 5 compression info for '{}': {err}",
display_text(file.name_lossy())
)
})?;
println!(
" #{index}: {} pack={} unp={} algo={} method={} solid={} dict={} flags={:#06x} attr={:#010x} crc={}",
display_text(file.name_lossy()),
file.packed_size(),
file.unpacked_size,
compression_info.algorithm_version,
compression_info.method,
compression_info.solid,
compression_info.dictionary_size,
file.block.flags,
file.attributes,
file.data_crc32
.map(|crc| format!("{crc:#010x}"))
.unwrap_or_else(|| "none".to_string())
);
if let Some(redirection) = &file.redirection {
println!(
" redirection: type={} flags={:#x} target={}",
redirection.redirection_type,
redirection.flags,
display_bytes_lossy(&redirection.target_name)
);
}
}
let mut suppressed_archive_cmt = archive_comment.is_some();
for service in archive.services() {
if suppressed_archive_cmt && service.name == b"CMT" {
suppressed_archive_cmt = false;
continue;
}
println!(
" service: {} pack={} unp={} flags={:#06x}",
display_text(service.name_lossy()),
service.packed_size(),
service.unpacked_size,
service.block.flags
);
}
Ok(())
}
fn cmd_test(args: TestArgs) -> CliResult<()> {
let scratch = args.read_options.scratch();
let mut password = resolve_password_args(&args.password)?;
let mut paths = args.paths;
if paths.len() == 1 {
let discovered = discover_sibling_volumes(&paths[0]);
if discovered.len() > 1 {
paths = discovered;
}
} else {
sort_volume_paths(&mut paths);
}
if paths.len() == 1 {
let archive =
read_archive_path_prompting_with_options(&paths[0], &mut password, &args.read_options)?;
ensure_password_for_extract(&archive, &mut password)?;
warn_rar50_redirections(&archive);
let mut entries = Vec::new();
let options = args
.read_options
.options(password_bytes(&password), scratch.as_ref());
archive
.test_with_options_and_callback(options, |meta| {
entries.push(meta.clone());
Ok(())
})
.map_err(|err| {
classify_rars_error(err, |err| {
format!(
"failed to test archive '{}': {err}{}",
paths[0].display(),
rar50_buffered_decode_limit_hint(err)
)
})
})?;
for entry in &entries {
print_ok_entry(entry);
}
} else {
let archives = parse_archives_prompting(&paths, &mut password, &args.read_options)?;
ensure_password_for_archives_extract(&archives, &mut password)?;
for archive in &archives {
warn_rar50_redirections(archive);
}
let mut entries = Vec::new();
let options = args
.read_options
.options(password_bytes(&password), scratch.as_ref());
test_volumes_with_options_and_callback(&archives, options, |meta| {
entries.push(meta.clone());
Ok(())
})
.map_err(|err| {
classify_rars_error(err, |err| {
format!(
"failed to test volume set '{}': {err}{}",
paths
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", "),
rar50_buffered_decode_limit_hint(err)
)
})
})?;
for entry in &entries {
print_ok_entry(entry);
}
}
Ok(())
}
fn cmd_extract(args: ExtractArgs) -> CliResult<()> {
let scratch = args.read_options.scratch();
let mut password = resolve_password_args(&args.password)?;
let overwrite: OverwritePolicy = args.overwrite.into();
let mut paths = args.paths;
reject_ambiguous_extract_target(&paths)?;
let out_dir = paths.pop().expect("outdir");
validate_extract_destination(&out_dir)?;
if paths.len() == 1 {
let discovered = discover_sibling_volumes(&paths[0]);
if discovered.len() > 1 {
paths = discovered;
}
} else {
sort_volume_paths(&mut paths);
}
if paths.len() == 1 {
let archive =
read_archive_path_prompting_with_options(&paths[0], &mut password, &args.read_options)?;
ensure_password_for_extract(&archive, &mut password)?;
let family = archive.family();
let state = RefCell::new(ExtractOutputState::new(&out_dir, overwrite, family));
let options = args
.read_options
.options(password_bytes(&password), scratch.as_ref());
extract_single_archive(&archive, options, &state)
.inspect_err(|err| {
let _ = state.borrow_mut().publish_before_failure(err);
})
.map_err(|err| {
classify_rars_error(err, |err| {
format!(
"failed to write extracted entry to '{}': {err}{}",
out_dir.display(),
rar50_buffered_decode_limit_hint(err)
)
})
})?;
let outputs = state.into_inner().publish()?;
restore_output_metadata(&outputs).map_err(|err| {
CliError::general(format!(
"failed to restore extracted metadata under '{}': {err}",
out_dir.display()
))
})?;
for output in &outputs {
println!("x {}", display_bytes_lossy(&output.name));
}
} else {
let archives = parse_archives_prompting(&paths, &mut password, &args.read_options)?;
ensure_password_for_archives_extract(&archives, &mut password)?;
let family = archives
.first()
.map(DetectedArchive::family)
.ok_or("no archive parts provided")?;
let state = RefCell::new(ExtractOutputState::new(&out_dir, overwrite, family));
let options = args
.read_options
.options(password_bytes(&password), scratch.as_ref());
extract_volume_archives(&archives, options, &state)
.inspect_err(|err| {
let _ = state.borrow_mut().publish_before_failure(err);
})
.map_err(|err| {
classify_rars_error(err, |err| {
format!(
"failed to extract volume set '{}': {err}{}",
paths
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", "),
rar50_buffered_decode_limit_hint(err)
)
})
})?;
let outputs = state.into_inner().publish()?;
restore_output_metadata(&outputs).map_err(|err| {
CliError::general(format!(
"failed to restore extracted metadata under '{}': {err}",
out_dir.display()
))
})?;
for output in &outputs {
println!("x {}", display_bytes_lossy(&output.name));
}
}
Ok(())
}
struct ExtractOutputState<'a> {
out_dir: &'a Path,
overwrite: OverwritePolicy,
family: rars::ArchiveFamily,
outputs: Vec<ExtractedOutput>,
planned_paths: HashSet<PathBuf>,
created_paths: HashMap<PathBuf, PathBuf>,
staging: Option<ExtractionStaging>,
staged_paths: Vec<PathBuf>,
hardlink_targets: HashMap<PathBuf, PathBuf>,
staged_redirections: HashSet<PathBuf>,
staged_directories: HashSet<PathBuf>,
directory_times: Vec<(PathBuf, std::time::SystemTime)>,
archive_names: Vec<Vec<u8>>,
published: usize,
published_identities: HashSet<PathBuf>,
published_paths: HashMap<PathBuf, PathBuf>,
}
impl<'a> ExtractOutputState<'a> {
fn new(out_dir: &'a Path, overwrite: OverwritePolicy, family: rars::ArchiveFamily) -> Self {
Self {
out_dir,
overwrite,
family,
outputs: Vec::new(),
planned_paths: HashSet::new(),
created_paths: HashMap::new(),
staging: None,
staged_paths: Vec::new(),
hardlink_targets: HashMap::new(),
staged_redirections: HashSet::new(),
staged_directories: HashSet::new(),
directory_times: Vec::new(),
archive_names: Vec::new(),
published: 0,
published_identities: HashSet::new(),
published_paths: HashMap::new(),
}
}
fn open_entry(&mut self, meta: &rars::ExtractedEntryMeta) -> rars::Result<Box<dyn Write>> {
self.open_entry_named(meta, &meta.name)
}
fn open_entry_named(
&mut self,
meta: &rars::ExtractedEntryMeta,
archive_name: &[u8],
) -> rars::Result<Box<dyn Write>> {
self.publish_verified()?;
let planned = output_path_for_entry(self.out_dir, meta)?;
self.reserve_output_path(planned)?;
let destination = output_path_for_entry(self.out_dir, meta)?;
self.remember_directory_time(&destination, meta)?;
let staging = self.prepare_staged_path(&destination, meta.is_directory)?;
let (path, writer) =
open_output_writer(&staging, meta, OverwritePolicy::Never, Backslash::Separator)?;
self.record_staged_path(&path, meta.is_directory)?;
self.archive_names.push(archive_name.to_vec());
self.outputs.push(ExtractedOutput {
name: meta.name.clone(),
path: destination,
meta: meta.clone(),
family: self.family,
restore_metadata: true,
});
Ok(writer)
}
fn open_rar50_entry(
&mut self,
meta: &rars::rar50::ExtractedEntryMeta,
) -> rars::Result<Box<dyn Write>> {
self.publish_verified()?;
let common = rar50_extracted_meta(meta);
let planned = output_path_for_rar50_entry(self.out_dir, meta)?;
self.reserve_output_path(planned)?;
let destination = output_path_for_rar50_entry(self.out_dir, meta)?;
self.remember_directory_time(&destination, &common)?;
let staging = self.prepare_staged_path(&destination, common.is_directory)?;
let (path, writer) = open_output_writer(
&staging,
&common,
OverwritePolicy::Never,
Backslash::Literal,
)?;
self.record_staged_path(&path, common.is_directory)?;
self.record_rar50_created_path(meta, path.clone())?;
self.archive_names.push(meta.name.clone());
self.outputs.push(ExtractedOutput {
name: meta.name.clone(),
path: destination,
meta: common,
family: self.family,
restore_metadata: true,
});
Ok(writer)
}
fn create_rar50_redirection(
&mut self,
meta: &rars::rar50::ExtractedEntryMeta,
redirection: &rars::rar50::FileRedirection,
) -> rars::Result<()> {
self.publish_verified()?;
let planned = output_path_for_rar50_entry(self.out_dir, meta)?;
self.reserve_output_path(planned)?;
let destination = output_path_for_rar50_entry(self.out_dir, meta)?;
let staging = self.prepare_staged_path(&destination, false)?;
let (path, restore_metadata) = create_rar50_redirection_output(
&staging,
meta,
redirection,
OverwritePolicy::Never,
&self.created_paths,
)?;
self.record_staged_path(&path, false)?;
self.staged_redirections.insert(path.clone());
if let Some(target) =
output::rar50_hardlink_source(redirection, meta.host_os, &self.created_paths)?
{
self.hardlink_targets
.insert(path.clone(), target.to_path_buf());
}
self.record_rar50_created_path(meta, path.clone())?;
self.archive_names.push(meta.name.clone());
self.outputs.push(ExtractedOutput {
name: meta.name.clone(),
path: destination,
meta: rar50_extracted_meta(meta),
family: self.family,
restore_metadata,
});
Ok(())
}
fn reserve_output_path(&mut self, path: PathBuf) -> rars::Result<()> {
if !self.planned_paths.insert(path) {
return Err(rars::Error::InvalidHeader(
"multiple archive entries map to the same output path",
));
}
Ok(())
}
fn prepare_staged_path(
&mut self,
destination: &Path,
directory: bool,
) -> rars::Result<PathBuf> {
if !directory
&& self.overwrite == OverwritePolicy::Never
&& fs::symlink_metadata(destination).is_ok()
{
return Err(
std::io::Error::new(std::io::ErrorKind::AlreadyExists, "File exists").into(),
);
}
if self.staging.is_none() {
self.staging = Some(ExtractionStaging::new(self.out_dir)?);
}
let root = &self.staging.as_ref().expect("staging exists").root;
self.staging
.as_ref()
.expect("staging exists")
.reject_alias(destination)?;
let relative = destination
.strip_prefix(self.out_dir)
.map_err(|_| rars::Error::InvalidHeader("unsafe archive path"))?;
if destination.starts_with(root) {
return Err(rars::Error::InvalidHeader(
"archive path conflicts with extraction storage",
));
}
let staged = root.join(relative);
if let Ok(metadata) = fs::symlink_metadata(&staged) {
let explicit_directory = metadata.is_dir()
&& self
.staged_directories
.contains(&fs::canonicalize(&staged)?);
if !metadata.is_dir() || !directory || explicit_directory {
return Err(rars::Error::InvalidHeader(
"multiple archive entries map to the same output path",
));
}
}
Ok(root.clone())
}
fn record_staged_path(&mut self, path: &Path, directory: bool) -> rars::Result<()> {
if directory {
self.staged_directories.insert(fs::canonicalize(path)?);
}
self.staged_paths.push(path.to_path_buf());
Ok(())
}
fn remember_directory_time(
&mut self,
destination: &Path,
meta: &rars::ExtractedEntryMeta,
) -> rars::Result<()> {
if meta.is_directory
&& time::extracted_system_time(self.family, meta.file_time, meta.mtime_refinement)
.is_none()
{
match fs::metadata(destination) {
Ok(metadata) if metadata.is_dir() => self
.directory_times
.push((destination.to_path_buf(), metadata.modified()?)),
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
}
Ok(())
}
fn publish_verified(&mut self) -> rars::Result<()> {
while self.published < self.outputs.len() {
let staged = self.staged_paths[self.published].clone();
let relative = self.outputs[self.published]
.path
.strip_prefix(self.out_dir)
.map_err(|_| rars::Error::InvalidHeader("unsafe archive path"))?
.to_path_buf();
let destination = output::checked_output_path(self.out_dir, &relative)?;
if let Some(staging) = &self.staging {
staging.reject_alias(&destination)?;
}
if let Ok(identity) = fs::canonicalize(&destination) {
if self.published_identities.contains(&identity) {
return Err(rars::Error::InvalidHeader(
"multiple archive entries map to the same output path",
));
}
}
if fs::symlink_metadata(&staged)?.is_dir() {
fs::create_dir_all(&destination)?;
} else {
fs::create_dir_all(destination.parent().expect("validated path has parent"))?;
output::checked_output_path(self.out_dir, &relative)?;
if self.overwrite == OverwritePolicy::Always
&& self.staged_redirections.contains(&staged)
{
match fs::symlink_metadata(&destination) {
Ok(metadata) if metadata.is_dir() => fs::remove_dir(&destination)?,
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
}
let hardlink_target = self
.hardlink_targets
.get(&staged)
.map(|target| self.published_paths.get(target).unwrap_or(target).as_path());
output::publish_verified_output(
&staged,
&destination,
self.overwrite,
hardlink_target,
)?;
}
if self.created_paths.get(&relative) == Some(&staged) {
self.created_paths
.insert(relative.clone(), destination.clone());
}
self.published_paths
.insert(staged.clone(), destination.clone());
if let Ok(identity) = fs::canonicalize(&destination) {
if !fs::symlink_metadata(&destination)?.file_type().is_symlink() {
self.published_identities.insert(identity);
}
}
self.published += 1;
}
Ok(())
}
fn publish_before_failure(&mut self, error: &rars::Error) -> rars::Result<()> {
let last_verified = matches!(
(failed_entry_name(error), self.archive_names.last()),
(Some(failed), Some(last)) if failed != last.as_slice()
);
if !last_verified && self.outputs.len() > self.published {
self.outputs.pop();
}
self.publish_verified()
}
fn publish(mut self) -> rars::Result<Vec<ExtractedOutput>> {
self.publish_verified()?;
for (path, modified) in &self.directory_times {
output::set_modified_time(path, *modified)?;
}
Ok(std::mem::take(&mut self.outputs))
}
fn record_rar50_created_path(
&mut self,
meta: &rars::rar50::ExtractedEntryMeta,
path: PathBuf,
) -> rars::Result<()> {
let key = rar50_output_relative_path(&meta.name, meta.host_os)
.map_err(|_| rars::Error::InvalidHeader("unsafe archive path"))?;
self.created_paths.insert(key, path);
Ok(())
}
}
fn rar50_extracted_meta(meta: &rars::rar50::ExtractedEntryMeta) -> rars::ExtractedEntryMeta {
let mut common = rars::rar50_meta(meta);
common.name = rar50_entry_name(&meta.name, meta.host_os);
common
}
fn extract_single_archive(
archive: &DetectedArchive,
options: ArchiveReadOptions<'_>,
state: &RefCell<ExtractOutputState<'_>>,
) -> rars::Result<()> {
match archive {
DetectedArchive::Rar50Plus(archive) => archive.extract_to_with_redirections(
options,
|meta| state.borrow_mut().open_rar50_entry(meta),
|meta, redirection| {
state
.borrow_mut()
.create_rar50_redirection(meta, redirection)
},
),
_ => extract_archive_to_with_options(archive, options, |meta| {
if options.legacy_name_encoding.is_none() || meta.name_is_unicode {
return state.borrow_mut().open_entry(meta);
}
let mut decoded = meta.clone();
decoded.name = rars::filename::decoded_name(
&meta.name,
meta.name_is_unicode,
options.legacy_name_encoding,
)?
.into_owned();
state.borrow_mut().open_entry_named(&decoded, &meta.name)
}),
}
}
fn extract_volume_archives(
archives: &[DetectedArchive],
options: ArchiveReadOptions<'_>,
state: &RefCell<ExtractOutputState<'_>>,
) -> rars::Result<()> {
if archives
.iter()
.all(|archive| matches!(archive, DetectedArchive::Rar50Plus(_)))
{
let rar50_archives: Vec<_> = archives
.iter()
.map(|archive| match archive {
DetectedArchive::Rar50Plus(archive) => archive.clone(),
_ => unreachable!("all archives are RAR5"),
})
.collect();
return rars::rar50::extract_volumes_to_with_redirections(
&rar50_archives,
options,
|meta| state.borrow_mut().open_rar50_entry(meta),
|meta, redirection| {
state
.borrow_mut()
.create_rar50_redirection(meta, redirection)
},
);
}
extract_volumes_to_with_options(archives, options, |meta| {
if options.legacy_name_encoding.is_none() || meta.name_is_unicode {
return state.borrow_mut().open_entry(meta);
}
let mut decoded = meta.clone();
decoded.name = rars::filename::decoded_name(
&meta.name,
meta.name_is_unicode,
options.legacy_name_encoding,
)?
.into_owned();
state.borrow_mut().open_entry_named(&decoded, &meta.name)
})
}
fn validate_extract_destination(out_dir: &Path) -> CliResult<()> {
if out_dir.exists() && !out_dir.is_dir() {
return Err(CliError::general(format!(
"extract destination '{}' is not a directory",
out_dir.display()
)));
}
Ok(())
}
fn reject_ambiguous_extract_target(paths: &[PathBuf]) -> CliResult<()> {
let Some(out_path) = paths.last() else {
return Ok(());
};
if looks_like_archive_path(out_path)? {
return Err(CliError::usage("ambiguous extract arguments: final argument looks like an archive; pass an explicit output directory"));
}
Ok(())
}
fn looks_like_archive_path(path: &Path) -> CliResult<bool> {
const ARCHIVE_SNIFF_LIMIT: u64 = 128 * 1024;
let metadata = match fs::metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => {
return Err(CliError::general(format!(
"failed to inspect extract output path '{}': {error}",
path.display()
)))
}
};
if metadata.is_dir() {
return Ok(false);
}
if !metadata.is_file() {
return Err(CliError::general(format!(
"extract destination '{}' is not a regular file or directory",
path.display()
)));
}
let mut file = match fs::File::open(path) {
Ok(file) => file,
Err(error) => {
return Err(CliError::general(format!(
"failed to inspect extract output path '{}': {error}",
path.display()
)))
}
};
let mut bytes = Vec::new();
std::io::Read::by_ref(&mut file)
.take(ARCHIVE_SNIFF_LIMIT)
.read_to_end(&mut bytes)
.map_err(|error| {
CliError::general(format!(
"failed to inspect extract output path '{}': {error}",
path.display()
))
})?;
Ok(ArchiveReader::detect(&bytes).is_ok())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AddWritePlan {
Rar13,
Rar15To40,
}
impl AddWritePlan {
fn for_target(target: ArchiveVersion) -> CliResult<Self> {
match target {
ArchiveVersion::Rar14 => Ok(Self::Rar13),
ArchiveVersion::Rar15
| ArchiveVersion::Rar20
| ArchiveVersion::Rar29
| ArchiveVersion::Rar30
| ArchiveVersion::Rar40 => Ok(Self::Rar15To40),
_ => Err(format!("unsupported writer target: {target:?}").into()),
}
}
}
struct AddCommand {
password: Option<Password>,
target: ArchiveVersion,
store: bool,
compression_level: Option<u8>,
dictionary_size: Option<usize>,
memory_limit: Option<usize>,
max_memory: Option<usize>,
temp_dir: Option<PathBuf>,
solid: bool,
header_encryption: bool,
quick_open: bool,
archive_comment: Option<Vec<u8>>,
archive_name: Option<Vec<u8>>,
file_comment: Option<Vec<u8>>,
recovery_percent: Option<u64>,
volume_size: Option<usize>,
delta_filter: Option<usize>,
e8_filter: Option<bool>,
itanium_filter: bool,
rgb_filter: Option<usize>,
audio_filter: Option<usize>,
arm_filter: bool,
auto_filter: bool,
no_filter: bool,
ppmd: bool,
archive_path: PathBuf,
input_paths: Vec<PathBuf>,
}
fn build_add_command(args: AddArgs) -> CliResult<AddCommand> {
let password = resolve_password_args(&args.password)?;
let target = args.format.archive_version();
let mut store = args.store;
let compression_level = args.level;
if let Some(level) = compression_level {
if level > 5 {
return Err(CliError::usage(
"compression level must be in the range 0..5",
));
}
if store && level != 0 {
return Err(CliError::usage(
"--store cannot be combined with --level > 0",
));
}
if level == 0 {
store = true;
}
}
if args.solid && store {
return Err(CliError::usage("solid output requires compression"));
}
let e8_filter = if args.e8e9_filter {
Some(true)
} else if args.e8_filter {
Some(false)
} else {
None
};
Ok(AddCommand {
password,
target,
store,
compression_level,
dictionary_size: args.dict_size,
memory_limit: args.memory_limit,
max_memory: args.max_memory,
temp_dir: args.temp_dir,
solid: args.solid,
header_encryption: args.encrypt_headers,
quick_open: args.quick_open,
archive_comment: args.comment.map(String::into_bytes),
archive_name: args.archive_name.map(String::into_bytes),
file_comment: args.file_comment.map(String::into_bytes),
recovery_percent: args.recovery_percent,
volume_size: args.volume_size,
delta_filter: args.delta_filter,
e8_filter,
itanium_filter: args.itanium_filter,
rgb_filter: args.rgb_filter,
audio_filter: args.audio_filter,
arm_filter: args.arm_filter,
auto_filter: args.auto_filter,
no_filter: args.no_filter,
ppmd: args.ppmd,
archive_path: args.archive,
input_paths: args.files,
})
}
fn cmd_add(args: AddArgs, progress: CliProgress) -> CliResult<()> {
let AddCommand {
password,
target,
store,
compression_level,
dictionary_size,
memory_limit,
max_memory,
temp_dir,
solid,
header_encryption,
quick_open,
archive_comment,
archive_name,
file_comment,
recovery_percent,
volume_size,
delta_filter,
e8_filter,
itanium_filter,
rgb_filter,
audio_filter,
arm_filter,
auto_filter,
no_filter,
ppmd,
archive_path,
input_paths,
} = build_add_command(args)?;
if max_memory.is_some() && target.family() != rars::ArchiveFamily::Rar50Plus {
return Err(CliError::usage(
"--max-memory is supported only for RAR5/7 writers",
));
}
let input_paths = input_paths.as_slice();
let compress = !store;
validate_archive_output_path(&archive_path)?;
let asked_filters = add_plan::AskedFilters {
delta: delta_filter.is_some(),
e8: e8_filter == Some(false),
e8e9: e8_filter == Some(true),
itanium: itanium_filter,
rgb: rgb_filter.is_some(),
audio: audio_filter.is_some(),
arm: arm_filter,
};
let shape = rars::PlanShape::new()
.compressed(compress)
.volumes(volume_size.is_some())
.filtered(asked_filters.count() > 0 || auto_filter);
add_plan::reject_unsupported(
target,
shape,
&asked_filters,
&[
(rars::WriterOption::Feature(rars::Feature::Solid), solid),
(
rars::WriterOption::Feature(rars::Feature::HeaderEncryption),
header_encryption,
),
(
rars::WriterOption::Feature(rars::Feature::QuickOpen),
quick_open,
),
(
rars::WriterOption::DictionarySize,
dictionary_size.is_some(),
),
(
rars::WriterOption::Filter,
asked_filters.count() > 0 || auto_filter,
),
(rars::WriterOption::CompressionMethod, ppmd),
(
rars::WriterOption::RecoveryRecord,
recovery_percent.is_some(),
),
(rars::WriterOption::VolumeSize, volume_size.is_some()),
(
rars::WriterOption::ArchiveComment,
archive_comment.is_some(),
),
(rars::WriterOption::FileComment, file_comment.is_some()),
(rars::WriterOption::ArchiveMetadata, archive_name.is_some()),
(rars::WriterOption::Password, password.is_some()),
(rars::WriterOption::MemoryLimit, memory_limit.is_some()),
(rars::WriterOption::TempDir, temp_dir.is_some()),
],
)?;
add_plan::reject_multiple_filters(&asked_filters)?;
add_plan::reject_unsupported_filter(target, &asked_filters)?;
add_plan::reject_filter_with_solid(target, &asked_filters, auto_filter, solid)?;
if store {
add_plan::reject_coding_without_compression(&asked_filters, auto_filter, ppmd)?;
}
if header_encryption && password.is_none() {
return Err(CliError::usage(
"--encrypt-headers needs a --password to encrypt them with",
));
}
if target.family() != rars::ArchiveFamily::Rar50Plus
&& volume_size.is_some()
&& input_paths.len() != 1
{
return Err("multivolume writer supports one input file".into());
}
if matches!(target, ArchiveVersion::Rar50 | ArchiveVersion::Rar70) {
if quick_open && header_encryption {
return Err("RAR 5 quick-open cannot be combined with header encryption".into());
}
return write_plain_rar50_streaming(
input_paths,
&archive_path,
target,
if store { Some(0) } else { compression_level },
dictionary_size,
memory_limit,
max_memory,
temp_dir.as_deref(),
password_bytes(&password),
solid,
header_encryption,
recovery_percent,
rar50_filter_policy(
auto_filter,
no_filter,
solid,
store,
delta_filter,
e8_filter,
arm_filter,
),
quick_open,
archive_comment.as_deref(),
file_comment.as_deref(),
archive_name.as_deref(),
volume_size.map(|size| size as u64),
&progress,
);
}
let write_plan = AddWritePlan::for_target(target)?;
if let Some(volume_size) = volume_size {
return write_legacy_volumes(
LegacyVolumeWrite {
write_plan,
input_paths,
archive_path: &archive_path,
target,
compression_level,
dictionary_size,
password: password_bytes(&password),
solid,
header_encryption,
file_comment: file_comment.as_deref(),
volume_size,
compress,
},
&progress,
);
}
progress.spinner("Scanning inputs");
let inputs = collect_inputs(input_paths)?;
let total: u64 = inputs.iter().map(|entry| entry.size).sum();
progress.finish(format!(
"Found {} entries ({})",
inputs.len(),
indicatif::HumanBytes(total)
));
progress.spinner(if compress {
"Preparing compression"
} else {
"Preparing archive"
});
let coding = if store {
MemberCoding::Stored
} else if let Some(policy) = legacy_filter_policy(
target,
auto_filter,
no_filter,
delta_filter,
e8_filter,
itanium_filter,
rgb_filter,
audio_filter,
) {
MemberCoding::Filtered(policy)
} else {
MemberCoding::Compressed
};
let mut features = FeatureSet::store_only();
features.solid = solid;
features.header_encryption = header_encryption;
let password = password_bytes(&password);
let resources = rars::WriterResources::new(
memory_limit.map_or(rars::DEFAULT_WRITER_MEMORY_LIMIT, |limit| limit as u64),
);
progress.bar("Compressing archive", total);
write_archive_streaming(&archive_path, |output| {
let written = match write_plan {
AddWritePlan::Rar15To40 => {
let entries: Vec<_> = inputs
.iter()
.map(|input| {
let entry =
rars::rar15_40::StreamingEntry::new(input.name.clone(), input.source())
.with_file_time(input.dos_mtime)
.with_file_attr(rar15_file_attr(input.unix_mode, input.file_attr))
.with_host_os(RAR15_HOST_NATIVE);
let entry = match file_comment.as_deref() {
Some(comment) => entry.with_file_comment(comment.to_vec()),
None => entry,
};
match password {
Some(password) => entry.with_password(password.to_vec()),
None => entry,
}
})
.collect();
let mut options = Rar15WriterOptions::new(target, features);
if let Some(level) = compression_level {
options = options.with_compression_level(level);
}
if let Some(size) = dictionary_size {
options = options.with_dictionary_size(size);
}
options = options.with_method(if ppmd {
rars::rar15_40::Rar29Method::Ppmd
} else if coding.is_filtered() && !auto_filter && !no_filter {
rars::rar15_40::Rar29Method::Lz
} else {
rars::rar15_40::Rar29Method::Auto
});
rars::rar15_40::write_streaming_archive_to(
&entries,
options,
coding.clone(),
archive_comment.as_deref(),
&resources,
Some(&progress),
output,
)
}
AddWritePlan::Rar13 => {
let entries: Vec<_> = inputs
.iter()
.map(|input| {
let entry =
rars::rar13::StreamingEntry::new(input.name.clone(), input.source())
.with_file_time(input.dos_mtime)
.with_file_attr(input.file_attr);
let entry = match file_comment.as_deref() {
Some(comment) => entry.with_file_comment(comment.to_vec()),
None => entry,
};
match password {
Some(password) => entry.with_password(password.to_vec()),
None => entry,
}
})
.collect();
let mut options = Rar13WriterOptions::new(target, features);
if let Some(level) = compression_level {
options = options.with_compression_level(level);
}
rars::rar13::write_streaming_archive_to(
&entries,
options,
coding.clone(),
archive_comment.as_deref(),
&resources,
Some(&progress),
output,
)
}
};
written.map_err(|error| add_plan::map_write_error(error, shape, &asked_filters))
})?;
progress.finish("100%");
eprintln!("created {}", archive_path.display());
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn legacy_filter_policy(
target: ArchiveVersion,
auto_filter: bool,
no_filter: bool,
delta_filter: Option<usize>,
e8_filter: Option<bool>,
itanium_filter: bool,
rgb_filter: Option<usize>,
audio_filter: Option<usize>,
) -> Option<rars::FilterPolicy> {
use rars::{FilterKind, FilterSpec};
let kind = if let Some(channels) = delta_filter {
FilterKind::Delta { channels }
} else if e8_filter == Some(true) {
FilterKind::E8E9
} else if e8_filter == Some(false) {
FilterKind::E8
} else if itanium_filter {
FilterKind::Itanium
} else if let Some(width) = rgb_filter {
FilterKind::Rgb { width, pos_r: 0 }
} else if let Some(channels) = audio_filter {
FilterKind::Audio { channels }
} else if auto_filter {
return Some(rars::FilterPolicy::Auto);
} else if no_filter
&& rars::supports(
target,
rars::WriterOption::Filter,
rars::PlanShape::new().compressed(true),
)
{
return Some(rars::FilterPolicy::None);
} else {
return None;
};
Some(rars::FilterPolicy::Explicit(FilterSpec::whole(kind)))
}
struct LegacyVolumeWrite<'a> {
write_plan: AddWritePlan,
input_paths: &'a [PathBuf],
archive_path: &'a Path,
target: ArchiveVersion,
compression_level: Option<u8>,
dictionary_size: Option<usize>,
password: Option<&'a [u8]>,
solid: bool,
header_encryption: bool,
file_comment: Option<&'a [u8]>,
volume_size: usize,
compress: bool,
}
fn write_legacy_volumes(plan: LegacyVolumeWrite<'_>, progress: &CliProgress) -> CliResult<()> {
let inputs = collect_inputs(plan.input_paths)?;
if inputs.len() != 1 || inputs[0].is_directory {
return Err("multivolume writer supports one regular input file".into());
}
warn_if_buffered_write_is_large(plan.input_paths, plan.target);
progress.spinner("Scanning inputs");
let owned = read_inputs_with_progress(
plan.input_paths,
plan.password,
|files, bytes| progress.bar(format!("Reading {files} files"), bytes),
|bytes, name| {
progress.set_message(format!("Reading {}", display_bytes_lossy(name)));
progress.advance(bytes);
},
)?;
let [entry] = owned.as_slice() else {
return Err("multivolume writer supports one regular input file".into());
};
if entry.file_attr & 0x10 != 0 {
return Err("multivolume writer supports one regular input file".into());
}
progress.finish(format!(
"Read 1 file ({})",
indicatif::HumanBytes(entry.data.len() as u64)
));
progress.spinner(if plan.compress {
"Preparing compression"
} else {
"Preparing archive"
});
let mut features = FeatureSet::store_only();
features.solid = plan.solid;
features.header_encryption = plan.header_encryption;
let password = entry.password.as_deref().map(Vec::as_slice);
let parts = match plan.write_plan {
AddWritePlan::Rar15To40 => {
let mut options = Rar15WriterOptions::new(plan.target, features);
if let Some(level) = plan.compression_level {
options = options.with_compression_level(level);
}
if let Some(size) = plan.dictionary_size {
options = options.with_dictionary_size(size);
}
if plan.compress {
rars::rar15_40::write_compressed_volumes_with_progress(
Rar15FileEntry {
name: &entry.name,
data: &entry.data,
file_time: entry.dos_mtime,
file_attr: rar15_file_attr(entry.unix_mode, entry.file_attr),
host_os: RAR15_HOST_NATIVE,
password,
file_comment: plan.file_comment,
},
options,
plan.volume_size,
Some(progress),
)?
} else {
rars::rar15_40::write_stored_volumes(
Rar15StoredEntry {
name: &entry.name,
data: &entry.data,
file_time: entry.dos_mtime,
file_attr: rar15_file_attr(entry.unix_mode, entry.file_attr),
host_os: RAR15_HOST_NATIVE,
password,
file_comment: plan.file_comment,
},
options,
plan.volume_size,
)?
}
}
AddWritePlan::Rar13 => {
let mut options = Rar13WriterOptions::new(plan.target, features);
if let Some(level) = plan.compression_level {
options = options.with_compression_level(level);
}
if plan.compress {
rar13::write_compressed_volumes_with_progress(
FileEntry {
name: &entry.name,
data: &entry.data,
file_time: entry.dos_mtime,
file_attr: entry.file_attr,
password,
file_comment: plan.file_comment,
},
options,
plan.volume_size,
Some(progress),
)?
} else {
rar13::write_stored_volumes(
Rar13StoredEntry {
name: &entry.name,
data: &entry.data,
file_time: entry.dos_mtime,
file_attr: entry.file_attr,
password,
file_comment: plan.file_comment,
},
options,
plan.volume_size,
)?
}
}
};
write_volume_parts(plan.archive_path, &parts, progress).map_err(|err| {
format!(
"failed to write volume set starting at '{}': {err}",
plan.archive_path.display()
)
})?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn write_plain_rar50_streaming(
input_paths: &[PathBuf],
archive_path: &Path,
target: ArchiveVersion,
compression_level: Option<u8>,
dictionary_size: Option<usize>,
memory_limit: Option<usize>,
max_memory: Option<usize>,
temp_dir: Option<&Path>,
password: Option<&[u8]>,
solid: bool,
header_encryption: bool,
recovery_percent: Option<u64>,
filter_policy: rars::rar50::FilterPolicy,
quick_open: bool,
archive_comment: Option<&[u8]>,
file_comment: Option<&[u8]>,
archive_name: Option<&[u8]>,
volume_size: Option<u64>,
progress: &CliProgress,
) -> CliResult<()> {
progress.spinner("Scanning inputs");
let inputs = collect_inputs(input_paths)?;
let total: u64 = inputs.iter().map(|entry| entry.size).sum();
progress.finish(format!(
"Found {} entries ({})",
inputs.len(),
indicatif::HumanBytes(total)
));
let entries: Vec<_> = inputs
.into_iter()
.map(|entry| {
let source = entry.source();
let name = if cfg!(unix) {
rars::filename::encode_rar50(&entry.name).into_owned()
} else {
entry.name
};
let entry = rars::rar50::ArchiveEntry::new(name, source)
.with_directory(entry.is_directory)
.with_mtime(entry.unix_mtime)
.with_attributes(u64::from(
entry
.unix_mode
.unwrap_or_else(|| u32::from(entry.file_attr)),
))
.with_host_os(RAR50_HOST_NATIVE);
let entry = match file_comment {
Some(comment) => entry.with_service(rars::rar50::ServiceEntry::new(
b"CMT".to_vec(),
comment.to_vec(),
)),
None => entry,
};
match password {
Some(password) => entry.with_password(password.to_vec()),
None => entry,
}
})
.collect();
let mut features = FeatureSet::store_only();
features.header_encryption = header_encryption;
features.solid = solid;
features.quick_open = quick_open;
let mut options = rars::rar50::WriterOptions::new(target, features);
if let Some(level) = compression_level {
options = options.with_compression_level(level);
}
if let Some(size) = dictionary_size {
options = options.with_dictionary_size(size as u64);
}
let default_temp = archive_path
.parent()
.filter(|parent| !parent.as_os_str().is_empty());
let mut resources = rars::WriterResources::new(
memory_limit.map_or(rars::DEFAULT_WRITER_MEMORY_LIMIT, |limit| limit as u64),
);
if let Some(limit) = max_memory {
resources = resources.with_max_memory_bytes(limit as u64);
}
if let Some(directory) = temp_dir.or(default_temp) {
resources = resources.with_temp_dir(directory);
}
if recovery_percent.is_some() {
eprintln!("{RAR50_STRUCTURAL_RR_WARNING}");
}
let metadata = archive_name.map(|name| rars::rar50::ArchiveMetadataEntry {
name: Some(name),
creation_time: Some(current_filetime()),
});
let extras = streaming_extras(recovery_percent, filter_policy, archive_comment, metadata);
progress.spinner("Preparing compression");
progress.bar("Compressing archive", total);
if let Some(volume_size) = volume_size {
if archive_path == Path::new("-") || archive_path == Path::new("/dev/stdout") {
return Err("volume sets cannot be written to stdout".into());
}
let mut sink = CliVolumeSink {
first_path: archive_path,
temporaries: Vec::new(),
};
let result = rars::rar50::write_streaming_volumes_with_progress(
&entries,
options,
extras,
volume_size,
&mut sink,
&resources,
Some(progress),
);
let paths = sink.finish(result)?;
progress.finish("Volumes written");
print_created_volumes(&paths);
return Ok(());
}
write_archive_streaming(archive_path, |output| {
rars::rar50::write_streaming_archive_with_progress(
&entries,
options,
extras.clone(),
&resources,
Some(progress),
output,
)?;
Ok(())
})?;
progress.finish("Archive written");
eprintln!("created {}", archive_path.display());
Ok(())
}
fn warn_if_buffered_write_is_large(input_paths: &[PathBuf], target: ArchiveVersion) {
const WARN_THRESHOLD: u64 = 256 * 1024 * 1024;
if target.family() == rars::version::ArchiveFamily::Rar50Plus {
return;
}
let Ok(inputs) = collect_inputs(input_paths) else {
return;
};
let total: u64 = inputs.iter().map(|entry| entry.size).sum();
if total < WARN_THRESHOLD {
return;
}
eprintln!(
"warning: --format {target} builds a volume set in memory, so this write needs \
several times the {} of input and may run out; --format rar50 splits as it goes",
indicatif::HumanBytes(total)
);
}
struct CliVolumeSink<'a> {
first_path: &'a Path,
temporaries: Vec<PathBuf>,
}
impl CliVolumeSink<'_> {
fn finish(self, result: rars::Result<()>) -> CliResult<Vec<PathBuf>> {
self.finish_with_rename(result, |source, destination| {
fs::rename(source, destination)
})
}
fn finish_with_rename(
self,
result: rars::Result<()>,
mut rename: impl FnMut(&Path, &Path) -> std::io::Result<()>,
) -> CliResult<Vec<PathBuf>> {
result?;
let paths = (0..self.temporaries.len())
.map(|index| rar50_volume_part_path(self.first_path, index, self.temporaries.len()))
.collect::<CliResult<Vec<_>>>()?;
for path in &paths {
match fs::symlink_metadata(path) {
Ok(meta) if meta.is_dir() => {
return Err(
format!("volume destination '{}' is a directory", path.display()).into(),
)
}
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
}
let mut backups = Vec::new();
let mut published = 0;
let publication = (|| -> CliResult<()> {
for path in &paths {
match fs::symlink_metadata(path) {
Ok(_) => {
let (backup, file) = create_streaming_archive_temp(path)?;
drop(file);
if let Err(error) = fs::rename(path, &backup) {
let _ = fs::remove_file(&backup);
return Err(error.into());
}
backups.push((path.clone(), backup));
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
}
for (temporary, path) in self.temporaries.iter().zip(&paths) {
rename(temporary, path)?;
published += 1;
}
Ok(())
})();
if let Err(error) = publication {
let mut failures = Vec::new();
for path in paths[..published].iter().rev() {
if let Err(error) = fs::remove_file(path) {
failures.push(format!("cannot remove '{}': {error}", path.display()));
}
}
for (path, backup) in backups.iter().rev() {
if let Err(error) = fs::rename(backup, path) {
failures.push(format!(
"cannot restore '{}': {error}; backup retained at '{}'",
path.display(),
backup.display()
));
}
}
if failures.is_empty() {
return Err(error);
}
return Err(format!(
"{error}; volume rollback incomplete: {}",
failures.join("; ")
)
.into());
}
let mut cleanup_errors = Vec::new();
for (_, backup) in backups {
if let Err(error) = fs::remove_file(&backup) {
cleanup_errors.push(format!("'{}': {error}", backup.display()));
}
}
if !cleanup_errors.is_empty() {
return Err(format!(
"volumes published, but cannot remove backups: {}",
cleanup_errors.join("; ")
)
.into());
}
Ok(paths)
}
}
impl Drop for CliVolumeSink<'_> {
fn drop(&mut self) {
for temporary in &self.temporaries {
let _ = fs::remove_file(temporary);
}
}
}
impl rars::rar50::VolumeSink for CliVolumeSink<'_> {
fn start_volume(&mut self, index: u64) -> rars::Result<Box<dyn std::io::Write + Send>> {
let base = self
.first_path
.with_file_name(format!("rars-volume-{index:06}"));
let (temporary, file) = create_streaming_archive_temp(&base)
.map_err(|error| std::io::Error::other(error.to_string()))?;
self.temporaries.push(temporary);
Ok(Box::new(file))
}
}
fn rar50_filter_policy(
auto_filter: bool,
no_filter: bool,
solid: bool,
store: bool,
delta_filter: Option<usize>,
e8_filter: Option<bool>,
arm_filter: bool,
) -> rars::rar50::FilterPolicy {
use rars::rar50::{FilterKind, FilterPolicy};
if let Some(channels) = delta_filter {
return FilterPolicy::explicit(FilterKind::Delta { channels });
}
if let Some(include_e9) = e8_filter {
return FilterPolicy::explicit(if include_e9 {
FilterKind::E8E9
} else {
FilterKind::E8
});
}
if arm_filter {
return FilterPolicy::explicit(FilterKind::Arm);
}
if no_filter || store || (solid && !auto_filter) {
return FilterPolicy::None;
}
FilterPolicy::Auto
}
fn streaming_extras<'a>(
recovery_percent: Option<u64>,
filter_policy: rars::rar50::FilterPolicy,
comment: Option<&'a [u8]>,
metadata: Option<rars::rar50::ArchiveMetadataEntry<'a>>,
) -> rars::rar50::ArchiveExtras<'a> {
let mut extras = rars::rar50::ArchiveExtras::default()
.with_recovery_percent(recovery_percent)
.with_filter_policy(filter_policy);
if let Some(comment) = comment {
extras = extras.with_comment(comment);
}
if let Some(metadata) = metadata {
extras = extras.with_metadata(metadata);
}
extras
}
fn write_archive_streaming(
archive_path: &Path,
write: impl FnOnce(&mut dyn Write) -> CliResult<()>,
) -> CliResult<()> {
if archive_path == Path::new("-") || archive_path == Path::new("/dev/stdout") {
return write(&mut std::io::stdout());
}
write_archive_file(archive_path, write)
}
fn write_archive_file<T>(
archive_path: &Path,
write: impl FnOnce(&mut dyn Write) -> CliResult<T>,
) -> CliResult<T> {
let (temporary, mut output) = create_streaming_archive_temp(archive_path)?;
let result = (|| -> CliResult<T> {
let value = write(&mut output)?;
output.sync_all()?;
Ok(value)
})();
drop(output);
let result = result.and_then(|value| {
fs::rename(&temporary, archive_path)?;
Ok(value)
});
if result.is_err() {
let _ = fs::remove_file(&temporary);
}
result
}
fn create_streaming_archive_temp(archive_path: &Path) -> CliResult<(PathBuf, fs::File)> {
let file_name = archive_path
.file_name()
.ok_or("archive output path has no file name")?;
for sequence in 0..128u8 {
let mut name = std::ffi::OsString::from(".");
name.push(file_name);
name.push(format!(
".rars-writing-{}-{sequence:02x}",
std::process::id()
));
let temporary = archive_path.with_file_name(name);
match fs::File::options()
.write(true)
.create_new(true)
.open(&temporary)
{
Ok(file) => return Ok((temporary, file)),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => {
return Err(format!(
"failed to create temporary archive '{}': {error}",
temporary.display()
)
.into())
}
}
}
Err("could not allocate a unique temporary archive file".into())
}
fn validate_archive_output_path(path: &Path) -> CliResult<()> {
if path == Path::new("-") || path == Path::new("/dev/stdout") {
return Ok(());
}
if path.is_dir() || has_trailing_path_separator(path) {
return Err(format!(
"archive output path '{}' is a directory; provide an archive filename",
path.display()
)
.into());
}
Ok(())
}
#[cfg(unix)]
fn has_trailing_path_separator(path: &Path) -> bool {
use std::os::unix::ffi::OsStrExt;
path.as_os_str().as_bytes().last() == Some(&b'/')
}
#[cfg(windows)]
fn has_trailing_path_separator(path: &Path) -> bool {
use std::os::windows::ffi::OsStrExt;
path.as_os_str()
.encode_wide()
.last()
.is_some_and(|ch| ch == b'/' as u16 || ch == b'\\' as u16)
}
fn write_bytes_with_progress(
writer: &mut impl Write,
bytes: &[u8],
progress: &CliProgress,
) -> std::io::Result<()> {
for chunk in bytes.chunks(1024 * 1024) {
writer.write_all(chunk)?;
progress.advance(chunk.len() as u64);
}
Ok(())
}
fn write_volume_parts(
first_path: &Path,
parts: &[Vec<u8>],
progress: &CliProgress,
) -> CliResult<()> {
let paths = (0..parts.len())
.map(|index| volume_part_path(first_path, index))
.collect::<CliResult<Vec<_>>>()?;
let total = parts.iter().map(|part| part.len() as u64).sum();
progress.bar(format!("Writing {} volumes", parts.len()), total);
for (path, bytes) in paths.iter().zip(parts) {
let mut file = fs::File::create(path)?;
write_bytes_with_progress(&mut file, bytes, progress)?;
}
progress.finish("Volumes written");
print_created_volumes(&paths);
Ok(())
}
fn print_created_volumes(paths: &[PathBuf]) {
println!("created {} volumes:", paths.len());
for path in paths {
println!(" {}", path.display());
}
}
fn parse_size(input: &str) -> CliResult<usize> {
let input = input.trim();
if input.is_empty() {
return Err(CliError::usage("size is empty"));
}
let (digits, multiplier) = match input.as_bytes().last().copied() {
Some(b'k' | b'K') => (&input[..input.len() - 1], 1024usize),
Some(b'm' | b'M') => (&input[..input.len() - 1], 1024usize * 1024),
Some(b'g' | b'G') => (&input[..input.len() - 1], 1024usize * 1024 * 1024),
_ => (input, 1usize),
};
if digits.is_empty() {
return Err(CliError::usage(format!("invalid size: {input}")));
}
let value = digits
.parse::<usize>()
.map_err(|_| CliError::usage(format!("invalid size value: {input}")))?;
value
.checked_mul(multiplier)
.ok_or_else(|| CliError::usage(format!("size overflows usize: {input}")))
}
pub(crate) fn parse_size_string(input: &str) -> Result<usize, String> {
parse_size(input).map_err(|err| err.to_string())
}
pub(crate) fn parse_thread_count(input: &str) -> Result<usize, String> {
let threads = input
.parse::<usize>()
.map_err(|_| format!("invalid thread count: {input}"))?;
if threads == 0 {
return Err("thread count must be at least 1".to_string());
}
Ok(threads)
}
pub(crate) fn resolve_password_args(args: &PasswordArgs) -> CliResult<Option<Password>> {
resolve_password(args.password.as_deref(), args.password_file.as_deref())
}
#[cfg(test)]
mod tests {
#[test]
fn single_thread_workers_run_on_the_caller_and_drain_panicking_jobs() {
const CHILD: &str = "RARS_INLINE_POOL_TEST_CHILD";
if std::env::var_os(CHILD).is_none() {
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"tests::single_thread_workers_run_on_the_caller_and_drain_panicking_jobs",
"--test-threads=1",
])
.env(CHILD, "1")
.output()
.unwrap();
assert!(
output.status.success(),
"fresh-process pool probe failed:\n{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
return;
}
use rayon::prelude::*;
use std::sync::atomic::{AtomicBool, Ordering};
super::configure_threads(Some(1)).unwrap();
assert_eq!(rayon::current_num_threads(), 1);
assert_eq!(rayon::current_thread_index(), Some(0));
let caller = std::thread::current().id();
let sum: usize = (0..128usize)
.into_par_iter()
.map(|value| {
assert_eq!(std::thread::current().id(), caller);
let (left, right) = rayon::join(|| value, || value + 1);
left + right
})
.sum();
assert_eq!(sum, (0..128usize).map(|value| 2 * value + 1).sum());
let sibling_finished = AtomicBool::new(false);
let panic = std::panic::catch_unwind(|| {
rayon::join(
|| panic!("intentional synchronous worker failure"),
|| sibling_finished.store(true, Ordering::SeqCst),
);
});
assert!(panic.is_err());
assert!(sibling_finished.load(Ordering::SeqCst));
assert_eq!((0..16usize).into_par_iter().sum::<usize>(), 120);
}
#[cfg(unix)]
#[test]
fn volume_preflight_reports_parent_symlink_loops_and_cleans_staging() {
let root = crate::scratch::case("volume-preflight-symlink-loop");
let first = root.join("archive.rar");
let blocked = root.join("loop/archive.rar");
std::os::unix::fs::symlink("loop", root.join("loop")).unwrap();
let mut sink = stage_two_volumes(&first);
sink.first_path = &blocked;
let error = sink.finish(Ok(())).unwrap_err();
assert_eq!(error.exit_code(), 1);
assert_eq!(std::fs::read_dir(&root).unwrap().count(), 1);
assert!(std::fs::symlink_metadata(root.join("loop"))
.unwrap()
.is_symlink());
}
fn append_rar50_test_header(bytes: &mut Vec<u8>, body: &[u8]) {
assert!(body.len() < 128);
let mut encoded = vec![body.len() as u8];
encoded.extend_from_slice(body);
bytes.extend_from_slice(&rars::crc32::crc32(&encoded).to_le_bytes());
bytes.extend_from_slice(&encoded);
}
#[test]
fn info_preserves_unknown_services_before_an_archive_comment() {
let mut bytes = b"Rar!\x1a\x07\x01\x00".to_vec();
append_rar50_test_header(&mut bytes, &[1, 0, 0]);
append_rar50_test_header(&mut bytes, b"\x03\0\0\0\0\0\0\x05OTHER");
append_rar50_test_header(&mut bytes, b"\x03\0\0\0\0\0\0\x03CMT");
append_rar50_test_header(&mut bytes, &[5, 0, 0]);
let archive = rars::rar50::Archive::parse(&bytes).unwrap();
assert_eq!(archive.services().count(), 2);
assert_eq!(archive.archive_comment().unwrap(), Some(Vec::new()));
super::info_rar50_verbose("services.rar", &archive, super::ArchiveReadOptions::new())
.unwrap();
super::info_rar50_terse("services.rar", &archive, super::ArchiveReadOptions::new())
.unwrap();
}
#[cfg(unix)]
#[test]
fn collection_extraction_preserves_fixture_redirections_and_unicode_names() {
let fixtures =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../rars/tests/fixtures");
let root = crate::scratch::case("collection-extract-redirections");
let archive =
rars::rar50::Archive::parse_path(fixtures.join("rar50/wild/symlink.rar")).unwrap();
let state = std::cell::RefCell::new(super::ExtractOutputState::new(
&root,
super::OverwritePolicy::Never,
rars::ArchiveFamily::Rar50Plus,
));
super::extract_volume_archives(
&[super::DetectedArchive::Rar50Plus(archive)],
super::ArchiveReadOptions::new(),
&state,
)
.unwrap();
state.into_inner().publish().unwrap();
assert_eq!(
std::fs::read_link(root.join("symlink.txt")).unwrap(),
std::path::PathBuf::from("file.txt")
);
assert_eq!(
std::fs::read_link(root.join("dirlink")).unwrap(),
std::path::PathBuf::from("dir")
);
assert!(root.join("file.txt").is_file());
let root = crate::scratch::case("collection-redirection-refusal");
std::fs::write(root.join("symlink.txt"), b"existing destination").unwrap();
let archive =
rars::rar50::Archive::parse_path(fixtures.join("rar50/wild/symlink.rar")).unwrap();
let state = std::cell::RefCell::new(super::ExtractOutputState::new(
&root,
super::OverwritePolicy::Never,
rars::ArchiveFamily::Rar50Plus,
));
assert!(super::extract_volume_archives(
&[super::DetectedArchive::Rar50Plus(archive)],
super::ArchiveReadOptions::new(),
&state
)
.is_err());
assert_eq!(
std::fs::read(root.join("symlink.txt")).unwrap(),
b"existing destination"
);
let root = crate::scratch::case("collection-extract-unicode");
let archive = rars::rar15_40::Archive::parse_path(
fixtures.join("rar15_40/encrypted/rar4_junrar_file_content_encrypted_unicode.rar"),
)
.unwrap();
let state = std::cell::RefCell::new(super::ExtractOutputState::new(
&root,
super::OverwritePolicy::Never,
rars::ArchiveFamily::Rar15To40,
));
let mut options = super::ArchiveReadOptions::with_optional_password(Some(b"test"));
options.legacy_name_encoding = Some(rars::filename::LegacyNameEncoding::Cp850);
super::extract_volume_archives(
&[super::DetectedArchive::Rar15To40(archive)],
options,
&state,
)
.unwrap();
state.into_inner().publish().unwrap();
assert_eq!(
std::fs::read(root.join("新建文本文档.txt")).unwrap(),
b"aaaaaaaaaa"
);
}
#[test]
fn repeated_worker_configuration_reports_the_global_pool_error() {
let _ = super::configure_threads(Some(2));
let error = super::configure_threads(Some(2)).unwrap_err();
assert_eq!(error.exit_code(), 1);
assert!(error
.to_string()
.contains("failed to configure parallel workers"));
}
#[cfg(unix)]
#[test]
fn buffered_volume_warning_uses_file_sizes_without_reading_payloads() {
const CHILD_INPUT: &str = "RARS_CLI_TEST_BUFFER_WARNING_INPUT";
if let Some(path) = std::env::var_os(CHILD_INPUT) {
let path = std::path::PathBuf::from(path);
super::warn_if_buffered_write_is_large(
std::slice::from_ref(&path),
super::ArchiveVersion::Rar50,
);
super::warn_if_buffered_write_is_large(
&[path.with_file_name("missing")],
super::ArchiveVersion::Rar14,
);
super::warn_if_buffered_write_is_large(&[path], super::ArchiveVersion::Rar14);
return;
}
let root = crate::scratch::case("buffered-volume-warning");
let path = root.join("sparse.bin");
let file = std::fs::File::create(&path).unwrap();
for (size, expected_warnings) in [(0, 0), (256 * 1024 * 1024, 1)] {
file.set_len(size).unwrap();
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"tests::buffered_volume_warning_uses_file_sizes_without_reading_payloads",
"--nocapture",
"--test-threads=1",
])
.env(CHILD_INPUT, &path)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stdout)
);
let stderr = String::from_utf8(output.stderr).unwrap();
assert_eq!(
stderr.matches("warning:").count(),
expected_warnings,
"{stderr}"
);
if expected_warnings != 0 {
assert!(stderr.contains("--format rar14 builds a volume set in memory"));
assert!(stderr.contains("--format rar50 splits as it goes"));
}
assert_eq!(std::fs::metadata(&path).unwrap().len(), size);
}
}
#[cfg(unix)]
#[test]
fn archive_sniffing_reports_permission_errors_after_successful_metadata() {
use std::os::unix::fs::PermissionsExt;
let root = crate::scratch::case("archive-sniff-permissions");
let path = root.join("private.rar");
std::fs::write(&path, b"private contents").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o0)).unwrap();
let privileged = std::fs::File::open(&path).is_ok();
if !privileged {
let error = super::looks_like_archive_path(&path)
.unwrap_err()
.to_string();
assert!(error.contains("failed to inspect extract output path"));
assert!(error.contains(&path.display().to_string()));
}
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
assert!(!super::looks_like_archive_path(&path).unwrap());
}
#[test]
fn info_accepts_independent_archive_metadata_fields() {
for flags in [0u8, 1, 2, 3] {
let mut record = vec![2, flags]; if flags & 1 != 0 {
record.extend_from_slice(b"\x08name.rar");
}
if flags & 2 != 0 {
record.extend_from_slice(&0x01dcd60e662d7a32u64.to_le_bytes());
}
let mut extra = vec![record.len() as u8];
extra.extend_from_slice(&record);
let mut main = vec![1, 1, extra.len() as u8, 0]; main.extend_from_slice(&extra);
let mut bytes = b"Rar!\x1a\x07\x01\x00".to_vec();
append_rar50_test_header(&mut bytes, &main);
append_rar50_test_header(&mut bytes, &[5, 0, 0]); let archive = rars::rar50::Archive::parse(&bytes).unwrap();
let metadata = archive.main.archive_metadata().unwrap();
assert_eq!(
metadata.name.as_deref(),
(flags & 1 != 0).then_some(b"name.rar".as_slice())
);
assert_eq!(metadata.creation_time.is_some(), flags & 2 != 0);
super::info_rar50_terse("metadata.rar", &archive, super::ArchiveReadOptions::new())
.unwrap();
super::info_rar50_verbose("metadata.rar", &archive, super::ArchiveReadOptions::new())
.unwrap();
}
}
#[test]
fn verbose_info_reports_lazy_comment_and_compression_errors() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../rars/tests/fixtures");
let mut legacy = rars::rar13::Archive::parse_path(root.join("rar13/FCOMM.RAR")).unwrap();
let entry = legacy
.entries
.iter_mut()
.find(|entry| entry.has_file_comment())
.unwrap();
entry.extra.clear();
let error =
super::info_rar13_verbose("broken13.rar", &legacy, super::ArchiveReadOptions::new())
.unwrap_err()
.to_string();
assert!(error.contains("failed to decode file comment"));
assert!(error.contains("broken13.rar"));
let mut authenticity =
rars::rar13::Archive::parse_path(root.join("rar13/rar140_av/rar140_av_patched.rar"))
.unwrap();
authenticity.main.extra.clear();
let error = super::info_rar13_verbose(
"broken-av.rar",
&authenticity,
super::ArchiveReadOptions::new(),
)
.unwrap_err()
.to_string();
assert!(error.contains("failed to parse authenticity verification"));
assert!(error.contains("broken-av.rar"));
let mut legacy15 =
rars::rar15_40::Archive::parse_path(root.join("rar15_40/rars_generated/comments.rar"))
.unwrap();
let file = legacy15
.blocks
.iter_mut()
.find_map(|block| match block {
rars::rar15_40::Block::File(file) if file.has_file_comment() => Some(file),
_ => None,
})
.unwrap();
file.file_comment.truncate(1);
let error = super::info_rar15_40_verbose(
"broken15.rar",
&legacy15,
super::ArchiveReadOptions::new(),
)
.unwrap_err()
.to_string();
assert!(error.contains("failed to decode file comment"));
assert!(error.contains("broken15.rar"));
let mut modern =
rars::rar50::Archive::parse_path(root.join("rar50/with_all_services.rar")).unwrap();
let file = modern
.blocks
.iter_mut()
.find_map(|block| match block {
rars::rar50::Block::File(file) => Some(file),
_ => None,
})
.unwrap();
file.compression_info = u64::MAX;
let error =
super::info_rar50_verbose("broken50.rar", &modern, super::ArchiveReadOptions::new())
.unwrap_err()
.to_string();
assert!(error.contains("failed to decode RAR 5 compression info"));
assert!(error.contains("hello.txt"));
}
#[test]
fn volume_publication_reports_backup_cleanup_failure_after_success() {
let root = crate::scratch::case("volume-backup-cleanup-failure");
let first = root.join("archive.rar");
let paths: Vec<_> = (0..2)
.map(|i| super::rar50_volume_part_path(&first, i, 2).unwrap())
.collect();
std::fs::write(&paths[0], b"old archive").unwrap();
let retained = root.join("retained-original");
let mut blocked_backup = None;
let error = stage_two_volumes(&first)
.finish_with_rename(Ok(()), |source, destination| {
if blocked_backup.is_none() {
let backup = std::fs::read_dir(&root)?
.map(|entry| entry.unwrap().path())
.find(|path| std::fs::read(path).is_ok_and(|bytes| bytes == b"old archive"))
.unwrap();
std::fs::rename(&backup, &retained)?;
std::fs::create_dir(&backup)?;
blocked_backup = Some(backup);
}
std::fs::rename(source, destination)
})
.unwrap_err();
let error = error.to_string();
assert!(error.contains("volumes published, but cannot remove backups"));
assert!(error.contains(&blocked_backup.unwrap().display().to_string()));
assert_eq!(std::fs::read(retained).unwrap(), b"old archive");
for (i, path) in paths.iter().enumerate() {
assert_eq!(std::fs::read(path).unwrap(), format!("new {i}").as_bytes());
}
}
#[test]
fn add_command_validation_and_filter_selection_preserve_explicit_requests() {
use clap::Parser;
let arguments = || {
let parsed =
super::cli::Cli::try_parse_from(["rars", "add", "archive.rar", "input.txt"])
.unwrap();
let super::Command::Add(args) = parsed.command else {
panic!("expected add")
};
args
};
let mut args = arguments();
args.level = Some(6);
assert_eq!(super::build_add_command(args).err().unwrap().exit_code(), 2);
let mut args = arguments();
args.store = true;
args.level = Some(1);
assert!(super::build_add_command(args)
.err()
.unwrap()
.to_string()
.contains("--store"));
let mut args = arguments();
args.level = Some(0);
assert!(super::build_add_command(args).unwrap().store);
let mut args = arguments();
args.level = Some(0);
args.store = true;
assert!(super::build_add_command(args).unwrap().store);
let mut args = arguments();
args.store = true;
args.solid = true;
assert!(super::build_add_command(args)
.err()
.unwrap()
.to_string()
.contains("solid output requires compression"));
for target in [
super::ArchiveVersion::Rar13,
super::ArchiveVersion::Rar50,
super::ArchiveVersion::Rar70,
] {
assert!(super::AddWritePlan::for_target(target).is_err());
}
use rars::rar50::FilterPolicy;
assert!(matches!(
super::rar50_filter_policy(false, false, true, false, None, None, false),
FilterPolicy::None
));
assert!(matches!(
super::rar50_filter_policy(true, false, true, false, None, None, false),
FilterPolicy::Auto
));
}
#[test]
fn extraction_destinations_refuse_files_and_duplicate_planned_paths() {
let root = crate::scratch::case("extract-destination-boundaries");
let file = root.join("plain.txt");
std::fs::write(&file, b"not an archive").unwrap();
assert!(super::validate_extract_destination(&file)
.unwrap_err()
.to_string()
.contains("not a directory"));
assert!(super::validate_extract_destination(&root).is_ok());
assert!(super::validate_extract_destination(&root.join("missing")).is_ok());
assert!(!super::looks_like_archive_path(&file).unwrap());
assert!(!super::looks_like_archive_path(&root).unwrap());
assert!(!super::looks_like_archive_path(&root.join("missing")).unwrap());
assert!(super::reject_ambiguous_extract_target(&[]).is_ok());
let mut state = super::ExtractOutputState::new(
&root,
super::OverwritePolicy::Always,
rars::ArchiveFamily::Rar50Plus,
);
state.reserve_output_path(file.clone()).unwrap();
assert!(matches!(
state.reserve_output_path(file),
Err(rars::Error::InvalidHeader(
"multiple archive entries map to the same output path"
))
));
#[cfg(unix)]
{
let loop_path = root.join("loop");
std::os::unix::fs::symlink("loop", &loop_path).unwrap();
assert!(super::looks_like_archive_path(&loop_path)
.unwrap_err()
.to_string()
.contains("failed to inspect"));
assert!(super::reject_ambiguous_extract_target(&[loop_path]).is_err());
assert!(
super::looks_like_archive_path(std::path::Path::new("/dev/null"))
.unwrap_err()
.to_string()
.contains("not a regular file or directory")
);
}
}
#[test]
fn reader_errors_keep_password_exit_classes_and_service_labels() {
let password_error = super::CliError::from(rars::Error::NeedPassword);
assert_eq!(password_error.exit_code(), 3);
assert_eq!(
super::CliError::from(rars::Error::InvalidHeader("invalid")).exit_code(),
1
);
for (name, label) in [
(b"QO".as_slice(), "quick-open"),
(b"RR", "recovery"),
(b"CMT", "comment"),
(b"ACL", "acl"),
(b"STM", "stream"),
(b"OTHER", "OTHER"),
] {
assert_eq!(super::service_label(name), label);
}
}
#[test]
fn archive_publication_failures_preserve_destinations_and_clean_staging() {
let root = crate::scratch::case("archive-publication-errors");
let archive = root.join("archive.rar");
std::fs::write(&archive, b"original").unwrap();
let error = super::write_archive_file(&archive, |output| {
output.write_all(b"partial")?;
Err::<(), _>(super::CliError::general("injected writer failure"))
})
.unwrap_err();
assert_eq!(error.to_string(), "injected writer failure");
assert_eq!(std::fs::read(&archive).unwrap(), b"original");
assert_eq!(std::fs::read_dir(&root).unwrap().count(), 1);
let directory = root.join("directory.rar");
std::fs::create_dir(&directory).unwrap();
assert!(super::write_archive_file(&directory, |output| {
output.write_all(b"new")?;
Ok(())
})
.is_err());
assert!(directory.is_dir());
assert_eq!(std::fs::read_dir(&root).unwrap().count(), 2);
assert!(super::create_streaming_archive_temp(std::path::Path::new("/")).is_err());
assert!(super::create_streaming_archive_temp(&root.join("missing/archive.rar")).is_err());
for sequence in 0..128u8 {
let temporary = root.join(format!(
".archive.rar.rars-writing-{}-{sequence:02x}",
std::process::id()
));
std::fs::write(temporary, b"occupied").unwrap();
}
assert_eq!(
super::create_streaming_archive_temp(&archive)
.unwrap_err()
.to_string(),
"could not allocate a unique temporary archive file"
);
let first = root.join(format!(
".archive.rar.rars-writing-{}-00",
std::process::id()
));
assert_eq!(std::fs::read(&first).unwrap(), b"occupied");
std::fs::remove_file(&first).unwrap();
let (temporary, output) = super::create_streaming_archive_temp(&archive).unwrap();
assert_eq!(temporary, first);
drop(output);
std::fs::remove_file(temporary).unwrap();
}
#[test]
fn archive_comments_escape_controls_and_preserve_text() {
for (bytes, expected) in [
(b"".as_slice(), ""),
(b"\0\0", ""),
(b"hello\r\n\0\0", "hello"),
(b"first\n\tsecond", "first\n\tsecond"),
(b"\x1b[31m\r\x7f", "\\x1b[31m\\x0d\\x7f"),
(b"invalid \xff", "invalid \u{fffd}"),
("café\u{0085}".as_bytes(), "café\\x85"),
] {
assert_eq!(super::render_comment_safe(bytes), expected);
super::print_comment(" ", bytes);
}
super::print_entry_table(std::iter::empty());
super::print_entry_table([(u64::MAX, 0, "large.txt".to_owned())]);
}
#[test]
fn size_and_thread_parsers_reject_invalid_and_overflowing_values() {
for value in ["", " ", "k", "M", "G", "-1", "1.5k", "nope"] {
assert!(super::parse_size_string(value).is_err(), "{value:?}");
}
assert_eq!(super::parse_size_string(" 2K ").unwrap(), 2048);
assert_eq!(super::parse_size_string("0").unwrap(), 0);
assert_eq!(
super::parse_size_string(&usize::MAX.to_string()).unwrap(),
usize::MAX
);
assert!(super::parse_size_string(&format!("{}k", usize::MAX))
.unwrap_err()
.contains("overflows usize"));
assert!(super::parse_size_string(&format!("{}0", usize::MAX)).is_err());
assert_eq!(super::parse_thread_count("1").unwrap(), 1);
assert_eq!(
super::parse_thread_count(&usize::MAX.to_string()).unwrap(),
usize::MAX
);
assert_eq!(
super::parse_thread_count("0").unwrap_err(),
"thread count must be at least 1"
);
for value in ["", "-1", " 1", "1k"] {
assert!(super::parse_thread_count(value).is_err(), "{value:?}");
}
}
fn stage_two_volumes(first_path: &std::path::Path) -> super::CliVolumeSink<'_> {
use rars::rar50::VolumeSink;
use std::io::Write;
let mut sink = super::CliVolumeSink {
first_path,
temporaries: Vec::new(),
};
for index in 0..2 {
sink.start_volume(index)
.unwrap()
.write_all(format!("new {index}").as_bytes())
.unwrap();
}
sink
}
#[test]
fn volume_publication_rolls_back_after_a_later_rename_fails() {
use std::fs;
for second_exists in [false, true] {
let root = crate::scratch::case("volume-rollback");
let first = root.join("archive.rar");
let paths: Vec<_> = (0..2)
.map(|i| rar50_volume_part_path(&first, i, 2).unwrap())
.collect();
fs::write(&paths[0], b"old 0").unwrap();
if second_exists {
fs::write(&paths[1], b"old 1").unwrap();
}
let result =
stage_two_volumes(&first).finish_with_rename(Ok(()), |source, destination| {
if destination == paths[1] {
return Err(std::io::Error::other("injected publication failure"));
}
fs::rename(source, destination)
});
assert!(result.is_err());
assert_eq!(fs::read(&paths[0]).unwrap(), b"old 0");
if second_exists {
assert_eq!(fs::read(&paths[1]).unwrap(), b"old 1");
} else {
assert!(!paths[1].exists());
}
assert_eq!(
fs::read_dir(&root).unwrap().count(),
1 + usize::from(second_exists)
);
}
}
#[test]
fn volume_publication_checks_destinations_before_replacing_any() {
use std::fs;
let root = crate::scratch::case("volume-preflight");
let first = root.join("archive.rar");
let paths: Vec<_> = (0..2)
.map(|i| rar50_volume_part_path(&first, i, 2).unwrap())
.collect();
fs::write(&paths[0], b"old").unwrap();
fs::create_dir(&paths[1]).unwrap();
assert!(stage_two_volumes(&first).finish(Ok(())).is_err());
assert_eq!(fs::read(&paths[0]).unwrap(), b"old");
assert!(paths[1].is_dir());
assert_eq!(fs::read_dir(&root).unwrap().count(), 2);
}
#[test]
fn failed_volume_rollback_retains_and_reports_the_old_archive_backup() {
use std::fs;
let root = crate::scratch::case("volume-rollback-failure");
let first = root.join("archive.rar");
let paths: Vec<_> = (0..2)
.map(|i| rar50_volume_part_path(&first, i, 2).unwrap())
.collect();
fs::write(&paths[0], b"old archive").unwrap();
let error = stage_two_volumes(&first)
.finish_with_rename(Ok(()), |source, destination| {
if destination == paths[1] {
fs::remove_file(&paths[0])?;
fs::create_dir(&paths[0])?;
return Err(std::io::Error::other("injected publication failure"));
}
fs::rename(source, destination)
})
.unwrap_err();
let remaining: Vec<_> = fs::read_dir(&root)
.unwrap()
.map(|entry| entry.unwrap().path())
.collect();
assert_eq!(remaining.len(), 2);
let backup = remaining.iter().find(|path| path.is_file()).unwrap();
assert_eq!(fs::read(backup).unwrap(), b"old archive");
assert!(error.to_string().contains("rollback incomplete"));
assert!(error.to_string().contains(&backup.display().to_string()));
}
#[test]
fn volume_staging_is_exclusive_and_cleaned_on_failure_or_drop() {
let root = crate::scratch::case("volume-cleanup");
let first = root.join("archive.rar");
let one = stage_two_volumes(&first);
let two = stage_two_volumes(&first);
assert!(one
.temporaries
.iter()
.all(|path| !two.temporaries.contains(path)));
assert!(one.finish(Err(Error::Cancelled)).is_err());
drop(two);
assert_eq!(std::fs::read_dir(&root).unwrap().count(), 0);
}
#[test]
fn volume_publication_replaces_existing_set_and_removes_backups() {
use std::fs;
let root = crate::scratch::case("volume-success");
let first = root.join("archive.rar");
for index in 0..2 {
fs::write(rar50_volume_part_path(&first, index, 2).unwrap(), b"old").unwrap();
}
let paths = stage_two_volumes(&first).finish(Ok(())).unwrap();
for (index, path) in paths.iter().enumerate() {
assert_eq!(fs::read(path).unwrap(), format!("new {index}").as_bytes());
}
assert_eq!(fs::read_dir(&root).unwrap().count(), 2);
}
use super::{display_text, parse_size, rar50_buffered_decode_limit_hint};
use crate::output::{checked_output_path, output_relative_path, redirection_warning};
use crate::password::{error_needs_password, should_prompt_password};
use crate::volumes::{infer_part_index, rar50_volume_part_path, volume_part_path};
use rars::Error;
use std::path::{Path, PathBuf};
#[test]
fn display_text_preserves_printable_unicode_but_escapes_control_chars() {
assert_eq!(display_text("ваапап"), "ваапап");
assert_eq!(display_text("日本語.txt"), "日本語.txt");
assert_eq!(display_text("a\u{1b}[31mb"), "a\\u{1b}[31mb");
assert_eq!(display_text("line\nfeed"), "line\\nfeed");
}
#[test]
fn infer_part_index_accepts_new_and_old_numbered_volume_names() {
assert_eq!(infer_part_index(Path::new("archive.part1.rar"), 4), Some(0));
assert_eq!(infer_part_index(Path::new("archive.part4.rar"), 4), Some(3));
assert_eq!(infer_part_index(Path::new("archive.part1foo.rar"), 4), None);
assert_eq!(infer_part_index(Path::new("archive.part1"), 4), None);
assert_eq!(infer_part_index(Path::new("archive.rar"), 4), Some(0));
assert_eq!(infer_part_index(Path::new("archive.r00"), 4), Some(1));
assert_eq!(infer_part_index(Path::new("archive.r02"), 4), Some(3));
assert_eq!(infer_part_index(Path::new("archive.r03"), 4), None);
}
#[test]
fn old_style_volume_writer_stops_at_r99() {
assert_eq!(
volume_part_path(Path::new("archive.rar"), 0).unwrap(),
PathBuf::from("archive.rar")
);
assert_eq!(
volume_part_path(Path::new("archive.rar"), 1).unwrap(),
PathBuf::from("archive.r00")
);
assert_eq!(
volume_part_path(Path::new("archive.rar"), 100).unwrap(),
PathBuf::from("archive.r99")
);
assert!(volume_part_path(Path::new("archive.rar"), 101).is_err());
}
#[test]
fn parse_size_accepts_binary_suffixes() {
assert_eq!(parse_size("10").unwrap(), 10);
assert_eq!(parse_size("10k").unwrap(), 10 * 1024);
assert_eq!(parse_size("10M").unwrap(), 10 * 1024 * 1024);
assert_eq!(parse_size("2g").unwrap(), 2 * 1024 * 1024 * 1024);
assert!(parse_size("m").is_err());
assert!(parse_size("").is_err());
}
#[test]
fn rar50_buffered_decode_limit_hint_names_cli_option() {
let error = Error::AtEntry {
name: b"large.bin".to_vec(),
operation: "decoding",
source: Box::new(Error::Rar50BufferedDecodeLimitExceeded {
limit: 512 * 1024 * 1024,
required: 900 * 1024 * 1024,
}),
};
assert_eq!(
rar50_buffered_decode_limit_hint(&error),
"\nhint: retry with --rar50-buffered-decode-limit 943718400 if you trust this archive and have enough memory"
);
let offset_context = Error::AtArchiveOffset {
offset: 123,
source: Box::new(error),
};
assert_eq!(
super::find_rar50_buffered_decode_limit_error(&offset_context),
Some((512 * 1024 * 1024, 900 * 1024 * 1024))
);
assert!(rar50_buffered_decode_limit_hint(&offset_context).contains("943718400"));
}
#[test]
fn password_prompt_is_gated_on_terminal_stdin() {
assert!(!should_prompt_password(false));
assert!(should_prompt_password(true));
}
#[test]
fn output_relative_path_accepts_plain_nested_names() {
assert_eq!(
output_relative_path(b"dir\\subdir/file.txt").unwrap(),
Path::new("dir").join("subdir").join("file.txt")
);
}
#[test]
fn output_relative_path_rejects_traversal_and_absolute_names() {
for name in [
b"../evil.txt".as_slice(),
b"safe/../../evil.txt",
b"/tmp/evil.txt",
b"//server/share/evil.txt",
b"\\server\\share\\evil.txt",
b"C:/evil.txt",
b"C:evil.txt",
b"",
b".",
b"./.",
b"bad\0name.txt",
] {
assert!(output_relative_path(name).is_err(), "{name:?}");
}
}
#[cfg(unix)]
#[test]
fn output_relative_path_preserves_non_utf8_archive_names() {
use std::os::unix::ffi::OsStrExt;
let path = output_relative_path(b"legacy-\xff-name.txt").unwrap();
assert_eq!(path.as_os_str().as_bytes(), b"legacy-\xff-name.txt");
}
#[cfg(unix)]
#[test]
fn open_output_writer_rejects_existing_symlink_components() {
let scratch = crate::scratch::case("rars-symlink-output");
let root = scratch.join("root");
let outside = scratch.join("outside");
std::fs::create_dir_all(&outside).unwrap();
std::fs::create_dir_all(&root).unwrap();
std::os::unix::fs::symlink(&outside, root.join("link")).unwrap();
assert!(matches!(
checked_output_path(&root, Path::new("link").join("escape.txt").as_path()),
Err(Error::InvalidHeader("unsafe archive path crosses symlink"))
));
assert!(!outside.join("escape.txt").exists());
}
#[test]
fn rar50_volume_part_path_does_not_duplicate_existing_part_suffix() {
assert_eq!(
rar50_volume_part_path(Path::new("archive.part01.rar"), 0, 20).unwrap(),
PathBuf::from("archive.part01.rar")
);
assert_eq!(
rar50_volume_part_path(Path::new("archive.part01.rar"), 1, 20).unwrap(),
PathBuf::from("archive.part02.rar")
);
assert_eq!(
rar50_volume_part_path(Path::new("archive.rar"), 0, 20).unwrap(),
PathBuf::from("archive.part01.rar")
);
}
#[test]
fn redirection_warning_names_unsupported_rar5_entry() {
let warning = redirection_warning("link");
assert!(warning.contains("RAR 5 redirection entry 'link'"));
assert!(warning.contains("not recreated"));
}
#[test]
fn detects_nested_need_password_errors_for_prompt_retry() {
let nested = Error::AtEntry {
name: b"secret.txt".to_vec(),
operation: "decoding",
source: Box::new(Error::NeedPassword),
};
assert!(error_needs_password(&nested));
assert!(!error_needs_password(&Error::InvalidHeader(
"not a password error"
)));
}
}