1use anyhow::{Context, Result};
2use std::fs;
3use std::path::{Path, PathBuf};
4use tracing::{debug, info, warn};
5use zip::ZipArchive;
6
7pub struct ArchiveExtractor;
9
10impl ArchiveExtractor {
11 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 pub fn is_archive_url(url: &str) -> bool {
22 url.ends_with(".zip")
23 }
24
25 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 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 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 fs::create_dir_all(&outpath).with_context(|| {
75 format!("Failed to create directory: {}", outpath.display())
76 })?;
77 } else {
78 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 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 #[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); fs::set_permissions(&outpath, perms)?;
100 }
101 }
102
103 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 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 ext.is_empty() || matches!(ext, "sh" | "bash" | "zsh" | "fish")
126 }
127 _ => false,
128 }
129 } else {
130 cfg!(unix)
132 }
133 }
134
135 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 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 !existing_names.contains(&base_name) {
168 return base_name;
169 }
170
171 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 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 zip.start_file("test.exe", SimpleFileOptions::default())?;
199 zip.write_all(b"fake executable content")?;
200
201 zip.start_file("readme.txt", SimpleFileOptions::default())?;
203 zip.write_all(b"This is a readme file")?;
204
205 zip.add_directory("subdir/", SimpleFileOptions::default())?;
207
208 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 assert_eq!(executables.len(), 2);
243
244 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}