use std::path::{Path, PathBuf};
use crate::error::{LmcppError, LmcppResult};
pub fn download_and_extract_zip(url: &str, working_dir: &Path, zip_file_name: &str) -> LmcppResult<()> {
std::fs::create_dir_all(working_dir).map_err(|e| {
LmcppError::file_system("create working directory", working_dir.to_path_buf(), e)
})?;
let zip_path = download_zip(url, working_dir, zip_file_name)?;
extract_zip(working_dir, &zip_path)?;
std::fs::remove_file(&zip_path).map_err(|e| {
LmcppError::file_system("remove zip file after extraction", zip_path.clone(), e)
})?;
Ok(())
}
pub fn download_zip(url: &str, working_dir: &Path, zip_file_name: &str) -> LmcppResult<PathBuf> {
assert!(
url.starts_with("http"),
"download_zip expects an http/https URL"
);
let zip_path = working_dir.join(format!("{zip_file_name}.zip"));
if zip_path.exists() {
let cached_ok = match std::fs::File::open(&zip_path) {
Ok(file) => zip::ZipArchive::new(file).is_ok(),
Err(_) => false,
};
if cached_ok {
crate::trace!("Using cached archive {}", zip_path.display());
return Ok(zip_path);
}
crate::warn!(
"Cached archive {} is corrupted or incomplete; redownloading",
zip_path.display()
);
let _ = std::fs::remove_file(&zip_path); }
crate::trace!("Downloading from {} to {}", url, zip_path.display());
fetch_with_ureq(url, &zip_path, 3)?;
crate::trace!("Download completed successfully");
Ok(zip_path)
}
pub fn extract_zip(working_dir: &Path, zip_path: &Path) -> LmcppResult<()> {
let zip_file = std::fs::File::open(zip_path)
.map_err(|e| LmcppError::file_system("open zip file", zip_path.to_path_buf(), e))?;
let mut archive = zip::ZipArchive::new(zip_file)
.map_err(|e| LmcppError::file_system("read zip archive", zip_path.to_path_buf(), e))?;
for i in 0..archive.len() {
let mut entry = archive.by_index(i).map_err(|e| {
LmcppError::file_system("read entry from zip archive", zip_path.to_path_buf(), e)
})?;
let rel = match entry.enclosed_name() {
Some(p) => {
let mut comps = p.components();
comps.next(); let tail = comps.as_path();
if tail.as_os_str().is_empty() {
p.to_owned() } else {
tail.to_owned()
}
}
None => continue, };
let out = working_dir.join(rel);
if entry.is_dir() {
std::fs::create_dir_all(&out).map_err(|e| {
LmcppError::file_system("create directory from zip entry", out.clone(), e)
})?;
continue;
}
if let Some(parent) = out.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
LmcppError::file_system(
"create parent directory from zip entry",
parent.to_path_buf(),
e,
)
})?;
}
let mut out_file = std::fs::File::create(&out)
.map_err(|e| LmcppError::file_system("create file from zip entry", out.clone(), e))?;
std::io::copy(&mut entry, &mut out_file)
.map_err(|e| LmcppError::file_system("copy contents from zip entry", out.clone(), e))?;
#[cfg(unix)]
if let Some(mode) = entry.unix_mode() {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&out, std::fs::Permissions::from_mode(mode)).map_err(|e| {
LmcppError::file_system("set Unix permissions from zip entry", out.clone(), e)
})?;
}
}
Ok(())
}
fn fetch_with_ureq(url: &str, dest: &Path, tries: u8) -> LmcppResult<()> {
debug_assert!(tries >= 1, "fetch_with_ureq: tries must be ≥ 1");
let mut failures = Vec::new();
for attempt in 1..=tries {
match ureq::get(url).call() {
Ok(resp) if resp.status() == 200 => {
let mut reader = resp.into_body().into_reader();
let mut file = std::fs::File::create(dest).map_err(|e| {
LmcppError::file_system("create destination file", dest.to_path_buf(), e)
})?;
std::io::copy(&mut reader, &mut file).map_err(|e| {
LmcppError::file_system(
"copy contents to destination file",
dest.to_path_buf(),
e,
)
})?;
return Ok(());
}
Ok(resp) => {
let msg = format!("HTTP {} (attempt {}/{})", resp.status(), attempt, tries);
crate::warn!("{}", msg);
failures.push(msg);
}
Err(e) => {
let msg = format!("Request error {} (attempt {}/{})", e, attempt, tries);
crate::warn!("{}", msg);
failures.push(msg);
}
}
if attempt < tries {
std::thread::sleep(std::time::Duration::from_secs(1 << attempt.min(3)));
}
}
Err(LmcppError::DownloadFailed(format!(
"Failed to download {url} after {tries} attempts:\n{}",
failures.join("\n")
)))
}
#[cfg(test)]
mod tests {
use std::{
fs::{self, File},
io::Write,
};
use tempfile::tempdir;
use zip::{write::FileOptions, ZipWriter};
use super::*;
fn tiny_zip(path: &std::path::Path) {
let file = std::fs::File::create(path).unwrap();
let mut zip = zip::write::ZipWriter::new(file);
zip.start_file::<_, ()>("dummy.txt", zip::write::FileOptions::default())
.unwrap();
zip.write_all(b"hello").unwrap();
zip.finish().unwrap();
}
#[test]
fn cached_archive_is_used() {
let tmp = tempdir().unwrap();
let work = tmp.path();
tiny_zip(&work.join("cached.zip"));
let url = "http://example.invalid/never-used.zip";
for _ in 0..2 {
let p = download_zip(url, work, "cached").unwrap();
assert_eq!(p, work.join("cached.zip"));
}
}
#[test]
fn corrupted_cache_triggers_redownload() {
let tmp = tempdir().unwrap();
let work = tmp.path();
let zip_path = work.join("bad.zip");
fs::write(&zip_path, b"garbage").unwrap();
let old_size = fs::metadata(&zip_path).unwrap().len();
let mut server = mockito::Server::new();
let endpoint = "/bad.zip";
let zip_bytes = {
let mut buf = Vec::<u8>::new();
{
let mut z = ZipWriter::new(std::io::Cursor::new(&mut buf));
z.start_file::<_, ()>("file.txt", FileOptions::default())
.unwrap();
z.write_all(b"hello").unwrap();
z.finish().unwrap();
}
buf
};
let _m = server
.mock("GET", endpoint)
.with_status(200)
.with_body(zip_bytes.clone())
.create();
let url = format!("{}{}", server.url(), endpoint);
let p = download_zip(&url, work, "bad").unwrap();
let new_size = fs::metadata(p).unwrap().len();
assert!(new_size > old_size, "file should have been replaced");
}
#[test]
fn extract_strips_top_folder() {
let tmp = tempdir().unwrap();
let work = tempdir().unwrap();
let zip_path = tmp.path().join("outer.zip");
{
let file = File::create(&zip_path).unwrap();
let mut zip = ZipWriter::new(file);
zip.start_file::<_, ()>("outer/inner/file.txt", FileOptions::default())
.unwrap();
zip.write_all(b"content").unwrap();
zip.finish().unwrap();
}
extract_zip(work.path(), &zip_path).unwrap();
assert!(work.path().join("inner/file.txt").exists());
assert!(!work.path().join("outer").exists());
}
#[cfg(unix)]
#[test]
fn extract_respects_permissions_unix() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempdir().unwrap();
let work = tempdir().unwrap();
let zip_path = tmp.path().join("perm.zip");
{
let file = File::create(&zip_path).unwrap();
let mut zip = ZipWriter::new(file);
let opts = FileOptions::default().unix_permissions(0o755);
zip.start_file::<_, ()>("script.sh", opts).unwrap();
zip.write_all(b"#!/bin/sh\necho hi\n").unwrap();
zip.finish().unwrap();
}
extract_zip(work.path(), &zip_path).unwrap();
let meta = fs::metadata(work.path().join("script.sh")).unwrap();
assert!(
meta.permissions().mode() & 0o111 != 0,
"executable bit lost"
);
}
#[test]
fn extract_skips_path_traversal() {
let tmp = tempdir().unwrap();
let work = tempdir().unwrap();
let zip_path = tmp.path().join("evil.zip");
{
let file = File::create(&zip_path).unwrap();
let mut zip = ZipWriter::new(file);
let opts = FileOptions::default();
zip.start_file::<_, ()>("../evil.txt", opts).unwrap();
zip.write_all(b"malice").unwrap();
zip.finish().unwrap();
}
extract_zip(work.path(), &zip_path).unwrap();
assert!(
!work.path().join("evil.txt").exists(),
"entry should have been discarded"
);
assert!(
!work.path().parent().unwrap().join("evil.txt").exists(),
"must not escape working_dir"
);
}
}