Skip to main content

magic_pack/
service.rs

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    /// 7z AES-256 password. `None` for unencrypted. Redacted in Debug.
16    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    /// 7z AES-256 password. `None` for unencrypted. Redacted in Debug.
25    pub password: Option<String>,
26}
27
28// Manual Debug impls keep the password out of any `{:?}` output so it
29// can never reach a log line or error message via formatting.
30impl 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    // Executable packers (UPX) work file-to-file in a single step. The
135    // generic level-loop below assumes archive containers and uses
136    // file_stem() to strip extensions, which mangles binary filenames.
137    // Detect once up front and take a dedicated path for packers.
138    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                &current_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
250/// `foo.exe` → `foo.upx.exe`. `foo` (no extension) → `foo.upx`. Keeps
251/// the original extension so the produced binary stays runnable on the
252/// target platform.
253fn 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
275/// `foo.upx.exe` → `foo.exe`. `foo.exe` (no `.upx` infix) →
276/// `foo.unpacked.exe`. `foo` → `foo.unpacked`.
277fn 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    // UPX takes no password; the plain (no-encryption) entry point.
314    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        // Every variant must be classified explicitly — when a new
377        // variant is added the compiler-driven match prevents drift.
378        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}