Files
donutbrowser/src-tauri/src/extraction.rs
T
2026-07-11 15:01:04 +04:00

2087 lines
66 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<std::time::Instant>,
}
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<u64>) {
{
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<u64>) {
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<R, F: FnMut(u64)> {
inner: R,
bytes_read: u64,
on_read: F,
}
impl<R, F: FnMut(u64)> ProgressReader<R, F> {
fn new(inner: R, on_read: F) -> Self {
Self {
inner,
bytes_read: 0,
on_read,
}
}
}
impl<R: Read, F: FnMut(u64)> Read for ProgressReader<R, F> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
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<impl Read + '_> {
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<dyn std::error::Error + Send + Sync>> {
// 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/<version>/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<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// 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<String, Box<dyn std::error::Error + Send + Sync>> {
// 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<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
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", &copy_src, &copy_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<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
self.find_app_recursive(dir, 0).await
}
#[cfg(target_os = "macos")]
async fn find_app_recursive(
&self,
dir: &Path,
depth: usize,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// 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<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
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<u64> = 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<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
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<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
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<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
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 080%, and the tar
// unpack of the decompressed data to 80100%.
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<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
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<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
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<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
{
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<dyn std::error::Error + Send + Sync>> {
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<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// 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<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
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<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
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<Output = Result<PathBuf, Box<dyn std::error::Error + Send + Sync>>>
+ 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<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
Ok(())
}
#[cfg(target_os = "linux")]
async fn find_any_executable_recursive(
&self,
dir: &Path,
depth: usize,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// 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<Vec<serde_json::Value>>);
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(&regular_file).expect("Failed to create test file");
#[cfg(target_os = "linux")]
{
// Should not be executable initially
assert!(
!extractor.is_executable(&regular_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(&regular_file, permissions).expect("Failed to set permissions");
// Should now be executable
assert!(
extractor.is_executable(&regular_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(&regular_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();
}