1use std::fmt;
2use std::fs;
3use std::io::ErrorKind;
4use std::panic::{catch_unwind, AssertUnwindSafe};
5use std::path::{Path, PathBuf};
6
7use crate::contents::enums::{self, FileType};
8use crate::modules;
9
10#[derive(Clone)]
11pub struct CompressRequest {
12 pub file_type: FileType,
13 pub input: PathBuf,
14 pub output: PathBuf,
15 pub password: Option<String>,
17}
18
19#[derive(Clone)]
20pub struct DecompressRequest {
21 pub input: PathBuf,
22 pub output: PathBuf,
23 pub level: i8,
24 pub password: Option<String>,
26}
27
28impl fmt::Debug for CompressRequest {
31 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32 f.debug_struct("CompressRequest")
33 .field("file_type", &self.file_type)
34 .field("input", &self.input)
35 .field("output", &self.output)
36 .field("password", &self.password.as_ref().map(|_| "<redacted>"))
37 .finish()
38 }
39}
40
41impl fmt::Debug for DecompressRequest {
42 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43 f.debug_struct("DecompressRequest")
44 .field("input", &self.input)
45 .field("output", &self.output)
46 .field("level", &self.level)
47 .field("password", &self.password.as_ref().map(|_| "<redacted>"))
48 .finish()
49 }
50}
51
52#[derive(Debug, Clone)]
53pub struct OperationResult {
54 pub output_path: PathBuf,
55 pub message: String,
56}
57
58#[derive(Debug)]
59pub enum MagicPackError {
60 Io(std::io::Error),
61 UnsupportedFileType,
62 InvalidInput(String),
63 OperationFailed(String),
64}
65
66impl fmt::Display for MagicPackError {
67 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68 match self {
69 MagicPackError::Io(err) => write!(f, "{}", err),
70 MagicPackError::UnsupportedFileType => write!(f, "unsupported file type"),
71 MagicPackError::InvalidInput(message) => write!(f, "{}", message),
72 MagicPackError::OperationFailed(message) => write!(f, "{}", message),
73 }
74 }
75}
76
77impl std::error::Error for MagicPackError {}
78
79impl From<std::io::Error> for MagicPackError {
80 fn from(err: std::io::Error) -> Self {
81 match err.kind() {
82 ErrorKind::Unsupported => MagicPackError::UnsupportedFileType,
83 _ => MagicPackError::Io(err),
84 }
85 }
86}
87
88pub fn supported_formats() -> Vec<&'static str> {
89 vec![
90 "zip", "tar", "bz2", "gz", "tar.bz2", "tar.gz", "7z", "xz", "tar.xz", "zst", "tar.zst",
91 "lz4", "tar.lz4", "upx",
92 ]
93}
94
95pub fn detect_file_type(path: &Path) -> Result<FileType, MagicPackError> {
96 modules::get_file_type(&path.to_path_buf()).map_err(MagicPackError::from)
97}
98
99pub fn compress(req: CompressRequest) -> Result<OperationResult, MagicPackError> {
100 validate_compress_request(&req)?;
101
102 let output_path = if req.output == Path::new(".") {
103 default_compress_output_path(&req.input, &req.output, req.file_type)?
104 } else {
105 req.output.clone()
106 };
107
108 let password = req.password.clone();
109 run_operation("compress", || {
110 modules::compress_with_password(
111 req.file_type,
112 &req.input,
113 &output_path,
114 password.as_deref(),
115 );
116 })?;
117
118 Ok(OperationResult {
119 output_path,
120 message: format!(
121 "compressed as {}",
122 enums::get_file_type_string(req.file_type)
123 ),
124 })
125}
126
127pub fn decompress(req: DecompressRequest) -> Result<OperationResult, MagicPackError> {
128 validate_decompress_request(&req)?;
129
130 if req.output != Path::new(".") {
131 fs::create_dir_all(&req.output)?;
132 }
133
134 let initial_type = detect_file_type(&req.input)?;
139 if initial_type.is_executable_packer() {
140 return decompress_executable_packer(&req, initial_type);
141 }
142
143 let src_filename = req.input.file_stem().ok_or_else(|| {
144 MagicPackError::InvalidInput("input path must include a file name".into())
145 })?;
146
147 let mut decompress_output = req.output.join(src_filename);
148 let mut decompress_input = req.input.clone();
149 let filename = decompress_output.file_name().ok_or_else(|| {
150 MagicPackError::InvalidInput("output path must include a file name".into())
151 })?;
152 let mg_filename = format!("mg_{}", filename.to_string_lossy());
153 decompress_output.set_file_name(mg_filename);
154
155 for index in 0..req.level {
156 let file_type = match detect_file_type(&decompress_input) {
157 Ok(file_type) => file_type,
158 Err(MagicPackError::UnsupportedFileType) if index != 0 => break,
159 Err(err) => return Err(err),
160 };
161
162 let current_output = decompress_output.clone();
163 let password = req.password.clone();
164 run_operation("decompress", || {
165 modules::decompress_with_password(
166 file_type,
167 &decompress_input,
168 ¤t_output,
169 password.as_deref(),
170 );
171 })?;
172 decompress_input = current_output;
173 let temp_filename = decompress_input.file_stem().ok_or_else(|| {
174 MagicPackError::InvalidInput("decompressed output must include a file name".into())
175 })?;
176 decompress_output.set_file_name(temp_filename);
177 }
178
179 let final_filename = decompress_input
180 .file_name()
181 .ok_or_else(|| {
182 MagicPackError::InvalidInput("decompressed output must include a file name".into())
183 })?
184 .to_string_lossy()
185 .replace("mg_", "");
186 let mut final_output = decompress_input.clone();
187 final_output.set_file_name(final_filename);
188 fs::rename(&decompress_input, &final_output)?;
189
190 Ok(OperationResult {
191 output_path: final_output,
192 message: String::from("decompressed"),
193 })
194}
195
196fn validate_compress_request(req: &CompressRequest) -> Result<(), MagicPackError> {
197 if !req.input.exists() {
198 return Err(MagicPackError::InvalidInput(format!(
199 "input path does not exist: {}",
200 req.input.display()
201 )));
202 }
203
204 if req.output == Path::new(".") {
205 return Ok(());
206 }
207
208 if let Some(parent) = req.output.parent() {
209 if !parent.as_os_str().is_empty() {
210 fs::create_dir_all(parent)?;
211 }
212 }
213
214 Ok(())
215}
216
217fn validate_decompress_request(req: &DecompressRequest) -> Result<(), MagicPackError> {
218 if !req.input.exists() {
219 return Err(MagicPackError::InvalidInput(format!(
220 "input path does not exist: {}",
221 req.input.display()
222 )));
223 }
224
225 if req.level <= 0 {
226 return Err(MagicPackError::InvalidInput(
227 "decompress level must be greater than 0".into(),
228 ));
229 }
230
231 Ok(())
232}
233
234fn default_compress_output_path(
235 src_path: &Path,
236 dst_path: &Path,
237 file_type: FileType,
238) -> Result<PathBuf, MagicPackError> {
239 if file_type.is_executable_packer() {
240 return default_packer_compress_output_path(src_path, dst_path, file_type);
241 }
242 let filename = src_path.file_stem().ok_or_else(|| {
243 MagicPackError::InvalidInput("input path must include a file name".into())
244 })?;
245 let mut temp_output = dst_path.join(filename);
246 temp_output.set_extension(enums::get_file_type_string(file_type));
247 Ok(temp_output)
248}
249
250fn default_packer_compress_output_path(
254 src_path: &Path,
255 dst_path: &Path,
256 file_type: FileType,
257) -> Result<PathBuf, MagicPackError> {
258 let filename = src_path.file_name().ok_or_else(|| {
259 MagicPackError::InvalidInput("input path must include a file name".into())
260 })?;
261 let format_str = enums::get_file_type_string(file_type);
262 let name_path = Path::new(filename);
263 let new_name = match (name_path.file_stem(), name_path.extension()) {
264 (Some(stem), Some(ext)) => format!(
265 "{}.{}.{}",
266 stem.to_string_lossy(),
267 format_str,
268 ext.to_string_lossy()
269 ),
270 _ => format!("{}.{}", filename.to_string_lossy(), format_str),
271 };
272 Ok(dst_path.join(new_name))
273}
274
275fn derive_packer_decompressed_name(input: &Path) -> Result<String, MagicPackError> {
278 let filename = input
279 .file_name()
280 .ok_or_else(|| MagicPackError::InvalidInput("input path must include a file name".into()))?
281 .to_string_lossy()
282 .into_owned();
283
284 if let Some(idx) = filename.find(".upx.") {
285 let mut result = String::with_capacity(filename.len() - 4);
286 result.push_str(&filename[..idx]);
287 result.push_str(&filename[idx + 4..]);
288 return Ok(result);
289 }
290 if let Some(stripped) = filename.strip_suffix(".upx") {
291 return Ok(stripped.to_string());
292 }
293
294 let path = Path::new(&filename);
295 match (path.file_stem(), path.extension()) {
296 (Some(stem), Some(ext)) => Ok(format!(
297 "{}.unpacked.{}",
298 stem.to_string_lossy(),
299 ext.to_string_lossy()
300 )),
301 _ => Ok(format!("{}.unpacked", filename)),
302 }
303}
304
305fn decompress_executable_packer(
306 req: &DecompressRequest,
307 file_type: FileType,
308) -> Result<OperationResult, MagicPackError> {
309 let dst_filename = derive_packer_decompressed_name(&req.input)?;
310 let dst_path = req.output.join(&dst_filename);
311 let dst_clone = dst_path.clone();
312 let input = req.input.clone();
313 run_operation("decompress", move || {
315 modules::decompress(file_type, &input, &dst_clone);
316 })?;
317 Ok(OperationResult {
318 output_path: dst_path,
319 message: String::from("decompressed"),
320 })
321}
322
323fn run_operation<F>(label: &str, operation: F) -> Result<(), MagicPackError>
324where
325 F: FnOnce(),
326{
327 catch_unwind(AssertUnwindSafe(operation)).map_err(|panic_payload| {
328 let message = if let Some(message) = panic_payload.downcast_ref::<&str>() {
329 (*message).to_string()
330 } else if let Some(message) = panic_payload.downcast_ref::<String>() {
331 message.clone()
332 } else {
333 format!("{} failed", label)
334 };
335 MagicPackError::OperationFailed(message)
336 })
337}
338
339#[cfg(test)]
340mod packer_filename_tests {
341 use super::*;
342
343 #[test]
344 fn compress_default_preserves_extension() {
345 let cwd = Path::new(".");
346 let cases = [
347 ("foo.exe", "./foo.upx.exe"),
348 ("foo", "./foo.upx"),
349 ("a.b.c.exe", "./a.b.c.upx.exe"),
350 ];
351 for (input, expected) in cases {
352 let got =
353 default_packer_compress_output_path(Path::new(input), cwd, FileType::Upx).unwrap();
354 assert_eq!(got, PathBuf::from(expected), "input={}", input);
355 }
356 }
357
358 #[test]
359 fn decompress_strips_upx_infix_or_adds_unpacked() {
360 let cases = [
361 ("foo.upx.exe", "foo.exe"),
362 ("foo.upx", "foo"),
363 ("a.b.upx.c", "a.b.c"),
364 ("foo.exe", "foo.unpacked.exe"),
365 ("foo", "foo.unpacked"),
366 ("a.b.c.exe", "a.b.c.unpacked.exe"),
367 ];
368 for (input, expected) in cases {
369 let got = derive_packer_decompressed_name(Path::new(input)).unwrap();
370 assert_eq!(got, expected, "input={}", input);
371 }
372 }
373
374 #[test]
375 fn is_executable_packer_predicate_table() {
376 for (variant, expected) in [
379 (FileType::Zip, false),
380 (FileType::Tar, false),
381 (FileType::Bz2, false),
382 (FileType::Gz, false),
383 (FileType::Tarbz2, false),
384 (FileType::Targz, false),
385 (FileType::SevenZ, false),
386 (FileType::Xz, false),
387 (FileType::Tarxz, false),
388 (FileType::Zst, false),
389 (FileType::Tarzst, false),
390 (FileType::Lz4, false),
391 (FileType::Tarlz4, false),
392 (FileType::Upx, true),
393 ] {
394 assert_eq!(
395 variant.is_executable_packer(),
396 expected,
397 "variant={:?}",
398 variant
399 );
400 }
401 }
402}