Skip to main content

zoi_lua/api/
archive.rs

1use colored::*;
2use mlua::{self, Lua, Table};
3use std::path::{Path, PathBuf};
4use zoi_core::utils;
5
6use ar::Archive as ArArchive;
7use flate2::read::GzDecoder;
8use sevenz_rust;
9use std::fs;
10use xz2::read::XzDecoder;
11use zip::ZipArchive;
12use zstd::stream::read::Decoder as ZstdDecoder;
13
14/// Exposes the `UTILS.EXTRACT` function to the Lua environment.
15///
16/// This utility provides a unified interface for downloading and extracting
17/// various archive formats. It handles:
18/// - Remote Fetching: If the source starts with http(s), it downloads the file to `BUILD_DIR`.
19/// - Format Detection: Dispatches to the appropriate decoder (Zip, Tar, Zstd, Xz, 7z, etc.).
20/// - Error Propagation: Any failure (network, filesystem, or corruption) is converted
21///   into an `mlua::Error::RuntimeError`, which halts the Lua execution and is
22///   caught by the Rust build engine to trigger a rollback.
23pub fn add_extract_util(lua: &Lua, quiet: bool) -> Result<(), mlua::Error> {
24    let extract_fn =
25        lua.create_function(move |lua, (source, out_name): (String, Option<String>)| {
26            let build_dir_str: String = lua.globals().get("BUILD_DIR")?;
27            let build_dir = Path::new(&build_dir_str);
28
29            let archive_file = if source.starts_with("http") {
30                if source.starts_with("http://") && !quiet {
31                    println!("{}: downloading over insecure HTTP: {}", "Warning:".yellow(), source);
32                }
33                if !quiet {
34                    println!("Downloading: {}", source);
35                }
36                let file_name = source.split('/').next_back().unwrap_or("download.tmp");
37                let temp_path = build_dir.join(file_name);
38                let client = utils::get_http_client()
39                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
40                let mut attempt = 0u32;
41                let mut response = loop {
42                    attempt += 1;
43                    match client.get(&source).send() {
44                        Ok(resp) => break resp,
45                        Err(e) => {
46                            if attempt < 3 {
47                                if !quiet {
48                                    eprintln!("Download failed ({}). Retrying...", e);
49                                }
50                                zoi_core::utils::retry_backoff_sleep(attempt);
51                                continue;
52                            } else {
53                                return Err(mlua::Error::RuntimeError(e.to_string()));
54                            }
55                        }
56                    }
57                };
58
59                if !response.status().is_success() {
60                    return Err(mlua::Error::RuntimeError(format!("Failed to download {}: {}", source, response.status())));
61                }
62
63                let mut temp_file = fs::File::create(&temp_path)
64                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
65                std::io::copy(&mut response, &mut temp_file)
66                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
67
68                temp_path
69            } else {
70                PathBuf::from(source)
71            };
72
73            let out_dir_name = out_name.unwrap_or_else(|| "extracted".to_string());
74            let out_dir = build_dir.join(&out_dir_name);
75
76            if !out_dir.starts_with(build_dir) || out_dir == build_dir {
77                return Err(mlua::Error::RuntimeError(format!(
78                    "Invalid output directory: {}. Extraction must be into a subdirectory of the build directory.",
79                    out_dir_name
80                )));
81            }
82
83            fs::create_dir_all(&out_dir).map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
84
85            if !quiet {
86                println!(
87                    "Extracting {} to {}",
88                    archive_file.display(),
89                    out_dir.display()
90                );
91            }
92
93            let file = fs::File::open(&archive_file)
94                .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
95
96            let archive_path_str = archive_file.to_string_lossy();
97
98            if archive_path_str.ends_with(".zip") {
99                let mut archive =
100                    ZipArchive::new(file).map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
101                archive
102                    .extract(&out_dir)
103                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
104            } else if archive_path_str.ends_with(".tar.gz") || archive_path_str.ends_with(".tgz") {
105                let tar_gz = GzDecoder::new(file);
106                let mut archive = tar::Archive::new(tar_gz);
107                archive
108                    .unpack(&out_dir)
109                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
110            } else if archive_path_str.ends_with(".tar.zst") {
111                let tar_zst =
112                    ZstdDecoder::new(file).map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
113                let mut archive = tar::Archive::new(tar_zst);
114                archive
115                    .unpack(&out_dir)
116                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
117            } else if archive_path_str.ends_with(".tar.xz") {
118                let tar_xz = XzDecoder::new(file);
119                let mut archive = tar::Archive::new(tar_xz);
120                archive
121                    .unpack(&out_dir)
122                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
123            } else if archive_path_str.ends_with(".7z") {
124                sevenz_rust::decompress_file(&archive_file, &out_dir)
125                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
126            } else if archive_path_str.ends_with(".dmg") {
127                if !cfg!(target_os = "macos") {
128                    return Err(mlua::Error::RuntimeError(
129                        "Extracting .dmg files is only supported on macOS.".to_string(),
130                    ));
131                }
132                let output = std::process::Command::new("hdiutil")
133                    .arg("attach")
134                    .arg("-nobrowse")
135                    .arg("-readonly")
136                    .arg(&archive_file)
137                    .output()
138                    .map_err(|e| mlua::Error::RuntimeError(format!("Failed to execute hdiutil: {}", e)))?;
139                if !output.status.success() {
140                    let stderr = String::from_utf8_lossy(&output.stderr);
141                    return Err(mlua::Error::RuntimeError(format!("hdiutil failed: {}", stderr)));
142                }
143                let output_str = String::from_utf8_lossy(&output.stdout);
144                let mut mount_point = None;
145                for line in output_str.lines() {
146                    if line.contains("/Volumes/")
147                        && let Some(idx) = line.find("/Volumes/") {
148                            mount_point = Some(line[idx..].trim().to_string());
149                            break;
150                        }
151                }
152                let mount_point = mount_point.ok_or_else(|| {
153                    mlua::Error::RuntimeError("Failed to parse mount point from hdiutil output.".to_string())
154                })?;
155                let mount_path = std::path::Path::new(&mount_point);
156                if let Err(e) = zoi_core::utils::copy_dir_all(mount_path, &out_dir) {
157                    let _ = std::process::Command::new("hdiutil").arg("detach").arg(&mount_point).status();
158                    return Err(mlua::Error::RuntimeError(format!("Failed to copy contents from dmg: {}", e)));
159                }
160                let detach_status = std::process::Command::new("hdiutil")
161                    .arg("detach")
162                    .arg(&mount_point)
163                    .status()
164                    .map_err(|e| mlua::Error::RuntimeError(format!("Failed to execute hdiutil detach: {}", e)))?;
165                if !detach_status.success() {
166                    eprintln!("Warning: failed to detach dmg volume at {}", mount_point);
167                }
168            } else if archive_path_str.ends_with(".pkg") {
169                if !cfg!(target_os = "macos") {
170                    return Err(mlua::Error::RuntimeError(
171                        "Extracting .pkg files natively is only supported on macOS.".to_string(),
172                    ));
173                }
174                let temp_extract_dir = out_dir.join(".pkg_extract_tmp");
175                let status = std::process::Command::new("pkgutil")
176                    .arg("--expand-full")
177                    .arg(&archive_file)
178                    .arg(&temp_extract_dir)
179                    .status()
180                    .map_err(|e| mlua::Error::RuntimeError(format!("Failed to execute pkgutil: {}", e)))?;
181                if !status.success() {
182                    return Err(mlua::Error::RuntimeError("pkgutil failed to expand the package.".to_string()));
183                }
184                zoi_core::utils::copy_dir_all(&temp_extract_dir, &out_dir)
185                    .map_err(|e| mlua::Error::RuntimeError(format!("Failed to copy pkg contents: {}", e)))?;
186                let _ = fs::remove_dir_all(&temp_extract_dir);
187
188            } else if archive_path_str.ends_with(".rar") {
189                if zoi_core::utils::command_exists("unrar") {
190                    let status = std::process::Command::new("unrar")
191                        .arg("x")
192                        .arg("-y")
193                        .arg(&archive_file)
194                        .arg(&out_dir)
195                        .status()
196                        .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
197                    if !status.success() {
198                        return Err(mlua::Error::RuntimeError("unrar failed".to_string()));
199                    }
200                } else {
201                    return Err(mlua::Error::RuntimeError(
202                        "unrar command not found. Please install unrar to extract .rar files."
203                            .to_string(),
204                    ));
205                }
206            } else if archive_path_str.ends_with(".deb") {
207                let mut ar = ArArchive::new(file);
208                while let Some(entry_result) = ar.next_entry() {
209                    let mut entry =
210                        entry_result.map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
211                    let name = String::from_utf8_lossy(entry.header().identifier())
212                        .trim()
213                        .trim_end_matches('/')
214                        .to_string();
215                    if name.starts_with("data.tar") {
216                        let temp_data_path = build_dir.join(&name);
217                        let mut temp_file = fs::File::create(&temp_data_path)
218                            .map_err(|e| mlua::Error::RuntimeError(format!("Failed to create temp file for {}: {}", name, e)))?;
219                        std::io::copy(&mut entry, &mut temp_file)
220                            .map_err(|e| mlua::Error::RuntimeError(format!("Failed to copy entry data for {}: {}", name, e)))?;
221
222                        let data_file = fs::File::open(&temp_data_path)
223                            .map_err(|e| mlua::Error::RuntimeError(format!("Failed to reopen temp file for {}: {}", name, e)))?;
224                        if name.ends_with(".gz") {
225                            let mut archive = tar::Archive::new(GzDecoder::new(data_file));
226                            archive
227                                .unpack(&out_dir)
228                                .map_err(|e| mlua::Error::RuntimeError(format!("Failed to unpack {}: {}", name, e)))?;
229                        } else if name.ends_with(".xz") {
230                            let mut archive = tar::Archive::new(XzDecoder::new(data_file));
231                            archive
232                                .unpack(&out_dir)
233                                .map_err(|e| mlua::Error::RuntimeError(format!("Failed to unpack {}: {}", name, e)))?;
234                        } else if name.ends_with(".zst") {
235                            let mut archive = tar::Archive::new(
236                                ZstdDecoder::new(data_file)
237                                    .map_err(|e| mlua::Error::RuntimeError(format!("Failed to initialize zstd for {}: {}", name, e)))?,
238                            );
239                            archive
240                                .unpack(&out_dir)
241                                .map_err(|e| mlua::Error::RuntimeError(format!("Failed to unpack {}: {}", name, e)))?;
242                        }
243                        fs::remove_file(temp_data_path).ok();
244                    }
245                }
246            } else {
247                return Err(mlua::Error::RuntimeError(format!(
248                    "Unsupported archive format for file: {}",
249                    archive_path_str
250                )));
251            }
252
253            Ok(())
254        })?;
255
256    let utils_table: Table = lua.globals().get("UTILS")?;
257    utils_table.set("EXTRACT", extract_fn)?;
258
259    Ok(())
260}
261
262pub fn add_archive_util(lua: &Lua) -> Result<(), mlua::Error> {
263    let archive_table = lua.create_table()?;
264
265    let list_fn = lua.create_function(|lua, path: String| {
266        let p = Path::new(&path);
267        let actual_path = if p.exists() {
268            p.to_path_buf()
269        } else if let Ok(build_dir) = lua.globals().get::<String>("BUILD_DIR") {
270            Path::new(&build_dir).join(p)
271        } else {
272            p.to_path_buf()
273        };
274
275        let file = fs::File::open(&actual_path).map_err(|e| {
276            mlua::Error::RuntimeError(format!("Failed to open archive {:?}: {}", actual_path, e))
277        })?;
278        let mut files = Vec::new();
279
280        if path.ends_with(".zip") {
281            let mut archive =
282                ZipArchive::new(file).map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
283            for i in 0..archive.len() {
284                let file = archive
285                    .by_index(i)
286                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
287                files.push(file.name().to_string());
288            }
289        } else if path.ends_with(".tar.gz") || path.ends_with(".tgz") {
290            let tar_gz = GzDecoder::new(file);
291            let mut archive = tar::Archive::new(tar_gz);
292            for entry in archive
293                .entries()
294                .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?
295            {
296                let entry = entry.map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
297                files.push(
298                    entry
299                        .path()
300                        .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?
301                        .to_string_lossy()
302                        .to_string(),
303                );
304            }
305        } else if path.ends_with(".tar.zst") {
306            let tar_zst =
307                ZstdDecoder::new(file).map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
308            let mut archive = tar::Archive::new(tar_zst);
309            for entry in archive
310                .entries()
311                .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?
312            {
313                let entry = entry.map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
314                files.push(
315                    entry
316                        .path()
317                        .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?
318                        .to_string_lossy()
319                        .to_string(),
320                );
321            }
322        } else if path.ends_with(".tar.xz") {
323            let tar_xz = XzDecoder::new(file);
324            let mut archive = tar::Archive::new(tar_xz);
325            for entry in archive
326                .entries()
327                .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?
328            {
329                let entry = entry.map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
330                files.push(
331                    entry
332                        .path()
333                        .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?
334                        .to_string_lossy()
335                        .to_string(),
336                );
337            }
338        } else if path.ends_with(".7z") {
339            let file =
340                fs::File::open(&path).map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
341            let len = file
342                .metadata()
343                .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?
344                .len();
345            let reader = sevenz_rust::SevenZReader::new(file, len, sevenz_rust::Password::empty())
346                .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
347            for entry in &reader.archive().files {
348                files.push(entry.name.to_string());
349            }
350        } else if path.ends_with(".rar") {
351            if zoi_core::utils::command_exists("unrar") {
352                let output = std::process::Command::new("unrar")
353                    .arg("lb")
354                    .arg(&path)
355                    .output()
356                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
357                if output.status.success() {
358                    let list = String::from_utf8_lossy(&output.stdout);
359                    for line in list.lines() {
360                        files.push(line.to_string());
361                    }
362                }
363            }
364        } else if path.ends_with(".deb") {
365            let mut ar = ArArchive::new(file);
366            while let Some(entry_result) = ar.next_entry() {
367                let entry = entry_result.map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
368                let header = entry.header();
369                files.push(String::from_utf8_lossy(header.identifier()).to_string());
370            }
371        } else {
372            return Err(mlua::Error::RuntimeError(format!(
373                "Unsupported archive format: {}",
374                path
375            )));
376        }
377
378        Ok(files)
379    })?;
380    archive_table.set("list", list_fn)?;
381
382    let utils_table: Table = lua.globals().get("UTILS")?;
383    utils_table.set("ARCHIVE", archive_table)?;
384
385    Ok(())
386}