//! Installer hand-off (port of `updates/updateInstallerLauncher.js`). //! Windows/macOS: the verified EXE/DMG is opened by the OS (launched ≠ installed). //! Linux: fixed `/usr/bin/pkexec /usr/bin/apt-get install --reinstall --yes `, //! no shell, root-owned non-writable executables, 30 min timeout. use crate::core::error::{CoreError, CoreResult}; use std::path::Path; use std::time::Duration; pub const PKEXEC_PATH: &str = "/usr/bin/pkexec"; pub const APT_GET_PATH: &str = "/usr/bin/apt-get"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct LaunchResult { pub launched: bool, pub installed: bool, pub restart: bool, } fn install_error(code: &str, message: &str) -> CoreError { CoreError::new(code, message) } pub fn assert_safe_regular_file(path: &Path, label: &str) -> CoreResult { if !path.is_absolute() { return Err(install_error("UPDATE_INSTALL_UNSAFE_PATH", &format!("{label} path must be absolute."))); } let meta = std::fs::symlink_metadata(path).map_err(|_| install_error("UPDATE_INSTALL_FILE_MISSING", &format!("{label} is missing.")))?; #[cfg(unix)] let links = { use std::os::unix::fs::MetadataExt; meta.nlink() }; #[cfg(not(unix))] let links = 1; if !meta.is_file() || meta.file_type().is_symlink() || links != 1 || meta.len() == 0 { return Err(install_error("UPDATE_INSTALL_UNSAFE_PATH", &format!("{label} must be a non-empty regular file."))); } Ok(meta) } #[cfg(unix)] pub fn assert_trusted_system_executable(path: &Path) -> CoreResult<()> { use std::os::unix::fs::MetadataExt; let meta = assert_safe_regular_file(path, "System installer executable")?; if meta.uid() != 0 { return Err(install_error("UPDATE_INSTALL_UNSAFE_EXECUTABLE", "System installer executable is not owned by root.")); } if meta.mode() & 0o111 == 0 { return Err(install_error("UPDATE_INSTALL_UNSAFE_EXECUTABLE", "System installer executable is not executable.")); } if meta.mode() & 0o022 != 0 { return Err(install_error("UPDATE_INSTALL_UNSAFE_EXECUTABLE", "System installer executable is writable by an untrusted account.")); } Ok(()) } pub async fn install(app: &tauri::AppHandle, path: &Path, logger: &crate::core::logging::Logger) -> CoreResult { if cfg!(target_os = "linux") { return install_deb(path, logger).await; } use tauri_plugin_opener::OpenerExt; app.opener().open_path(path.to_string_lossy(), None::<&str>).map_err(|e| install_error("UPDATE_INSTALL_FAILED", &format!("The installer could not be opened: {e}")))?; Ok(LaunchResult { launched: true, installed: false, restart: false }) } #[cfg(unix)] async fn install_deb(path: &Path, logger: &crate::core::logging::Logger) -> CoreResult { if !path.is_absolute() { return Err(install_error("UPDATE_INSTALL_UNSAFE_PATH", "Verified Debian update package path must be absolute.")); } if path.extension().map(|e| e.to_string_lossy().to_lowercase()) != Some("deb".into()) { return Err(install_error("UPDATE_INSTALL_UNSUPPORTED_PACKAGE", "Linux updates must use a Debian package.")); } assert_safe_regular_file(path, "Verified Debian update package")?; assert_trusted_system_executable(Path::new(PKEXEC_PATH))?; assert_trusted_system_executable(Path::new(APT_GET_PATH))?; let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); logger.info("Starting privileged Debian update installation.", serde_json::json!({ "filename": name })); let mut command = tokio::process::Command::new(PKEXEC_PATH); command.args([APT_GET_PATH, "install", "--reinstall", "--yes"]).arg(path).stdin(std::process::Stdio::null()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped()).kill_on_drop(true); let child = command.spawn().map_err(|_| install_error("UPDATE_INSTALL_LAUNCH_FAILED", "The privileged installer could not be started."))?; let output = tokio::time::timeout(Duration::from_secs(30 * 60), child.wait_with_output()) .await .map_err(|_| install_error("UPDATE_INSTALL_TIMEOUT", "The Debian update installation timed out."))? .map_err(|_| install_error("UPDATE_INSTALL_LAUNCH_FAILED", "The privileged installer could not be started."))?; if output.status.code() == Some(0) { logger.info("Debian update installation completed.", serde_json::json!({ "filename": name })); return Ok(LaunchResult { launched: true, installed: true, restart: true }); } let denied = matches!(output.status.code(), Some(126) | Some(127)); let text = format!("{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); let tail: String = text.trim().chars().rev().take(4096).collect::>().into_iter().rev().collect(); logger.warn("Debian update installation failed.", serde_json::json!({ "code": output.status.code(), "output": tail })); Err(if denied { install_error("UPDATE_INSTALL_AUTHORIZATION_DENIED", "Administrator authorization for the update was canceled or denied.") } else { install_error("UPDATE_INSTALL_FAILED", "The Debian update installation failed.") }) } #[cfg(not(unix))] async fn install_deb(_path: &Path, _logger: &crate::core::logging::Logger) -> CoreResult { Err(install_error("UPDATE_INSTALL_UNSUPPORTED_PACKAGE", "Linux updates must use a Debian package.")) } #[cfg(all(test, unix))] mod tests { use super::*; #[test] fn unsafe_files_rejected() { let dir = tempfile::tempdir().unwrap(); assert_eq!(assert_safe_regular_file(Path::new("relative.deb"), "x").unwrap_err().code, "UPDATE_INSTALL_UNSAFE_PATH"); let file = dir.path().join("a.deb"); std::fs::write(&file, b"x").unwrap(); let link = dir.path().join("b.deb"); std::os::unix::fs::symlink(&file, &link).unwrap(); assert!(assert_safe_regular_file(&file, "x").is_ok()); assert!(assert_safe_regular_file(&link, "x").is_err()); // A file owned by the current (non-root) user is not a trusted system executable. if unsafe { libc::getuid() } != 0 { assert_eq!(assert_trusted_system_executable(&file).unwrap_err().code, "UPDATE_INSTALL_UNSAFE_EXECUTABLE"); } } }