use std::fs::{self, File}; use std::io::{self, BufReader, Read}; use std::path::{Path, PathBuf}; use crate::browser::BrowserType; use crate::downloader::DownloadProgress; use crate::events; #[cfg(target_os = "macos")] use tokio::process::Command; #[cfg(target_os = "macos")] use std::fs::create_dir_all; /// Returns true if `path` carries a `com.apple.quarantine` extended attribute. /// /// Uses `getxattr` with a null buffer to query the attribute size only — /// this is a read-only syscall and does NOT trigger macOS Sequoia's App /// Management TCC prompt. We use it to gate the `xattr -d` removal: macOS /// fires the prompt on the modify-class syscall (`removexattr`) even when /// the operation is a no-op, so skipping the call entirely when the /// attribute is absent is the only way to stay quiet. #[cfg(target_os = "macos")] fn has_quarantine_attr(path: &Path) -> bool { use std::ffi::CString; use std::os::unix::ffi::OsStrExt; let Ok(path_c) = CString::new(path.as_os_str().as_bytes()) else { return false; }; let Ok(attr_c) = CString::new("com.apple.quarantine") else { return false; }; // SAFETY: getxattr is a stable libc API. Passing a null buffer with size 0 // makes it a pure read-only size query. let result = unsafe { libc::getxattr( path_c.as_ptr(), attr_c.as_ptr(), std::ptr::null_mut(), 0, 0, 0, ) }; result >= 0 } /// Best-effort recursive size of a file tree. Uses `symlink_metadata` so /// symlinks inside .app bundles are not followed (`cp -R` copies them as /// links, so following them would overcount and could loop). #[cfg(target_os = "macos")] fn dir_size(path: &Path) -> u64 { let Ok(meta) = fs::symlink_metadata(path) else { return 0; }; if meta.is_file() { return meta.len(); } if !meta.is_dir() { return 0; } let Ok(entries) = fs::read_dir(path) else { return 0; }; entries.flatten().map(|entry| dir_size(&entry.path())).sum() } const PROGRESS_REPORT_INTERVAL: std::time::Duration = std::time::Duration::from_millis(150); /// Emits throttled "extracting" progress on the `download-progress` channel so /// the UI can render a moving bar during long extractions. pub struct ExtractionReporter { browser: String, version: String, last_emit: std::sync::Mutex, } impl ExtractionReporter { pub fn new(browser: String, version: String) -> Self { // Backdate so the first report goes out immediately. let backdated = std::time::Instant::now() .checked_sub(PROGRESS_REPORT_INTERVAL) .unwrap_or_else(std::time::Instant::now); Self { browser, version, last_emit: std::sync::Mutex::new(backdated), } } /// Report byte-level progress where bytes map linearly onto the whole job. pub fn report_bytes(&self, done: u64, total: u64) { if total == 0 { return; } let done = done.min(total); self.report((done as f64 / total as f64) * 100.0, done, Some(total)); } /// Report a pre-computed percentage (multi-phase extractions where byte /// counts don't map linearly onto overall progress). pub fn report_percentage(&self, percentage: f64) { self.report(percentage, 0, None); } /// Force a final 100% event so the bar lands full before the next stage. pub fn finish(&self) { self.emit(100.0, 0, None); } fn report(&self, percentage: f64, downloaded_bytes: u64, total_bytes: Option) { { let mut last = self.last_emit.lock().unwrap(); if last.elapsed() < PROGRESS_REPORT_INTERVAL { return; } *last = std::time::Instant::now(); } self.emit(percentage, downloaded_bytes, total_bytes); } fn emit(&self, percentage: f64, downloaded_bytes: u64, total_bytes: Option) { let progress = DownloadProgress { browser: self.browser.clone(), version: self.version.clone(), downloaded_bytes, total_bytes, percentage: percentage.clamp(0.0, 100.0), speed_bytes_per_sec: 0.0, eta_seconds: None, stage: "extracting".to_string(), }; let _ = events::emit("download-progress", &progress); } } /// A reader that passes cumulative bytes read to a callback on every read. /// Throttling is the reporter's job, so the callback can fire freely. struct ProgressReader { inner: R, bytes_read: u64, on_read: F, } impl ProgressReader { fn new(inner: R, on_read: F) -> Self { Self { inner, bytes_read: 0, on_read, } } } impl Read for ProgressReader { fn read(&mut self, buf: &mut [u8]) -> io::Result { let n = self.inner.read(buf)?; self.bytes_read += n as u64; (self.on_read)(self.bytes_read); Ok(n) } } /// Wrap an archive file so compressed bytes consumed report linearly against /// its on-disk size — shared by the streaming tar decoders, where stream /// position maps monotonically onto overall extraction progress. fn progress_file_reader( file: File, progress: Option<&ExtractionReporter>, ) -> io::Result { let compressed_total = file.metadata()?.len(); Ok(ProgressReader::new(file, move |read| { if let Some(reporter) = progress { reporter.report_bytes(read, compressed_total); } })) } pub struct Extractor; impl Extractor { fn new() -> Self { Self } pub fn instance() -> &'static Extractor { &EXTRACTOR } // NOTE: We intentionally do not rename or sanitize ZIP entry paths. // We only ensure paths are enclosed within the destination using zip's enclosed_name. /// Ensure the extracted files are in the correct directory structure expected by verification #[cfg(target_os = "linux")] async fn ensure_correct_directory_structure( &self, dest_dir: &Path, exe_path: &Path, ) -> Result<(), Box> { // Determine browser type from the destination directory path let browser_type = if dest_dir.to_string_lossy().contains("wayfern") { "wayfern" } else { return Ok(()); }; // For Wayfern on Linux, we expect the executable directly under version directory // e.g., binaries/wayfern//wayfern, without an extra subdirectory if browser_type == "wayfern" { return Ok(()); } let expected_subdir = dest_dir.join(browser_type); // If the executable is not in the expected subdirectory, create the structure if !exe_path.starts_with(&expected_subdir) { log::info!("Reorganizing directory structure for {}", browser_type); // Create the expected subdirectory std::fs::create_dir_all(&expected_subdir)?; // Move all files from the root to the subdirectory if let Ok(entries) = std::fs::read_dir(dest_dir) { for entry in entries.flatten() { let path = entry.path(); let file_name = match path.file_name() { Some(name) => name, None => continue, }; // Skip the subdirectory we just created if path == expected_subdir { continue; } let target_path = expected_subdir.join(file_name); // Move the file/directory if let Err(e) = std::fs::rename(&path, &target_path) { log::info!( "Warning: Failed to move {} to {}: {}", path.display(), target_path.display(), e ); } else { log::info!("Moved {} to {}", path.display(), target_path.display()); } } } log::info!("Directory structure reorganized for {}", browser_type); } Ok(()) } pub async fn extract_browser( &self, _app_handle: &tauri::AppHandle, browser_type: BrowserType, version: &str, archive_path: &Path, dest_dir: &Path, ) -> Result> { // Emit extraction start progress let progress = DownloadProgress { browser: browser_type.as_str().to_string(), version: version.to_string(), downloaded_bytes: 0, total_bytes: None, percentage: 0.0, speed_bytes_per_sec: 0.0, eta_seconds: None, stage: "extracting".to_string(), }; let _ = events::emit("download-progress", &progress); // Reports incremental extraction progress to the UI. Formats without a // measurable byte stream (MSI, plain EXE/AppImage copies) simply never // report, and the frontend falls back to an indeterminate bar. let reporter = ExtractionReporter::new(browser_type.as_str().to_string(), version.to_string()); log::info!( "Starting extraction of {} for browser {} version {}", archive_path.display(), browser_type.as_str(), version ); // Detect the actual file type by reading the file header let actual_format = self.detect_file_format(archive_path).map_err(|e| { format!( "Failed to detect file format for {}: {}", archive_path.display(), e ) })?; log::info!("Detected format: {actual_format}"); let extraction_result = match actual_format.as_str() { "dmg" => { #[cfg(target_os = "macos")] { self.extract_dmg(archive_path, dest_dir, Some(&reporter)).await.map_err(|e| { format!("DMG extraction failed for {} {}: {}", browser_type.as_str(), version, e).into() }) } #[cfg(not(target_os = "macos"))] { Err(format!("DMG extraction is only supported on macOS, but {} {} requires DMG extraction", browser_type.as_str(), version).into()) } } "zip" => { self.extract_zip(archive_path, dest_dir, Some(&reporter)).await.map_err(|e| { format!("ZIP extraction failed for {} {}: {}", browser_type.as_str(), version, e).into() }) } "tar.xz" => { self.extract_tar_xz(archive_path, dest_dir, Some(&reporter)).await.map_err(|e| { format!("TAR.XZ extraction failed for {} {}: {}", browser_type.as_str(), version, e).into() }) } "tar.bz2" => { self.extract_tar_bz2(archive_path, dest_dir, Some(&reporter)).await.map_err(|e| { format!("TAR.BZ2 extraction failed for {} {}: {}", browser_type.as_str(), version, e).into() }) } "tar.gz" => { self.extract_tar_gz(archive_path, dest_dir, Some(&reporter)).await.map_err(|e| { format!("TAR.GZ extraction failed for {} {}: {}", browser_type.as_str(), version, e).into() }) } "msi" => { self.extract_msi(archive_path, dest_dir).await.map_err(|e| { format!("MSI extraction failed for {} {}: {}", browser_type.as_str(), version, e).into() }) } "exe" => { // For Windows EXE files, some may be self-extracting archives, others are installers self .handle_exe_file(archive_path, dest_dir, browser_type.clone()) .await .map_err(|e| { format!("EXE handling failed for {} {}: {}", browser_type.as_str(), version, e).into() }) } "appimage" => { #[cfg(target_os = "linux")] { self.handle_appimage(archive_path, dest_dir).await.map_err(|e| { format!("AppImage handling failed for {} {}: {}", browser_type.as_str(), version, e).into() }) } #[cfg(not(target_os = "linux"))] { Err(format!("AppImage is only supported on Linux, but {} {} requires AppImage handling", browser_type.as_str(), version).into()) } } _ => { Err(format!( "Unsupported archive format for {} {}: {} (detected: {}). The downloaded file might be corrupted or in an unexpected format. File: {}", browser_type.as_str(), version, archive_path.extension().and_then(|ext| ext.to_str()).unwrap_or("unknown"), actual_format, archive_path.display() ).into()) } }; match extraction_result { Ok(path) => { reporter.finish(); // Remove quarantine attributes on macOS to prevent Gatekeeper prompts — // but only if there's actually something to remove. Calling the // modify-class `removexattr` syscall on a file without quarantine still // fires macOS Sequoia's App Management TCC notification, so we skip // the call entirely when the attribute is absent. #[cfg(target_os = "macos")] { if has_quarantine_attr(dest_dir) { let _ = tokio::process::Command::new("xattr") .args([ "-dr", "com.apple.quarantine", dest_dir.to_str().unwrap_or("."), ]) .output() .await; } } log::info!( "Successfully extracted {} {} to: {}", browser_type.as_str(), version, path.display() ); Ok(path) } Err(e) => { log::error!( "Extraction failed for {} {}: {}", browser_type.as_str(), version, e ); Err(e) } } } /// Detect the actual file format by reading file headers fn detect_file_format( &self, file_path: &Path, ) -> Result> { // Check file extension first for container formats (DMG, MSI) whose internal // compression makes magic bytes unreliable if let Some(ext) = file_path.extension().and_then(|ext| ext.to_str()) { match ext.to_lowercase().as_str() { "dmg" => return Ok("dmg".to_string()), "msi" => return Ok("msi".to_string()), _ => {} } } let mut file = File::open(file_path)?; let mut buffer = [0u8; 12]; file.read_exact(&mut buffer)?; // Check magic numbers for other file types match &buffer[0..4] { [0x50, 0x4B, 0x03, 0x04] | [0x50, 0x4B, 0x05, 0x06] | [0x50, 0x4B, 0x07, 0x08] => { return Ok("zip".to_string()) } [0x7F, 0x45, 0x4C, 0x46] => return Ok("appimage".to_string()), // ELF header (AppImage) [0x4D, 0x5A, _, _] => return Ok("exe".to_string()), // PE header (Windows EXE) _ => {} } // Check for MSI files (Microsoft Installer) if buffer[0..8] == [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1] { return Ok("msi".to_string()); } // Check for XZ compressed files if buffer[0..6] == [0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00] { return Ok("tar.xz".to_string()); } // Check for Bzip2 compressed files if buffer[0..3] == [0x42, 0x5A, 0x68] { return Ok("tar.bz2".to_string()); } // Check for Gzip compressed files if buffer[0..3] == [0x1F, 0x8B, 0x08] { return Ok("tar.gz".to_string()); } // Fallback to file extension if let Some(ext) = file_path.extension().and_then(|ext| ext.to_str()) { match ext.to_lowercase().as_str() { "dmg" => Ok("dmg".to_string()), "zip" => Ok("zip".to_string()), "msi" => Ok("msi".to_string()), "xz" => { if file_path .file_name() .and_then(|n| n.to_str()) .unwrap_or("") .ends_with(".tar.xz") { Ok("tar.xz".to_string()) } else { Ok("xz".to_string()) } } "bz2" => { if file_path .file_name() .and_then(|n| n.to_str()) .unwrap_or("") .ends_with(".tar.bz2") { Ok("tar.bz2".to_string()) } else { Ok("bz2".to_string()) } } "gz" => { if file_path .file_name() .and_then(|n| n.to_str()) .unwrap_or("") .ends_with(".tar.gz") { Ok("tar.gz".to_string()) } else { Ok("gz".to_string()) } } "exe" => Ok("exe".to_string()), "appimage" => Ok("appimage".to_string()), _ => Ok("unknown".to_string()), } } else { Ok("unknown".to_string()) } } #[cfg(target_os = "macos")] pub async fn extract_dmg( &self, dmg_path: &Path, dest_dir: &Path, progress: Option<&ExtractionReporter>, ) -> Result> { log::info!( "Extracting DMG: {} to {}", dmg_path.display(), dest_dir.display() ); // Create a temporary mount point let mount_point = std::env::temp_dir().join(format!( "donut_mount_{}", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs() )); create_dir_all(&mount_point)?; log::info!("Created mount point: {}", mount_point.display()); // Mount the DMG let output = Command::new("hdiutil") .args([ "attach", "-nobrowse", "-noverify", "-noautoopen", "-mountpoint", mount_point.to_str().unwrap(), dmg_path.to_str().unwrap(), ]) .stdin(std::process::Stdio::null()) .output() .await?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); log::error!("Failed to mount DMG. stdout: {stdout}, stderr: {stderr}"); // Clean up mount point before returning error let _ = fs::remove_dir_all(&mount_point); return Err(format!("Failed to mount DMG: {stderr}").into()); } log::info!("Successfully mounted DMG"); // Find the .app directory in the mount point let app_result = self.find_app_in_directory(&mount_point).await; let app_entry = match app_result { Ok(app_path) => app_path, Err(e) => { log::error!("Failed to find .app in mount point: {e}"); // Try to unmount before returning error let _ = Command::new("hdiutil") .args(["detach", "-force", mount_point.to_str().unwrap()]) .output() .await; let _ = fs::remove_dir_all(&mount_point); return Err("No .app found after extraction".into()); } }; log::info!("Found .app bundle: {}", app_entry.display()); // Copy the .app to the destination let app_path = dest_dir.join(app_entry.file_name().unwrap()); log::info!("Copying .app to: {}", app_path.display()); // The copy is the long pole of DMG extraction; size up the source once so // we can report real progress by polling the destination while cp runs. // report_bytes no-ops on a 0 total, so the None path skips both walks. let total_size = if progress.is_some() { dir_size(&app_entry) } else { 0 }; // `-X` strips extended attributes (notably com.apple.quarantine) during // the copy itself. Without it, `cp -R` preserves quarantine from the // mounted DMG, which then has to be removed with `xattr -dr` — and that // removexattr syscall on a signed .app bundle trips macOS Sequoia's App // Management TCC notification ("Donut.app was prevented from modifying // apps on your Mac"). Stripping at copy time is silent. let copy_src = app_entry.to_str().unwrap().to_string(); let copy_dst = app_path.to_str().unwrap().to_string(); let mut copy_task = tokio::spawn(async move { Command::new("cp") .args(["-RX", ©_src, ©_dst]) .output() .await }); let output = loop { tokio::select! { result = &mut copy_task => { break result.map_err(|e| format!("Copy task failed: {e}"))??; } () = tokio::time::sleep(std::time::Duration::from_millis(500)) => { if let Some(reporter) = progress { reporter.report_bytes(dir_size(&app_path), total_size); } } } }; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); log::error!("Failed to copy app: {stderr}"); // Unmount before returning error let _ = Command::new("hdiutil") .args(["detach", "-force", mount_point.to_str().unwrap()]) .output() .await; let _ = fs::remove_dir_all(&mount_point); return Err(format!("Failed to copy app: {stderr}").into()); } log::info!("Successfully copied .app bundle"); // Remove the macOS quarantine attribute so Gatekeeper doesn't block launch // — but only if it's actually present. A no-op `removexattr` syscall on a // signed .app bundle still trips macOS Sequoia's App Management privacy // prompt ("Donut.app was prevented from modifying apps on your Mac"), // even when no modification actually happens, so we gate the call behind // a read-only `getxattr` check. if has_quarantine_attr(&app_path) { let _ = Command::new("xattr") .args(["-dr", "com.apple.quarantine", app_path.to_str().unwrap()]) .output() .await; log::info!("Removed quarantine attributes"); } else { log::info!("No quarantine attribute on .app, skipping xattr removal"); } // Unmount the DMG let output = Command::new("hdiutil") .args(["detach", mount_point.to_str().unwrap()]) .output() .await?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); log::warn!("Warning: Failed to unmount DMG: {stderr}"); // Don't fail if unmount fails - the extraction was successful } else { log::info!("Successfully unmounted DMG"); } // Clean up mount point directory let _ = fs::remove_dir_all(&mount_point); Ok(app_path) } #[cfg(target_os = "macos")] async fn find_app_in_directory( &self, dir: &Path, ) -> Result> { self.find_app_recursive(dir, 0).await } #[cfg(target_os = "macos")] async fn find_app_recursive( &self, dir: &Path, depth: usize, ) -> Result> { // Limit search depth to avoid infinite loops if depth > 4 { return Err("Maximum search depth reached".into()); } if let Ok(entries) = fs::read_dir(dir) { let mut subdirs = Vec::new(); let mut hidden_subdirs = Vec::new(); // First pass: look for .app bundles directly for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { if let Some(extension) = path.extension() { if extension == "app" { log::info!("Found .app bundle at depth {}: {}", depth, path.display()); return Ok(path); } } // Collect subdirectories for second pass let filename = path.file_name().unwrap_or_default().to_string_lossy(); if filename.starts_with('.') { // Hidden directories - search these with lower priority hidden_subdirs.push(path); } else { // Regular directories - search these first subdirs.push(path); } } } // Second pass: search regular subdirectories first for subdir in subdirs { // Skip common directories that are unlikely to contain .app files let dirname = subdir.file_name().unwrap_or_default().to_string_lossy(); if matches!( dirname.as_ref(), "Documents" | "Downloads" | "Desktop" | "Library" | "System" | "tmp" | "var" ) { continue; } if let Ok(result) = Box::pin(self.find_app_recursive(&subdir, depth + 1)).await { return Ok(result); } } // Third pass: search hidden directories if nothing found in regular ones for hidden_dir in hidden_subdirs { if let Ok(result) = Box::pin(self.find_app_recursive(&hidden_dir, depth + 1)).await { return Ok(result); } } } Err(format!("No .app found in directory: {}", dir.display()).into()) } pub async fn extract_zip( &self, zip_path: &Path, dest_dir: &Path, progress: Option<&ExtractionReporter>, ) -> Result> { log::info!("Extracting ZIP archive: {}", zip_path.display()); std::fs::create_dir_all(dest_dir)?; let file = File::open(zip_path) .map_err(|e| format!("Failed to open ZIP file {}: {}", zip_path.display(), e))?; let mut archive = zip::ZipArchive::new(BufReader::new(file)) .map_err(|e| format!("Failed to read ZIP archive {}: {}", zip_path.display(), e))?; log::info!("ZIP archive contains {} files", archive.len()); // Total uncompressed size, known from the central directory without any // decompression. None for archives using data descriptors — those get no // byte progress (the UI falls back to an indeterminate bar). let total_uncompressed: Option = archive .decompressed_size() .and_then(|total| u64::try_from(total).ok()) .filter(|total| *total > 0); let mut extracted_bytes: u64 = 0; for i in 0..archive.len() { let mut entry = archive .by_index(i) .map_err(|e| format!("Failed to read ZIP entry at index {i}: {e}"))?; // Use enclosed_name to prevent path traversal; do not modify names otherwise let enclosed = entry .enclosed_name() .ok_or_else(|| format!("ZIP contains an invalid entry path: {}", entry.name()))?; let outpath = dest_dir.join(enclosed); // Handle directories and files if entry.is_dir() { std::fs::create_dir_all(&outpath) .map_err(|e| format!("Failed to create directory {}: {}", outpath.display(), e))?; } else { if let Some(parent) = outpath.parent() { std::fs::create_dir_all(parent).map_err(|e| { format!( "Failed to create parent directory {}: {}", parent.display(), e ) })?; } let mut outfile = File::create(&outpath) .map_err(|e| format!("Failed to create file {}: {}", outpath.display(), e))?; let entry_size = entry.size(); let already_extracted = extracted_bytes; let mut reader = ProgressReader::new(&mut entry, |read| { if let (Some(reporter), Some(total)) = (progress, total_uncompressed) { reporter.report_bytes(already_extracted + read, total); } }); io::copy(&mut reader, &mut outfile) .map_err(|e| format!("Failed to extract file {}: {}", outpath.display(), e))?; extracted_bytes = extracted_bytes.saturating_add(entry_size); // Set executable permissions on Unix-like systems based on stored mode #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; if let Some(mode) = entry.unix_mode() { let permissions = std::fs::Permissions::from_mode(mode); std::fs::set_permissions(&outpath, permissions) .map_err(|e| format!("Failed to set permissions for {}: {}", outpath.display(), e))?; } } } } log::info!("ZIP extraction completed."); self.flatten_single_directory_archive(dest_dir)?; log::info!("Searching for executable..."); self .find_extracted_executable(dest_dir) .await .map_err(|e| format!("Failed to find executable after ZIP extraction: {e}").into()) } pub async fn extract_tar_gz( &self, tar_path: &Path, dest_dir: &Path, progress: Option<&ExtractionReporter>, ) -> Result> { log::info!("Extracting tar.gz archive: {}", tar_path.display()); std::fs::create_dir_all(dest_dir)?; let file = File::open(tar_path)?; let counted = progress_file_reader(file, progress)?; let gz_decoder = flate2::read::GzDecoder::new(BufReader::new(counted)); let mut archive = tar::Archive::new(gz_decoder); archive.unpack(dest_dir)?; // Set executable permissions for extracted files self.set_executable_permissions_recursive(dest_dir).await?; log::info!("tar.gz extraction completed."); self.flatten_single_directory_archive(dest_dir)?; log::info!("Searching for executable..."); self.find_extracted_executable(dest_dir).await } pub async fn extract_tar_bz2( &self, tar_path: &Path, dest_dir: &Path, progress: Option<&ExtractionReporter>, ) -> Result> { log::info!("Extracting tar.bz2 archive: {}", tar_path.display()); std::fs::create_dir_all(dest_dir)?; let file = File::open(tar_path)?; let counted = progress_file_reader(file, progress)?; let bz2_decoder = bzip2::read::BzDecoder::new(BufReader::new(counted)); let mut archive = tar::Archive::new(bz2_decoder); archive.unpack(dest_dir)?; // Set executable permissions for extracted files self.set_executable_permissions_recursive(dest_dir).await?; log::info!("tar.bz2 extraction completed."); self.flatten_single_directory_archive(dest_dir)?; log::info!("Searching for executable..."); self.find_extracted_executable(dest_dir).await } pub async fn extract_tar_xz( &self, tar_path: &Path, dest_dir: &Path, progress: Option<&ExtractionReporter>, ) -> Result> { log::info!("Extracting tar.xz archive: {}", tar_path.display()); std::fs::create_dir_all(dest_dir)?; let file = File::open(tar_path)?; let mut buf_reader = BufReader::new(file); // Read the entire file into memory for lzma-rs let mut compressed_data = Vec::new(); buf_reader.read_to_end(&mut compressed_data)?; let compressed_total = compressed_data.len() as u64; // Two phases with no shared byte scale: CPU-bound LZMA decompression // dominates, so compressed bytes consumed map to 0–80%, and the tar // unpack of the decompressed data to 80–100%. let mut decompressed_data = Vec::new(); let counted_input = ProgressReader::new(std::io::Cursor::new(compressed_data), |read| { if let Some(reporter) = progress { if compressed_total > 0 { reporter .report_percentage(80.0 * read.min(compressed_total) as f64 / compressed_total as f64); } } }); lzma_rs::xz_decompress(&mut BufReader::new(counted_input), &mut decompressed_data)?; // Create tar archive from decompressed data let decompressed_total = decompressed_data.len() as u64; let counted_tar = ProgressReader::new(std::io::Cursor::new(decompressed_data), |read| { if let Some(reporter) = progress { if decompressed_total > 0 { reporter.report_percentage( 80.0 + 20.0 * read.min(decompressed_total) as f64 / decompressed_total as f64, ); } } }); let mut archive = tar::Archive::new(counted_tar); archive.unpack(dest_dir)?; // Set executable permissions for extracted files self.set_executable_permissions_recursive(dest_dir).await?; log::info!("tar.xz extraction completed."); self.flatten_single_directory_archive(dest_dir)?; log::info!("Searching for executable..."); self.find_extracted_executable(dest_dir).await } pub async fn extract_msi( &self, msi_path: &Path, dest_dir: &Path, ) -> Result> { log::info!("Extracting MSI archive: {}", msi_path.display()); std::fs::create_dir_all(dest_dir)?; // Extract MSI in a separate scope to avoid Send issues { let mut extractor = msi_extract::MsiExtractor::from_path(msi_path)?; extractor.to(dest_dir); } log::info!("MSI extraction completed."); self.flatten_single_directory_archive(dest_dir)?; log::info!("Searching for executable..."); self.find_extracted_executable(dest_dir).await } #[cfg(target_os = "linux")] pub async fn handle_appimage( &self, appimage_path: &Path, dest_dir: &Path, ) -> Result> { std::fs::create_dir_all(dest_dir)?; // For AppImages, we typically just copy them and make sure they're executable let dest_file = dest_dir.join( appimage_path .file_name() .unwrap_or_else(|| std::ffi::OsStr::new("app.AppImage")), ); // Copy the AppImage to destination fs::copy(appimage_path, &dest_file)?; // Set executable permissions self .set_executable_permissions_recursive(&dest_file) .await?; Ok(dest_file) } pub async fn handle_exe_file( &self, exe_path: &Path, dest_dir: &Path, browser_type: BrowserType, ) -> Result> { { let _ = browser_type; let exe_name = exe_path .file_name() .and_then(|name| name.to_str()) .unwrap_or("browser.exe"); let dest_path = dest_dir.join(exe_name); fs::copy(exe_path, &dest_path)?; Ok(dest_path) } } fn flatten_single_directory_archive( &self, dest_dir: &Path, ) -> Result<(), Box> { let entries: Vec<_> = fs::read_dir(dest_dir)?.filter_map(|e| e.ok()).collect(); let archive_extensions = ["zip", "tar", "xz", "gz", "bz2", "dmg", "msi", "exe"]; let mut dirs = Vec::new(); let mut has_non_archive_files = false; for entry in &entries { let path = entry.path(); if path.is_dir() { dirs.push(path); } else if let Some(ext) = path.extension().and_then(|e| e.to_str()) { if !archive_extensions.contains(&ext.to_lowercase().as_str()) { has_non_archive_files = true; } } else { has_non_archive_files = true; } } if dirs.len() == 1 && !has_non_archive_files { let single_dir = &dirs[0]; if single_dir.extension().is_some_and(|ext| ext == "app") { log::info!( "Skipping flatten: {} is a macOS app bundle", single_dir.display() ); return Ok(()); } log::info!( "Flattening single-directory archive: moving contents of {} to {}", single_dir.display(), dest_dir.display() ); let inner_entries: Vec<_> = fs::read_dir(single_dir)?.filter_map(|e| e.ok()).collect(); for entry in inner_entries { let source = entry.path(); let file_name = match source.file_name() { Some(name) => name.to_owned(), None => continue, }; let target = dest_dir.join(&file_name); fs::rename(&source, &target).map_err(|e| { format!( "Failed to move {} to {}: {}", source.display(), target.display(), e ) })?; } fs::remove_dir(single_dir).map_err(|e| { format!( "Failed to remove empty directory {}: {}", single_dir.display(), e ) })?; log::info!("Successfully flattened archive directory structure"); } Ok(()) } async fn find_extracted_executable( &self, dest_dir: &Path, ) -> Result> { // Platform-specific executable finding logic #[cfg(target_os = "macos")] { self.find_macos_app(dest_dir).await } #[cfg(target_os = "windows")] { self.find_windows_executable(dest_dir).await } #[cfg(target_os = "linux")] { let result = self.find_linux_executable(dest_dir).await; // If we found an executable, ensure it's in the correct directory structure // that the verification expects if let Ok(exe_path) = &result { self .ensure_correct_directory_structure(dest_dir, exe_path) .await?; } result } } #[cfg(target_os = "macos")] async fn find_macos_app( &self, dest_dir: &Path, ) -> Result> { log::info!("Searching for .app bundle in: {}", dest_dir.display()); // Use the enhanced recursive search match self.find_app_in_directory(dest_dir).await { Ok(app_path) => { // Check if the app is in a subdirectory and move it to the root if needed let app_parent = app_path.parent().unwrap(); if app_parent != dest_dir { log::info!( "Found .app in subdirectory, moving to root: {} -> {}", app_path.display(), dest_dir.display() ); let target_path = dest_dir.join(app_path.file_name().unwrap()); // Move the app to the root destination directory fs::rename(&app_path, &target_path)?; // Try to clean up the now-empty subdirectory (ignore errors) if let Some(parent_dir) = app_path.parent() { if parent_dir != dest_dir { let _ = fs::remove_dir_all(parent_dir); } } log::info!("Successfully moved .app to: {}", target_path.display()); Ok(target_path) } else { log::info!("Found .app at root level: {}", app_path.display()); Ok(app_path) } } Err(e) => { log::info!("Failed to find .app bundle: {e}"); Err("No .app found after extraction".into()) } } } #[cfg(target_os = "windows")] async fn find_windows_executable( &self, dest_dir: &Path, ) -> Result> { log::info!( "Searching for Windows executable in: {}", dest_dir.display() ); // Look for .exe files, preferring main browser executables. Wayfern is the // current name; chromium/chrome cover builds extracted before the rename. let priority_exe_names = ["wayfern.exe", "chromium.exe", "chrome.exe"]; // First try priority executable names for exe_name in &priority_exe_names { let exe_path = dest_dir.join(exe_name); if exe_path.exists() { log::info!("Found priority executable: {}", exe_path.display()); return Ok(exe_path); } } // Recursively search for executables with depth limit match self.find_windows_executable_recursive(dest_dir, 0, 3).await { Ok(exe_path) => { log::info!( "Found executable via recursive search: {}", exe_path.display() ); Ok(exe_path) } Err(_) => Err("No executable found after extraction".into()), } } #[cfg(target_os = "windows")] #[allow(clippy::type_complexity)] fn find_windows_executable_recursive<'a>( &'a self, dir: &'a Path, depth: usize, max_depth: usize, ) -> std::pin::Pin< Box< dyn std::future::Future>> + Send + 'a, >, > { Box::pin(async move { if depth > max_depth { return Err("Maximum search depth reached".into()); } if let Ok(entries) = fs::read_dir(dir) { let mut dirs_to_search = Vec::new(); // First pass: look for .exe files in current directory for entry in entries.flatten() { let path = entry.path(); if path.is_file() && path .extension() .is_some_and(|ext| ext.to_string_lossy().to_lowercase() == "exe") { let file_name = path .file_name() .and_then(|n| n.to_str()) .unwrap_or("") .to_lowercase(); // Check if it's a browser executable if file_name.contains("chrome") || file_name.contains("chromium") || file_name.contains("browser") || file_name.contains("wayfern") { return Ok(path); } } else if path.is_dir() { // Collect directories for later search dirs_to_search.push(path); } } // Second pass: search subdirectories for subdir in dirs_to_search { if let Ok(result) = self .find_windows_executable_recursive(&subdir, depth + 1, max_depth) .await { return Ok(result); } } // Third pass: if no browser-specific executable found, return any .exe if let Ok(entries) = fs::read_dir(dir) { for entry in entries.flatten() { let path = entry.path(); if path.is_file() && path .extension() .is_some_and(|ext| ext.to_string_lossy().to_lowercase() == "exe") { return Ok(path); } } } } Err("No executable found".into()) }) } #[cfg(target_os = "linux")] async fn find_linux_executable( &self, dest_dir: &Path, ) -> Result> { log::info!("Searching for Linux executable in: {}", dest_dir.display()); // Enhanced list of common browser executable names, Wayfern first since it // is the current name. Chrome/Chromium cover builds extracted before the // rename. let exe_names = [ // Wayfern variants (current naming) "wayfern", "wayfern-bin", "wayfern-browser", // Chrome/Chromium variants (builds extracted before the rename) "chrome", "chromium", "chromium-browser", "chromium-bin", ]; // First, try direct lookup in the main directory for exe_name in &exe_names { let exe_path = dest_dir.join(exe_name); if exe_path.exists() && self.is_executable(&exe_path) { log::info!("Found executable at root level: {}", exe_path.display()); return Ok(exe_path); } } // Enhanced list of common Linux subdirectories to search let subdirs = [ "bin", "usr/bin", "usr/local/bin", "opt", "sbin", "usr/sbin", "wayfern", "wayfern-linux", "chrome", "chromium", "chrome-linux", ".", "./", "Browser", "browser", "usr/lib/chromium", "usr/share/applications", "usr/bin", "AppRun", ]; // Search in subdirectories for subdir in &subdirs { let subdir_path = dest_dir.join(subdir); if subdir_path.exists() && subdir_path.is_dir() { for exe_name in &exe_names { let exe_path = subdir_path.join(exe_name); if exe_path.exists() && self.is_executable(&exe_path) { log::info!("Found executable in subdirectory: {}", exe_path.display()); return Ok(exe_path); } } } } // Look for AppImage files if let Ok(entries) = fs::read_dir(dest_dir) { for entry in entries.flatten() { let path = entry.path(); if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { if file_name.ends_with(".AppImage") && self.is_executable(&path) { log::info!("Found AppImage: {}", path.display()); return Ok(path); } } } } // Last resort: recursive search for any executable file log::info!("Performing recursive search for executables..."); match self.find_any_executable_recursive(dest_dir, 0).await { Ok(path) => { log::info!("Found executable via recursive search: {}", path.display()); Ok(path) } Err(e) => { // List all files in the directory for debugging log::info!("Failed to find executable. Directory contents:"); if let Ok(entries) = fs::read_dir(dest_dir) { for entry in entries.flatten() { let path = entry.path(); let is_exec = if path.is_file() { self.is_executable(&path) } else { false }; log::info!(" {} (executable: {})", path.display(), is_exec); } } Err( format!( "No executable found in {} after extraction. Original error: {}", dest_dir.display(), e ) .into(), ) } } } #[cfg(target_os = "linux")] fn is_executable(&self, path: &Path) -> bool { if let Ok(metadata) = path.metadata() { use std::os::unix::fs::PermissionsExt; return metadata.permissions().mode() & 0o111 != 0; } false } /// Set executable permissions recursively for all files in a directory #[cfg(unix)] async fn set_executable_permissions_recursive( &self, dir: &Path, ) -> Result<(), Box> { use std::os::unix::fs::PermissionsExt; if let Ok(entries) = fs::read_dir(dir) { for entry in entries.flatten() { let path = entry.path(); if path.is_file() { // Check if file looks like it should be executable if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { let name_lower = file_name.to_lowercase(); if name_lower.contains("chrome") || name_lower.contains("brave") || name_lower.contains("zen") || name_lower.contains("wayfern") || name_lower.ends_with(".appimage") || !name_lower.contains('.') { // Likely an executable, set permissions let mut permissions = path.metadata()?.permissions(); let current_mode = permissions.mode(); let new_mode = current_mode | 0o755; // rwxr-xr-x permissions.set_mode(new_mode); std::fs::set_permissions(&path, permissions)?; } } } else if path.is_dir() { // Recursively process subdirectories Box::pin(self.set_executable_permissions_recursive(&path)).await?; } } } Ok(()) } #[cfg(not(unix))] async fn set_executable_permissions_recursive( &self, _dir: &Path, ) -> Result<(), Box> { Ok(()) } #[cfg(target_os = "linux")] async fn find_any_executable_recursive( &self, dir: &Path, depth: usize, ) -> Result> { // Limit recursion depth to avoid infinite loops if depth > 5 { return Err("Maximum search depth reached".into()); } if let Ok(entries) = fs::read_dir(dir) { let mut directories = Vec::new(); let mut potential_executables = Vec::new(); // First pass: look for executable files for entry in entries.flatten() { let path = entry.path(); if path.is_file() && self.is_executable(&path) { // Prefer files with browser-like names if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { let name_lower = file_name.to_lowercase(); if name_lower.contains("chrome") || name_lower.contains("brave") || name_lower.contains("zen") || name_lower.contains("wayfern") || file_name.ends_with(".AppImage") { log::info!( "Found priority executable at depth {}: {}", depth, path.display() ); return Ok(path); } // Collect other executables as potential candidates potential_executables.push(path); } } else if path.is_dir() { directories.push(path); } } // Second pass: recursively search directories for dir_path in directories { if let Ok(result) = Box::pin(self.find_any_executable_recursive(&dir_path, depth + 1)).await { return Ok(result); } } // Third pass: if no browser-specific executable found, try any executable if !potential_executables.is_empty() { // Sort by filename to prefer more likely candidates potential_executables.sort_by(|a, b| { let a_name = a .file_name() .unwrap_or_default() .to_string_lossy() .to_lowercase(); let b_name = b .file_name() .unwrap_or_default() .to_string_lossy() .to_lowercase(); // Prefer shorter names (likely main executables) a_name.len().cmp(&b_name.len()) }); log::info!( "Found potential executable at depth {}: {}", depth, potential_executables[0].display() ); return Ok(potential_executables[0].clone()); } } Err(format!("No executable found in directory: {}", dir.display()).into()) } } #[cfg(test)] mod tests { use super::*; use std::fs::File; use std::io::Write; use tempfile::TempDir; #[cfg(target_os = "macos")] use std::fs::create_dir_all; #[test] fn test_format_detection_zip() { let extractor = Extractor::instance(); let temp_dir = TempDir::new().unwrap(); let zip_path = temp_dir.path().join("test.zip"); // Create a file with ZIP magic number let mut file = File::create(&zip_path).unwrap(); file.write_all(&[0x50, 0x4B, 0x03, 0x04]).unwrap(); // ZIP magic file.write_all(&[0; 8]).unwrap(); // padding let result = extractor.detect_file_format(&zip_path); assert!(result.is_ok()); assert_eq!(result.unwrap(), "zip"); } #[test] fn test_format_detection_dmg_by_extension() { let extractor = Extractor::instance(); let temp_dir = TempDir::new().unwrap(); let dmg_path = temp_dir.path().join("test.dmg"); // Create a file (magic number won't match, but extension will) let mut file = File::create(&dmg_path).unwrap(); file.write_all(b"fake dmg content").unwrap(); let result = extractor.detect_file_format(&dmg_path); assert!(result.is_ok()); assert_eq!(result.unwrap(), "dmg"); } #[test] fn test_format_detection_exe() { let extractor = Extractor::instance(); let temp_dir = TempDir::new().unwrap(); let exe_path = temp_dir.path().join("test.exe"); // Create a file with PE header let mut file = File::create(&exe_path).unwrap(); file.write_all(&[0x4D, 0x5A]).unwrap(); // PE magic file.write_all(&[0; 10]).unwrap(); // padding let result = extractor.detect_file_format(&exe_path); assert!(result.is_ok()); assert_eq!(result.unwrap(), "exe"); } #[test] fn test_format_detection_tar_gz() { let extractor = Extractor::instance(); let temp_dir = TempDir::new().unwrap(); let tar_gz_path = temp_dir.path().join("test.tar.gz"); // Create a file with gzip magic let mut file = File::create(&tar_gz_path).unwrap(); file.write_all(&[0x1F, 0x8B, 0x08]).unwrap(); // gzip magic file.write_all(&[0; 9]).unwrap(); // padding let result = extractor.detect_file_format(&tar_gz_path); assert!(result.is_ok()); assert_eq!(result.unwrap(), "tar.gz"); } #[test] fn test_format_detection_tar_bz2() { let extractor = Extractor::instance(); let temp_dir = TempDir::new().unwrap(); let tar_bz2_path = temp_dir.path().join("test.tar.bz2"); // Create a file with bzip2 magic let mut file = File::create(&tar_bz2_path).unwrap(); file.write_all(&[0x42, 0x5A, 0x68]).unwrap(); // bzip2 magic file.write_all(&[0; 9]).unwrap(); // padding let result = extractor.detect_file_format(&tar_bz2_path); assert!(result.is_ok()); assert_eq!(result.unwrap(), "tar.bz2"); } #[test] fn test_format_detection_tar_xz() { let extractor = Extractor::instance(); let temp_dir = TempDir::new().unwrap(); let tar_xz_path = temp_dir.path().join("test.tar.xz"); // Create a file with xz magic let mut file = File::create(&tar_xz_path).unwrap(); file .write_all(&[0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00]) .unwrap(); // xz magic file.write_all(&[0; 6]).unwrap(); // padding let result = extractor.detect_file_format(&tar_xz_path); assert!(result.is_ok()); assert_eq!(result.unwrap(), "tar.xz"); } #[test] fn test_format_detection_msi() { let extractor = Extractor::instance(); let temp_dir = TempDir::new().unwrap(); let msi_path = temp_dir.path().join("test.msi"); // Create a file with MSI magic let mut file = File::create(&msi_path).unwrap(); file .write_all(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]) .unwrap(); // MSI magic file.write_all(&[0; 4]).unwrap(); // padding let result = extractor.detect_file_format(&msi_path); assert!(result.is_ok()); assert_eq!(result.unwrap(), "msi"); } #[test] fn test_format_detection_msi_by_extension() { let extractor = Extractor::instance(); let temp_dir = TempDir::new().unwrap(); let msi_path = temp_dir.path().join("test.msi"); // Create a file (magic number won't match, but extension will) let mut file = File::create(&msi_path).unwrap(); file.write_all(b"fake msi content").unwrap(); let result = extractor.detect_file_format(&msi_path); assert!(result.is_ok()); assert_eq!(result.unwrap(), "msi"); } #[tokio::test] async fn test_extract_zip_reports_progress() { use std::sync::{Arc, Mutex}; #[derive(Default)] struct CapturingEmitter(Mutex>); impl crate::events::EventEmitter for CapturingEmitter { fn emit_value(&self, event: &str, payload: serde_json::Value) -> Result<(), String> { if event == "download-progress" { self.0.lock().unwrap().push(payload); } Ok(()) } } let captured = Arc::new(CapturingEmitter::default()); // The global emitter can only be set once per process; if another test ever // claims it first we lose observability, so only assert on captured events // when this test's emitter actually won. let emitter_installed = crate::events::set_global_emitter(captured.clone()).is_ok(); let extractor = Extractor::instance(); let temp_dir = TempDir::new().expect("Failed to create temp directory"); let dest_dir = temp_dir.path().join("extracted"); // A payload comfortably larger than io::copy's 8KB chunks so the counting // reader fires multiple times. let payload = vec![0x42u8; 256 * 1024]; let zip_path = temp_dir.path().join("test.zip"); { let file = std::fs::File::create(&zip_path).expect("Failed to create test zip file"); let mut zip = zip::ZipWriter::new(file); let options = zip::write::FileOptions::<()>::default().compression_method(zip::CompressionMethod::Stored); zip .start_file("data.bin", options) .expect("Failed to start zip file"); zip.write_all(&payload).expect("Failed to write to zip"); zip.finish().expect("Failed to finish zip"); } let reporter = ExtractionReporter::new("test-browser-zip-progress".to_string(), "1.0.0".to_string()); let result = extractor .extract_zip(&zip_path, &dest_dir, Some(&reporter)) .await; // Extraction itself must have worked even if no executable is found. assert!(dest_dir.join("data.bin").exists(), "payload not extracted"); if let Err(e) = result { assert!( e.to_string().contains("executable"), "unexpected extraction error: {e}" ); } if emitter_installed { let events = captured.0.lock().unwrap(); let ours: Vec<_> = events .iter() .filter(|p| p["browser"] == "test-browser-zip-progress") .collect(); assert!( !ours.is_empty(), "expected at least one extracting progress event" ); for p in &ours { assert_eq!(p["stage"], "extracting"); assert_eq!(p["version"], "1.0.0"); let pct = p["percentage"].as_f64().unwrap(); assert!( (0.0..=100.0).contains(&pct), "percentage out of range: {pct}" ); assert_eq!(p["total_bytes"].as_u64(), Some(payload.len() as u64)); } } } #[tokio::test] async fn test_extract_zip_with_test_archive() { let extractor = Extractor::instance(); let temp_dir = TempDir::new().expect("Failed to create temp directory"); let dest_dir = temp_dir.path().join("extracted"); // Create a test ZIP archive in memory let zip_path = temp_dir.path().join("test.zip"); { let file = std::fs::File::create(&zip_path).expect("Failed to create test zip file"); let mut zip = zip::ZipWriter::new(file); let options = zip::write::FileOptions::<()>::default().compression_method(zip::CompressionMethod::Stored); zip .start_file("test.txt", options) .expect("Failed to start zip file"); zip .write_all(b"Hello, World!") .expect("Failed to write to zip"); zip.finish().expect("Failed to finish zip"); } let result = extractor.extract_zip(&zip_path, &dest_dir, None).await; // The result might fail because we're looking for executables, but the extraction should work // Let's check if the file was extracted regardless of the result let extracted_file = dest_dir.join("test.txt"); assert!(extracted_file.exists(), "Extracted file should exist"); let content = std::fs::read_to_string(&extracted_file).expect("Failed to read extracted file"); assert_eq!( content.trim(), "Hello, World!", "Extracted content should match" ); // If the result is an error, it should be because no executable was found, not extraction failure if let Err(e) = result { let error_msg = e.to_string(); assert!( error_msg.contains("No executable found") || error_msg.contains("executable"), "Error should be about missing executable, not extraction failure: {error_msg}" ); } } #[tokio::test] async fn test_extract_tar_gz_with_test_archive() { let extractor = Extractor::instance(); let temp_dir = TempDir::new().expect("Failed to create temp directory"); let dest_dir = temp_dir.path().join("extracted"); // Create a test tar.gz archive in memory let tar_gz_path = temp_dir.path().join("test.tar.gz"); { let tar_gz_file = std::fs::File::create(&tar_gz_path).expect("Failed to create test tar.gz file"); let enc = flate2::write::GzEncoder::new(tar_gz_file, flate2::Compression::default()); let mut tar = tar::Builder::new(enc); let mut header = tar::Header::new_gnu(); header.set_path("test.txt").expect("Failed to set tar path"); header.set_size(13); // "Hello, World!" length header.set_cksum(); tar .append(&header, "Hello, World!".as_bytes()) .expect("Failed to append to tar"); tar.finish().expect("Failed to finish tar"); } let result = extractor .extract_tar_gz(&tar_gz_path, &dest_dir, None) .await; // Check if the file was extracted let extracted_file = dest_dir.join("test.txt"); assert!(extracted_file.exists(), "Extracted file should exist"); let content = std::fs::read_to_string(&extracted_file).expect("Failed to read extracted file"); assert_eq!( content.trim(), "Hello, World!", "Extracted content should match" ); // If the result is an error, it should be because no executable was found, not extraction failure if let Err(e) = result { let error_msg = e.to_string(); assert!( error_msg.contains("No executable found") || error_msg.contains("executable") || error_msg.contains("No .app found") || error_msg.contains("app not found"), "Error should be about missing executable/app, not extraction failure: {error_msg}" ); } } #[tokio::test] async fn test_extract_tar_bz2_with_test_archive() { let extractor = Extractor::instance(); let temp_dir = TempDir::new().expect("Failed to create temp directory"); let dest_dir = temp_dir.path().join("extracted"); // Create a test tar.bz2 archive in memory let tar_bz2_path = temp_dir.path().join("test.tar.bz2"); { let tar_bz2_file = std::fs::File::create(&tar_bz2_path).expect("Failed to create test tar.bz2 file"); let enc = bzip2::write::BzEncoder::new(tar_bz2_file, bzip2::Compression::default()); let mut tar = tar::Builder::new(enc); let mut header = tar::Header::new_gnu(); header.set_path("test.txt").expect("Failed to set tar path"); header.set_size(13); // "Hello, World!" length header.set_cksum(); tar .append(&header, "Hello, World!".as_bytes()) .expect("Failed to append to tar"); tar.finish().expect("Failed to finish tar"); } let result = extractor .extract_tar_bz2(&tar_bz2_path, &dest_dir, None) .await; // Check if the file was extracted let extracted_file = dest_dir.join("test.txt"); assert!(extracted_file.exists(), "Extracted file should exist"); let content = std::fs::read_to_string(&extracted_file).expect("Failed to read extracted file"); assert_eq!( content.trim(), "Hello, World!", "Extracted content should match" ); // If the result is an error, it should be because no executable was found, not extraction failure if let Err(e) = result { let error_msg = e.to_string(); assert!( error_msg.contains("No executable found") || error_msg.contains("executable") || error_msg.contains("No .app found") || error_msg.contains("app not found"), "Error should be about missing executable/app, not extraction failure: {error_msg}" ); } } #[tokio::test] async fn test_extract_tar_xz_with_test_archive() { let extractor = Extractor::instance(); let temp_dir = TempDir::new().expect("Failed to create temp directory"); let dest_dir = temp_dir.path().join("extracted"); // Create a test tar.xz archive in memory let tar_xz_path = temp_dir.path().join("test.tar.xz"); { // First create a tar archive in memory let mut tar_data = Vec::new(); { let mut tar = tar::Builder::new(&mut tar_data); let mut header = tar::Header::new_gnu(); header.set_path("test.txt").expect("Failed to set tar path"); header.set_size(13); // "Hello, World!" length header.set_cksum(); tar .append(&header, "Hello, World!".as_bytes()) .expect("Failed to append to tar"); tar.finish().expect("Failed to finish tar"); } // Then compress with xz let tar_xz_file = std::fs::File::create(&tar_xz_path).expect("Failed to create test tar.xz file"); let mut compressed_data = Vec::new(); lzma_rs::xz_compress(&mut std::io::Cursor::new(tar_data), &mut compressed_data) .expect("Failed to compress with xz"); std::io::Write::write_all(&mut std::io::BufWriter::new(tar_xz_file), &compressed_data) .expect("Failed to write compressed data"); } let result = extractor .extract_tar_xz(&tar_xz_path, &dest_dir, None) .await; // Check if the file was extracted let extracted_file = dest_dir.join("test.txt"); assert!(extracted_file.exists(), "Extracted file should exist"); let content = std::fs::read_to_string(&extracted_file).expect("Failed to read extracted file"); assert_eq!( content.trim(), "Hello, World!", "Extracted content should match" ); // If the result is an error, it should be because no executable was found, not extraction failure if let Err(e) = result { let error_msg = e.to_string(); assert!( error_msg.contains("No executable found") || error_msg.contains("executable") || error_msg.contains("No .app found") || error_msg.contains("app not found"), "Error should be about missing executable/app, not extraction failure: {error_msg}" ); } } #[test] fn test_unsupported_archive_format() { let extractor = Extractor::instance(); let temp_dir = TempDir::new().unwrap(); let fake_archive = temp_dir.path().join("test.rar"); // Create a file with invalid header let mut file = File::create(&fake_archive).unwrap(); file.write_all(b"invalid content").unwrap(); // Test format detection let result = extractor.detect_file_format(&fake_archive); assert!(result.is_ok()); assert_eq!(result.unwrap(), "unknown"); } #[cfg(target_os = "macos")] #[tokio::test] async fn test_find_app_at_root_level() { let extractor = Extractor::instance(); let temp_dir = TempDir::new().unwrap(); // Create a Wayfern.app directory let wayfern_app = temp_dir.path().join("Wayfern.app"); create_dir_all(&wayfern_app).unwrap(); // Create the standard macOS app structure let contents_dir = wayfern_app.join("Contents"); let macos_dir = contents_dir.join("MacOS"); create_dir_all(&macos_dir).unwrap(); // Create the executable let executable = macos_dir.join("Wayfern"); File::create(&executable).unwrap(); // Test finding the app let result = extractor.find_app_in_directory(temp_dir.path()).await; assert!(result.is_ok()); let found_app = result.unwrap(); assert_eq!(found_app.file_name().unwrap(), "Wayfern.app"); assert!(found_app.exists()); } #[test] fn test_is_executable() { #[allow(unused_variables)] let extractor = Extractor::instance(); let temp_dir = TempDir::new().expect("Failed to create temp directory"); // Create a regular file let regular_file = temp_dir.path().join("regular.txt"); File::create(®ular_file).expect("Failed to create test file"); #[cfg(target_os = "linux")] { // Should not be executable initially assert!( !extractor.is_executable(®ular_file), "File should not be executable initially" ); // Make it executable use std::os::unix::fs::PermissionsExt; let mut permissions = regular_file .metadata() .expect("Failed to get file metadata") .permissions(); permissions.set_mode(0o755); std::fs::set_permissions(®ular_file, permissions).expect("Failed to set permissions"); // Should now be executable assert!( extractor.is_executable(®ular_file), "File should be executable after setting permissions" ); } #[cfg(not(target_os = "linux"))] { // On non-Linux systems, the is_executable method is not available // We'll just verify the file exists since executable permissions work differently on Windows/macOS assert!(regular_file.exists(), "Test file should exist"); // On Unix systems (but not Linux), we can still test basic permission setting #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut permissions = regular_file .metadata() .expect("Failed to get file metadata") .permissions(); permissions.set_mode(0o755); std::fs::set_permissions(®ular_file, permissions).expect("Failed to set permissions"); // Verify the permissions were set let new_permissions = regular_file .metadata() .expect("Failed to get updated metadata") .permissions(); assert_eq!( new_permissions.mode() & 0o777, 0o755, "Permissions should be set to 755" ); } } } } // Global singleton instance lazy_static::lazy_static! { static ref EXTRACTOR: Extractor = Extractor::new(); }