use std::{
collections::{BTreeMap, HashMap},
io::Read,
path::{Path, PathBuf},
process::{ExitStatus, Stdio},
thread,
time::{Duration, Instant},
};
use super::DESCRIPTOR;
use crate::{
ByteSourceHandle, Error, FileDataSource, NamespaceDirectoryEntry, NamespaceNodeId,
NamespaceNodeKind, NamespaceNodeRecord, Result, SourceHints,
archives::{
Archive,
cache::{ArchiveCachePaths, ensure_cache_space, prepare_archive_cache, reset_extract_dir},
tool::find_tool,
},
};
const ROOT_ENTRY_ID: u64 = 0;
const LIST_TIMEOUT: Duration = Duration::from_secs(15);
const EXTRACT_TIMEOUT: Duration = Duration::from_secs(60);
pub struct RarArchive {
entries: Vec<RarEntry>,
path_to_id: HashMap<String, NamespaceNodeId>,
cache: ArchiveCachePaths,
total_uncompressed_size: u64,
locked: bool,
headers_locked: bool,
}
#[derive(Clone)]
struct RarEntry {
record: NamespaceNodeRecord,
children: Vec<NamespaceDirectoryEntry>,
extracted_path: Option<PathBuf>,
}
#[derive(Clone)]
struct RarListingEntry {
path: String,
kind: NamespaceNodeKind,
size: u64,
encrypted: bool,
}
impl RarArchive {
pub fn open(source: ByteSourceHandle) -> Result<Self> {
Self::open_with_hints(source, SourceHints::new())
}
pub fn open_with_hints(source: ByteSourceHandle, _hints: SourceHints<'_>) -> Result<Self> {
let cache = prepare_archive_cache(source.as_ref(), "rar")?;
match list_archive(&cache.source_path, None) {
Ok(listing) => {
let total_uncompressed_size = listing.iter().map(|entry| entry.size).sum();
let locked = listing.iter().any(|entry| entry.encrypted);
let (entries, path_to_id) = build_tree(&listing, Some(&cache.extract_dir))?;
let mut archive = Self {
entries,
path_to_id,
cache,
total_uncompressed_size,
locked,
headers_locked: false,
};
if !archive.locked {
archive.extract(None)?;
}
Ok(archive)
}
Err(Error::InvalidSourceReference(message))
if message.contains("encrypted headers")
|| message.contains("Wrong password")
|| message.contains("Can not open encrypted archive")
|| message.contains("Break signaled") =>
{
Ok(Self {
entries: vec![RarEntry {
record: NamespaceNodeRecord::new(
NamespaceNodeId::from_u64(ROOT_ENTRY_ID),
NamespaceNodeKind::Directory,
0,
),
children: Vec::new(),
extracted_path: None,
}],
path_to_id: HashMap::new(),
cache,
total_uncompressed_size: 0,
locked: true,
headers_locked: true,
})
}
Err(error) => Err(error),
}
}
pub fn find_entry_by_path(&self, path: &str) -> Option<NamespaceNodeId> {
self.path_to_id.get(path).cloned()
}
#[allow(dead_code)]
fn populate_listing(&mut self, password: &str) -> Result<()> {
let listing = list_archive(&self.cache.source_path, Some(password))?;
self.total_uncompressed_size = listing.iter().map(|entry| entry.size).sum();
let (entries, path_to_id) = build_tree(&listing, Some(&self.cache.extract_dir))?;
self.entries = entries;
self.path_to_id = path_to_id;
self.headers_locked = false;
Ok(())
}
fn extract(&mut self, password: Option<&str>) -> Result<()> {
ensure_cache_space(&self.cache.extract_dir, self.total_uncompressed_size)?;
reset_extract_dir(&self.cache.extract_dir)?;
if let Ok(tool) = find_tool(&["unrar"]) {
let output = extract_with_tool(
&tool,
&self.cache.source_path,
&self.cache.extract_dir,
password,
)?;
if output.status.success() {
self.locked = false;
self.refresh_extracted_paths();
return Ok(());
}
return Err(Error::invalid_source_reference(if password.is_some() {
"rar archive password unlock failed"
} else {
"unable to extract rar archive into cache"
}));
}
if let Ok(tool) = find_tool(&["unar"]) {
let output = extract_with_unar_tool(
&tool,
&self.cache.source_path,
&self.cache.extract_dir,
password,
)?;
if output.status.success() {
self.locked = false;
self.refresh_extracted_paths();
return Ok(());
}
return Err(Error::invalid_source_reference(if password.is_some() {
"rar archive password unlock failed"
} else {
"unable to extract rar archive into cache"
}));
}
let tool = find_tool(&["7z", "7zz"])?;
let mut command = std::process::Command::new(&tool);
command
.arg("x")
.arg("-y")
.arg("-bb0")
.arg("-bso0")
.arg("-bsp0")
.arg(format!("-o{}", self.cache.extract_dir.display()));
if let Some(password) = password {
command.arg(format!("-p{password}"));
}
command.arg(&self.cache.source_path);
command.stdout(Stdio::null());
let output = run_command_with_timeout(command, EXTRACT_TIMEOUT)?;
if !output.status.success() {
return Err(Error::invalid_source_reference(if password.is_some() {
"rar archive password unlock failed"
} else {
"unable to extract rar archive into cache"
}));
}
self.locked = false;
self.refresh_extracted_paths();
Ok(())
}
fn refresh_extracted_paths(&mut self) {
for entry in self.entries.iter_mut().skip(1) {
entry.extracted_path = if entry.record.kind == NamespaceNodeKind::File {
Some(self.cache.extract_dir.join(&entry.record.path))
} else {
None
};
}
}
fn entry_ref(&self, entry_id: &NamespaceNodeId) -> Result<&RarEntry> {
let index = entry_id_to_index(entry_id)?;
self
.entries
.get(index)
.ok_or_else(|| Error::not_found(format!("missing rar archive entry index: {index}")))
}
}
impl Archive for RarArchive {
fn descriptor(&self) -> crate::FormatDescriptor {
DESCRIPTOR
}
fn root_entry_id(&self) -> NamespaceNodeId {
NamespaceNodeId::from_u64(ROOT_ENTRY_ID)
}
fn entry(&self, entry_id: &NamespaceNodeId) -> Result<NamespaceNodeRecord> {
if self.headers_locked {
return Err(Error::invalid_source_reference(
"rar archive headers are encrypted; unlock the archive before reading entries".to_string(),
));
}
Ok(self.entry_ref(entry_id)?.record.clone())
}
fn read_dir(&self, directory_id: &NamespaceNodeId) -> Result<Vec<NamespaceDirectoryEntry>> {
if self.headers_locked {
return Err(Error::invalid_source_reference(
"rar archive headers are encrypted; unlock the archive before listing entries".to_string(),
));
}
let entry = self.entry_ref(directory_id)?;
if entry.record.kind != NamespaceNodeKind::Directory {
return Err(Error::invalid_format(
"rar directory reads require a directory entry".to_string(),
));
}
Ok(entry.children.clone())
}
fn open_file(&self, entry_id: &NamespaceNodeId) -> Result<ByteSourceHandle> {
if self.locked {
return Err(Error::invalid_source_reference(
"rar archive is locked; unlock it with a password before opening files".to_string(),
));
}
let entry = self.entry_ref(entry_id)?;
if entry.record.kind != NamespaceNodeKind::File {
return Err(Error::invalid_format(
"rar file opens require a regular file entry".to_string(),
));
}
let path = entry.extracted_path.as_ref().ok_or_else(|| {
Error::invalid_format("rar file entry does not have a cached extraction path")
})?;
Ok(std::sync::Arc::new(FileDataSource::open(path)?) as ByteSourceHandle)
}
fn is_locked(&self) -> bool {
self.locked || self.headers_locked
}
fn unlock_with_password(&mut self, password: &str) -> Result<bool> {
if !self.headers_locked && !self.locked {
return Ok(true);
}
if self.headers_locked {
match self.populate_listing(password) {
Ok(()) => {}
Err(Error::InvalidSourceReference(_)) => return Ok(false),
Err(error) => return Err(error),
}
}
match self.extract(Some(password)) {
Ok(()) => Ok(true),
Err(Error::InvalidSourceReference(_)) => Ok(false),
Err(error) => Err(error),
}
}
}
fn list_archive(source_path: &Path, password: Option<&str>) -> Result<Vec<RarListingEntry>> {
let tool = find_tool(&["7z", "7zz"])?;
let mut command = std::process::Command::new(&tool);
command.arg("l").arg("-slt");
if let Some(password) = password {
command.arg(format!("-p{password}"));
}
command.arg(source_path);
let output = run_command_with_timeout(command, LIST_TIMEOUT)?;
if !output.status.success() {
return Err(Error::invalid_source_reference(format!(
"unable to list rar archive contents: {}",
String::from_utf8_lossy(&output.stderr)
)));
}
parse_rar_listing(&String::from_utf8_lossy(&output.stdout))
}
fn extract_with_tool(
tool: &Path, source_path: &Path, extract_dir: &Path, password: Option<&str>,
) -> Result<CommandOutput> {
let mut command = std::process::Command::new(tool);
command
.arg("x")
.arg("-o+")
.arg(format!("-op{}", extract_dir.display()))
.arg(match password {
Some(password) => format!("-p{password}"),
None => "-p-".to_string(),
})
.arg(source_path);
run_command_with_timeout(command, EXTRACT_TIMEOUT)
}
fn extract_with_unar_tool(
tool: &Path, source_path: &Path, extract_dir: &Path, password: Option<&str>,
) -> Result<CommandOutput> {
let mut command = std::process::Command::new(tool);
command.arg("-f").arg("-D").arg("-q");
if let Some(password) = password {
command.arg("-p").arg(password);
}
command.arg("-o").arg(extract_dir).arg(source_path);
run_command_with_timeout(command, EXTRACT_TIMEOUT)
}
fn run_command_with_timeout(
mut command: std::process::Command, timeout: Duration,
) -> Result<CommandOutput> {
command.stdout(Stdio::piped());
command.stderr(Stdio::piped());
let mut child = command.spawn()?;
let mut stdout = child
.stdout
.take()
.ok_or_else(|| Error::invalid_source_reference("missing command stdout pipe"))?;
let mut stderr = child
.stderr
.take()
.ok_or_else(|| Error::invalid_source_reference("missing command stderr pipe"))?;
let start = Instant::now();
loop {
if let Some(status) = child.try_wait()? {
let mut stdout_bytes = Vec::new();
let mut stderr_bytes = Vec::new();
stdout.read_to_end(&mut stdout_bytes)?;
stderr.read_to_end(&mut stderr_bytes)?;
return Ok(CommandOutput {
status,
stdout: stdout_bytes,
stderr: stderr_bytes,
});
}
if start.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
return Err(Error::invalid_source_reference(format!(
"rar helper command timed out after {} seconds",
timeout.as_secs()
)));
}
thread::sleep(Duration::from_millis(50));
}
}
struct CommandOutput {
status: ExitStatus,
stdout: Vec<u8>,
stderr: Vec<u8>,
}
fn parse_rar_listing(text: &str) -> Result<Vec<RarListingEntry>> {
let mut entries = Vec::new();
let mut current = BTreeMap::<String, String>::new();
let mut in_entries = false;
for line in text.lines() {
let line = line.trim_end();
if line == "----------" {
in_entries = true;
continue;
}
if !in_entries {
continue;
}
if line.is_empty() {
if let Some(entry) = listing_entry_from_map(¤t)? {
entries.push(entry);
}
current.clear();
continue;
}
if let Some((key, value)) = line.split_once(" = ") {
current.insert(key.to_string(), value.to_string());
}
}
if let Some(entry) = listing_entry_from_map(¤t)? {
entries.push(entry);
}
Ok(entries)
}
fn listing_entry_from_map(map: &BTreeMap<String, String>) -> Result<Option<RarListingEntry>> {
let Some(path) = map.get("Path") else {
return Ok(None);
};
if map.get("Type").is_some() {
return Ok(None);
}
let is_dir = map.get("Folder").is_some_and(|value| value == "+")
|| map
.get("Attributes")
.is_some_and(|value| value.starts_with('D'));
let normalized = normalize_path(path, is_dir)?;
if normalized.is_empty() {
return Ok(None);
}
let size = map
.get("Size")
.and_then(|value| value.parse().ok())
.unwrap_or(0);
Ok(Some(RarListingEntry {
path: normalized,
kind: if is_dir {
NamespaceNodeKind::Directory
} else {
NamespaceNodeKind::File
},
size,
encrypted: map.get("Encrypted").is_some_and(|value| value == "+")
|| map
.get("Flags")
.is_some_and(|value| value.to_ascii_lowercase().contains("encrypted"))
|| map
.get("Method")
.is_some_and(|value| value.contains("AES") || value.contains("Crypt")),
}))
}
fn build_tree(
listing: &[RarListingEntry], extract_root: Option<&Path>,
) -> Result<(Vec<RarEntry>, HashMap<String, NamespaceNodeId>)> {
let mut builders = BTreeMap::<String, RarListingEntry>::new();
for entry in listing {
ensure_parent_directories(&mut builders, &entry.path)?;
builders.insert(entry.path.clone(), entry.clone());
}
let mut path_to_id = HashMap::new();
let ordered_paths = builders.keys().cloned().collect::<Vec<_>>();
for (index, path) in ordered_paths.iter().enumerate() {
path_to_id.insert(path.clone(), NamespaceNodeId::from_u64(index as u64 + 1));
}
let mut entries = Vec::with_capacity(ordered_paths.len() + 1);
entries.push(RarEntry {
record: NamespaceNodeRecord::new(
NamespaceNodeId::from_u64(ROOT_ENTRY_ID),
NamespaceNodeKind::Directory,
0,
),
children: Vec::new(),
extracted_path: None,
});
for path in &ordered_paths {
let entry = builders.get(path).ok_or_else(|| {
Error::invalid_format(format!("missing rar entry builder for path: {path}"))
})?;
let id = path_to_id.get(path).cloned().ok_or_else(|| {
Error::invalid_format(format!("missing rar entry identifier for path: {path}"))
})?;
entries.push(RarEntry {
record: NamespaceNodeRecord::new(id, entry.kind, entry.size).with_path(path.clone()),
children: Vec::new(),
extracted_path: extract_root
.and_then(|root| (entry.kind == NamespaceNodeKind::File).then(|| root.join(path))),
});
}
for path in &ordered_paths {
let child_id = path_to_id
.get(path)
.cloned()
.ok_or_else(|| Error::invalid_format(format!("missing rar path mapping for path: {path}")))?;
let child_index = entry_id_to_index(&child_id)?;
let child_kind = entries[child_index].record.kind;
let name = relative_name(path);
let parent_index = match parent_path(path) {
Some(parent) => entry_id_to_index(path_to_id.get(parent).ok_or_else(|| {
Error::invalid_format(format!("missing rar parent directory mapping: {parent}"))
})?)?,
None => 0,
};
entries[parent_index]
.children
.push(NamespaceDirectoryEntry::new(name, child_id, child_kind));
}
for entry in &mut entries {
entry
.children
.sort_by(|left, right| left.name.cmp(&right.name));
}
Ok((entries, path_to_id))
}
fn ensure_parent_directories(
builders: &mut BTreeMap<String, RarListingEntry>, path: &str,
) -> Result<()> {
let mut current = path;
while let Some(parent) = parent_path(current) {
builders
.entry(parent.to_string())
.or_insert_with(|| RarListingEntry {
path: parent.to_string(),
kind: NamespaceNodeKind::Directory,
size: 0,
encrypted: false,
});
current = parent;
}
Ok(())
}
fn normalize_path(path: &str, is_dir: bool) -> Result<String> {
let path = path.trim_matches('