shimexe_core/
archive.rs

1use anyhow::{Context, Result};
2use std::fs;
3use std::path::{Path, PathBuf};
4use tracing::{debug, info, warn};
5use zip::ZipArchive;
6
7/// Archive extractor for handling compressed files
8pub struct ArchiveExtractor;
9
10impl ArchiveExtractor {
11    /// Check if a file is a supported archive format
12    pub fn is_archive(path: &Path) -> bool {
13        if let Some(extension) = path.extension() {
14            matches!(extension.to_str(), Some("zip"))
15        } else {
16            false
17        }
18    }
19
20    /// Check if a URL points to an archive file
21    pub fn is_archive_url(url: &str) -> bool {
22        url.ends_with(".zip")
23    }
24
25    /// Extract archive to destination directory and return list of extracted executables
26    pub fn extract_archive(archive_path: &Path, dest_dir: &Path) -> Result<Vec<PathBuf>> {
27        info!(
28            "Extracting archive: {} to {}",
29            archive_path.display(),
30            dest_dir.display()
31        );
32
33        // Create destination directory if it doesn't exist
34        fs::create_dir_all(dest_dir)
35            .with_context(|| format!("Failed to create directory: {}", dest_dir.display()))?;
36
37        let extension = archive_path
38            .extension()
39            .and_then(|ext| ext.to_str())
40            .unwrap_or("");
41
42        match extension {
43            "zip" => Self::extract_zip(archive_path, dest_dir),
44            _ => Err(anyhow::anyhow!("Unsupported archive format: {}", extension)),
45        }
46    }
47
48    /// Extract ZIP archive
49    fn extract_zip(archive_path: &Path, dest_dir: &Path) -> Result<Vec<PathBuf>> {
50        let file = fs::File::open(archive_path)
51            .with_context(|| format!("Failed to open archive: {}", archive_path.display()))?;
52
53        let mut archive = ZipArchive::new(file).with_context(|| "Failed to read ZIP archive")?;
54
55        let mut executables = Vec::new();
56
57        for i in 0..archive.len() {
58            let mut file = archive
59                .by_index(i)
60                .with_context(|| format!("Failed to read file at index {}", i))?;
61
62            let outpath = match file.enclosed_name() {
63                Some(path) => dest_dir.join(path),
64                None => {
65                    warn!("Skipping file with invalid name at index {}", i);
66                    continue;
67                }
68            };
69
70            debug!("Extracting: {}", outpath.display());
71
72            if file.is_dir() {
73                // Create directory
74                fs::create_dir_all(&outpath).with_context(|| {
75                    format!("Failed to create directory: {}", outpath.display())
76                })?;
77            } else {
78                // Create parent directories if needed
79                if let Some(parent) = outpath.parent() {
80                    fs::create_dir_all(parent).with_context(|| {
81                        format!("Failed to create parent directory: {}", parent.display())
82                    })?;
83                }
84
85                // Extract file
86                let mut outfile = fs::File::create(&outpath)
87                    .with_context(|| format!("Failed to create file: {}", outpath.display()))?;
88
89                std::io::copy(&mut file, &mut outfile)
90                    .with_context(|| format!("Failed to extract file: {}", outpath.display()))?;
91
92                // Make executable on Unix systems
93                #[cfg(unix)]
94                {
95                    use std::os::unix::fs::PermissionsExt;
96                    if Self::is_executable_file(&outpath) {
97                        let mut perms = fs::metadata(&outpath)?.permissions();
98                        perms.set_mode(0o755); // rwxr-xr-x
99                        fs::set_permissions(&outpath, perms)?;
100                    }
101                }
102
103                // Check if this is an executable file
104                if Self::is_executable_file(&outpath) {
105                    executables.push(outpath);
106                }
107            }
108        }
109
110        info!(
111            "Extracted {} files, found {} executables",
112            archive.len(),
113            executables.len()
114        );
115        Ok(executables)
116    }
117
118    /// Check if a file is an executable based on its extension
119    fn is_executable_file(path: &Path) -> bool {
120        if let Some(extension) = path.extension() {
121            match extension.to_str() {
122                Some("exe") | Some("bin") | Some("app") => true,
123                Some(ext) if cfg!(unix) => {
124                    // On Unix, also check for files without extension that might be executables
125                    ext.is_empty() || matches!(ext, "sh" | "bash" | "zsh" | "fish")
126                }
127                _ => false,
128            }
129        } else {
130            // Files without extension might be executables on Unix
131            cfg!(unix)
132        }
133    }
134
135    /// Find all executable files in a directory (non-recursive)
136    pub fn find_executables_in_dir(dir: &Path) -> Result<Vec<PathBuf>> {
137        let mut executables = Vec::new();
138
139        if !dir.exists() || !dir.is_dir() {
140            return Ok(executables);
141        }
142
143        let entries = fs::read_dir(dir)
144            .with_context(|| format!("Failed to read directory: {}", dir.display()))?;
145
146        for entry in entries {
147            let entry = entry?;
148            let path = entry.path();
149
150            if path.is_file() && Self::is_executable_file(&path) {
151                executables.push(path);
152            }
153        }
154
155        Ok(executables)
156    }
157
158    /// Generate a unique name for an executable based on its filename
159    pub fn generate_shim_name(executable_path: &Path, existing_names: &[String]) -> String {
160        let base_name = executable_path
161            .file_stem()
162            .and_then(|name| name.to_str())
163            .unwrap_or("unknown")
164            .to_string();
165
166        // If the base name is unique, use it
167        if !existing_names.contains(&base_name) {
168            return base_name;
169        }
170
171        // Otherwise, append a number to make it unique
172        for i in 1..=999 {
173            let candidate = format!("{}-{}", base_name, i);
174            if !existing_names.contains(&candidate) {
175                return candidate;
176            }
177        }
178
179        // Fallback: use timestamp
180        format!("{}-{}", base_name, chrono::Utc::now().timestamp())
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use std::fs::File;
188    use std::io::Write;
189    use tempfile::TempDir;
190    use zip::write::{SimpleFileOptions, ZipWriter};
191
192    fn create_test_zip(temp_dir: &Path) -> Result<PathBuf> {
193        let zip_path = temp_dir.join("test.zip");
194        let file = File::create(&zip_path)?;
195        let mut zip = ZipWriter::new(file);
196
197        // Add a test executable
198        zip.start_file("test.exe", SimpleFileOptions::default())?;
199        zip.write_all(b"fake executable content")?;
200
201        // Add a regular file
202        zip.start_file("readme.txt", SimpleFileOptions::default())?;
203        zip.write_all(b"This is a readme file")?;
204
205        // Add a directory
206        zip.add_directory("subdir/", SimpleFileOptions::default())?;
207
208        // Add another executable in subdirectory
209        zip.start_file("subdir/tool.exe", SimpleFileOptions::default())?;
210        zip.write_all(b"another fake executable")?;
211
212        zip.finish()?;
213        Ok(zip_path)
214    }
215
216    #[test]
217    fn test_is_archive() {
218        assert!(ArchiveExtractor::is_archive(Path::new("test.zip")));
219        assert!(!ArchiveExtractor::is_archive(Path::new("test.exe")));
220        assert!(!ArchiveExtractor::is_archive(Path::new("test")));
221    }
222
223    #[test]
224    fn test_is_archive_url() {
225        assert!(ArchiveExtractor::is_archive_url(
226            "https://example.com/file.zip"
227        ));
228        assert!(!ArchiveExtractor::is_archive_url(
229            "https://example.com/file.exe"
230        ));
231    }
232
233    #[test]
234    fn test_extract_zip() -> Result<()> {
235        let temp_dir = TempDir::new()?;
236        let zip_path = create_test_zip(temp_dir.path())?;
237
238        let extract_dir = temp_dir.path().join("extracted");
239        let executables = ArchiveExtractor::extract_archive(&zip_path, &extract_dir)?;
240
241        // Should find 2 executables
242        assert_eq!(executables.len(), 2);
243
244        // Check that files were extracted
245        assert!(extract_dir.join("test.exe").exists());
246        assert!(extract_dir.join("readme.txt").exists());
247        assert!(extract_dir.join("subdir").is_dir());
248        assert!(extract_dir.join("subdir/tool.exe").exists());
249
250        Ok(())
251    }
252
253    #[test]
254    fn test_generate_shim_name() {
255        let path = Path::new("test.exe");
256        let existing = vec![];
257        assert_eq!(
258            ArchiveExtractor::generate_shim_name(path, &existing),
259            "test"
260        );
261
262        let existing = vec!["test".to_string()];
263        assert_eq!(
264            ArchiveExtractor::generate_shim_name(path, &existing),
265            "test-1"
266        );
267
268        let existing = vec!["test".to_string(), "test-1".to_string()];
269        assert_eq!(
270            ArchiveExtractor::generate_shim_name(path, &existing),
271            "test-2"
272        );
273    }
274}