1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
use crate::db::Database;
use crate::error::{Error, PostProcessError, Result};
use crate::types::DownloadId;
use std::path::{Path, PathBuf};
use tracing::{debug, info};
use super::password_list::PasswordList;
use super::shared::extract_with_passwords_impl;
/// Archive extractor for RAR files
pub struct RarExtractor;
impl RarExtractor {
/// Detect RAR archive files in a directory
///
/// Looks for .rar files or .r00, .r01, etc. (split archives)
/// Returns the main archive file (first part)
pub fn detect_rar_files(download_path: &Path) -> Result<Vec<PathBuf>> {
debug!(?download_path, "detecting RAR archives");
let mut archives = Vec::new();
// Read directory
let entries = std::fs::read_dir(download_path).map_err(|e| {
Error::Io(std::io::Error::other(format!(
"failed to read directory: {}",
e
)))
})?;
for entry in entries {
let entry = entry.map_err(|e| {
Error::Io(std::io::Error::other(format!(
"failed to read entry: {}",
e
)))
})?;
let path = entry.path();
// Skip directories
if path.is_dir() {
continue;
}
// Check for .rar extension
if let Some(ext) = path.extension() {
let ext_str = ext.to_string_lossy().to_lowercase();
// Main RAR file or first part of split archive
if ext_str == "rar" || ext_str == "r00" {
archives.push(path);
}
}
}
debug!("found {} RAR archive(s)", archives.len());
Ok(archives)
}
/// Test-only public accessor for `is_password_error`
#[cfg(test)]
pub(crate) fn is_password_error_pub(error_msg: &str) -> bool {
Self::is_password_error(error_msg)
}
/// Check if an unrar error indicates a password problem
fn is_password_error(error_msg: &str) -> bool {
error_msg.contains("password")
|| error_msg.contains("encrypted")
|| error_msg.contains("ERAR_BAD_PASSWORD")
}
/// Convert an unrar error to our error type, checking for password errors
fn convert_unrar_error(e: unrar::error::UnrarError, archive_path: &Path) -> Error {
let err_str = e.to_string();
if Self::is_password_error(&err_str) {
Error::PostProcess(PostProcessError::WrongPassword {
archive: archive_path.to_path_buf(),
})
} else {
Error::PostProcess(PostProcessError::ExtractionFailed {
archive: archive_path.to_path_buf(),
reason: err_str,
})
}
}
/// Try to extract a RAR archive with a single password
///
/// Returns Ok(extracted_files) on success
/// Returns Err with ExtractError::WrongPassword if password is incorrect
/// Returns Err with other errors for corrupt archives, disk full, etc.
pub fn try_extract(
archive_path: &Path,
password: &str,
dest_path: &Path,
) -> Result<Vec<PathBuf>> {
debug!(
?archive_path,
password_length = password.len(),
?dest_path,
"attempting RAR extraction"
);
// Create destination directory if it doesn't exist
std::fs::create_dir_all(dest_path).map_err(|e| {
Error::Io(std::io::Error::other(format!(
"failed to create destination: {}",
e
)))
})?;
// Create archive with optional password
let archive = if password.is_empty() {
unrar::Archive::new(archive_path)
} else {
unrar::Archive::with_password(archive_path, password.as_bytes())
};
// Open for processing
let processor = archive
.open_for_processing()
.map_err(|e| Self::convert_unrar_error(e, archive_path))?;
let mut extracted_files = Vec::new();
// Process each entry using the state machine interface
let mut at_header = processor;
loop {
// Read the next header - transitions to BeforeFile state
let at_file = match at_header.read_header() {
Ok(Some(entry_processor)) => entry_processor,
Ok(None) => break, // No more entries
Err(e) => return Err(Self::convert_unrar_error(e, archive_path)),
};
// Get the file header information (available in BeforeFile state)
let header = at_file.entry();
// Sanitize filename to prevent path traversal attacks (e.g., "../../../etc/passwd")
let sanitized = Path::new(&header.filename)
.components()
.filter(|c| matches!(c, std::path::Component::Normal(_)))
.collect::<PathBuf>();
if sanitized.as_os_str().is_empty() {
// Skip entries with no valid path components (e.g., pure ".." entries)
at_header = at_file.skip().map_err(|e| {
Error::PostProcess(PostProcessError::ExtractionFailed {
archive: archive_path.to_path_buf(),
reason: format!("failed to skip unsafe entry: {}", e),
})
})?;
continue;
}
let file_path = dest_path.join(&sanitized);
// Check if it's a file (not a directory)
if !header.is_directory() {
// Extract the file - transitions back to BeforeHeader state
at_header = at_file
.extract_to(&file_path)
.map_err(|e| Self::convert_unrar_error(e, archive_path))?;
extracted_files.push(file_path);
} else {
// Skip directory entries - transitions back to BeforeHeader state
at_header = at_file.skip().map_err(|e| {
Error::PostProcess(PostProcessError::ExtractionFailed {
archive: archive_path.to_path_buf(),
reason: format!("failed to skip directory: {}", e),
})
})?;
}
}
info!(
?archive_path,
extracted_count = extracted_files.len(),
"RAR extraction successful"
);
Ok(extracted_files)
}
/// Extract RAR archive with password attempts
///
/// Tries each password in the list until one works or all fail.
/// Caches the successful password in the database.
pub async fn extract_with_passwords(
download_id: DownloadId,
archive_path: &Path,
dest_path: &Path,
passwords: &PasswordList,
db: &Database,
) -> Result<Vec<PathBuf>> {
extract_with_passwords_impl(
"RAR",
Self::try_extract,
download_id,
archive_path,
dest_path,
passwords,
db,
)
.await
}
}