use std::{ fs::{create_dir_all, File}, io::{ErrorKind, Read, Write}, path::{Path, PathBuf}, process::Command, }; use anyhow::Context; use flate2::read::GzDecoder; use futures_util::StreamExt; use tar::Archive; use tokio::io::AsyncWriteExt; #[derive(Debug, PartialEq, Eq)] pub enum Tool { Sass, Tailwind, } pub fn app_path() -> PathBuf { let data_local = dirs::data_local_dir().unwrap(); let dioxus_dir = data_local.join("dioxus"); if !dioxus_dir.is_dir() { create_dir_all(&dioxus_dir).unwrap(); } dioxus_dir } pub fn temp_path() -> PathBuf { let app_path = app_path(); let temp_path = app_path.join("temp"); if !temp_path.is_dir() { create_dir_all(&temp_path).unwrap(); } temp_path } pub fn clone_repo(dir: &Path, url: &str) -> anyhow::Result<()> { let target_dir = dir.parent().unwrap(); let dir_name = dir.file_name().unwrap(); let mut cmd = Command::new("git"); let cmd = cmd.current_dir(target_dir); let res = cmd.arg("clone").arg(url).arg(dir_name).output(); if let Err(err) = res { if ErrorKind::NotFound == err.kind() { tracing::warn!("Git program not found. Hint: Install git or check $PATH."); return Err(err.into()); } } Ok(()) } pub fn tools_path() -> PathBuf { let app_path = app_path(); let temp_path = app_path.join("tools"); if !temp_path.is_dir() { create_dir_all(&temp_path).unwrap(); } temp_path } #[allow(clippy::should_implement_trait)] impl Tool { /// from str to tool enum pub fn from_str(name: &str) -> Option { match name { "sass" => Some(Self::Sass), "tailwindcss" => Some(Self::Tailwind), _ => None, } } /// get current tool name str pub fn name(&self) -> &str { match self { Self::Sass => "sass", Self::Tailwind => "tailwindcss", } } /// get tool bin dir path pub fn bin_path(&self) -> &str { match self { Self::Sass => ".", Self::Tailwind => ".", } } /// get target platform pub fn target_platform(&self) -> &str { match self { Self::Sass => { if cfg!(target_os = "windows") { "windows" } else if cfg!(target_os = "macos") { "macos" } else if cfg!(target_os = "linux") { "linux" } else { panic!("unsupported platformm"); } } Self::Tailwind => { if cfg!(target_os = "windows") { "windows" } else if cfg!(target_os = "macos") { "macos" } else if cfg!(target_os = "linux") { "linux" } else { panic!("unsupported platformm"); } } } } /// get tool version pub fn tool_version(&self) -> &str { match self { Self::Sass => "1.51.0", Self::Tailwind => "v3.1.6", } } /// get tool package download url pub fn download_url(&self) -> String { match self { Self::Sass => { format!( "https://github.com/sass/dart-sass/releases/download/{version}/dart-sass-{version}-{target}-x64.{extension}", version = self.tool_version(), target = self.target_platform(), extension = self.extension() ) } Self::Tailwind => { let windows_extension = match self.target_platform() { "windows" => ".exe", _ => "", }; format!( "https://github.com/tailwindlabs/tailwindcss/releases/download/{version}/tailwindcss-{target}-x64{optional_ext}", version = self.tool_version(), target = self.target_platform(), optional_ext = windows_extension ) } } } /// get package extension name pub fn extension(&self) -> &str { match self { Self::Sass => { if cfg!(target_os = "windows") { "zip" } else { "tar.gz" } } Self::Tailwind => "bin", } } /// check tool state pub fn is_installed(&self) -> bool { tools_path().join(self.name()).is_dir() } /// get download temp path pub fn temp_out_path(&self) -> PathBuf { temp_path().join(format!("{}-tool.tmp", self.name())) } /// start to download package pub async fn download_package(&self) -> anyhow::Result { let download_url = self.download_url(); let temp_out = self.temp_out_path(); let mut file = tokio::fs::File::create(&temp_out) .await .context("failed creating temporary output file")?; let resp = reqwest::get(download_url).await.unwrap(); let mut res_bytes = resp.bytes_stream(); while let Some(chunk_res) = res_bytes.next().await { let chunk = chunk_res.context("error reading chunk from download")?; let _ = file.write(chunk.as_ref()).await; } // tracing::info!("temp file path: {:?}", temp_out); Ok(temp_out) } /// start to install package pub async fn install_package(&self) -> anyhow::Result<()> { let temp_path = self.temp_out_path(); let tool_path = tools_path(); let dir_name = match self { Self::Sass => "dart-sass".to_string(), Self::Tailwind => self.name().to_string(), }; if self.extension() == "tar.gz" { let tar_gz = File::open(temp_path)?; let tar = GzDecoder::new(tar_gz); let mut archive = Archive::new(tar); archive.unpack(&tool_path)?; std::fs::rename(tool_path.join(dir_name), tool_path.join(self.name()))?; } else if self.extension() == "zip" { // decompress the `zip` file extract_zip(&temp_path, &tool_path)?; std::fs::rename(tool_path.join(dir_name), tool_path.join(self.name()))?; } else if self.extension() == "bin" { let bin_path = match self.target_platform() { "windows" => tool_path.join(&dir_name).join(self.name()).join(".exe"), _ => tool_path.join(&dir_name).join(self.name()), }; // Manualy creating tool directory because we directly download the binary via Github std::fs::create_dir(tool_path.join(dir_name))?; let mut final_file = std::fs::File::create(&bin_path)?; let mut temp_file = File::open(&temp_path)?; let mut content = Vec::new(); temp_file.read_to_end(&mut content)?; final_file.write_all(&content)?; if self.target_platform() == "linux" { // This code does not update permissions idk why // let mut perms = final_file.metadata()?.permissions(); // perms.set_mode(0o744); // Adding to the binary execution rights with "chmod" let mut command = Command::new("chmod"); let _ = command .args(vec!["+x", bin_path.to_str().unwrap()]) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()) .output()?; } std::fs::remove_file(&temp_path)?; } Ok(()) } pub fn call(&self, command: &str, args: Vec<&str>) -> anyhow::Result> { let bin_path = tools_path().join(self.name()).join(self.bin_path()); let command_file = match self { Tool::Sass => { if cfg!(target_os = "windows") { format!("{}.bat", command) } else { command.to_string() } } Tool::Tailwind => { if cfg!(target_os = "windows") { format!("{}.exe", command) } else { command.to_string() } } }; if !bin_path.join(&command_file).is_file() { return Err(anyhow::anyhow!("Command file not found.")); } let mut command = Command::new(bin_path.join(&command_file).to_str().unwrap()); let output = command .args(&args[..]) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()) .output()?; Ok(output.stdout) } } pub fn extract_zip(file: &Path, target: &Path) -> anyhow::Result<()> { let zip_file = std::fs::File::open(file)?; let mut zip = zip::ZipArchive::new(zip_file)?; if !target.exists() { std::fs::create_dir_all(target)?; } for i in 0..zip.len() { let mut zip_entry = zip.by_index(i)?; // check for dangerous paths // see https://docs.rs/zip/latest/zip/read/struct.ZipFile.html#warnings let Some(enclosed_name) = zip_entry.enclosed_name() else { return Err(anyhow::anyhow!( "Refusing to unpack zip entry with potentially dangerous path: zip={} entry={:?}", file.display(), zip_entry.name() )); }; let output_path = target.join(enclosed_name); if zip_entry.is_dir() { std::fs::create_dir_all(output_path)?; } else { // create parent dirs if needed if let Some(parent) = output_path.parent() { std::fs::create_dir_all(parent)?; } // extract file let mut target_file = if !output_path.exists() { std::fs::File::create(output_path)? } else { std::fs::File::open(output_path)? }; let _num = std::io::copy(&mut zip_entry, &mut target_file)?; } } Ok(()) } #[cfg(test)] mod test { use super::*; use tempfile::tempdir; #[test] fn test_extract_zip() -> anyhow::Result<()> { let path = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()) .join("tests/fixtures/test.zip"); let temp_dir = tempdir()?; let temp_path = temp_dir.path(); extract_zip(path.as_path(), temp_path)?; let expected_files = vec!["file1.txt", "file2.txt", "dir/file3.txt"]; for file in expected_files { let path = temp_path.join(file); assert!(path.exists(), "File not found: {:?}", path); } Ok(()) } #[test] fn test_extract_zip_dangerous_path() -> anyhow::Result<()> { let path = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()) .join("tests/fixtures/dangerous.zip"); let temp_dir = tempdir()?; let temp_path = temp_dir.path(); let result = extract_zip(path.as_path(), temp_path); let err = result.unwrap_err(); assert!(err .to_string() .contains("Refusing to unpack zip entry with potentially dangerous path: zip=")); assert!(err.to_string().contains("entry=\"/etc/passwd\"")); Ok(()) } }