//! Server-proxied releases: never accept installer paths or commands from the UI. use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::{io::Write, path::Path, time::Duration}; use tauri::{AppHandle, Emitter, Manager, State}; use tokio::sync::Mutex; #[derive(Clone, Deserialize, Serialize)] pub struct Build { client: String, version: String, filename: String, size: u64, sha256: Option, download_url: String, } #[derive(Deserialize)] struct Catalog { version: String, notes_md: String, clients: Vec } #[derive(Clone, Serialize)] pub struct Release { build: Build, notes: String } #[derive(Default)] pub struct Updater { release: Mutex> } fn client() -> &'static str { if cfg!(target_os = "windows") { "windows" } else if cfg!(target_os = "macos") { "macos" } else { "linux" } } fn version_code(raw: &str) -> Option { let (base, rc) = raw.trim_start_matches('v').split_once("-rc.").map_or((raw.trim_start_matches('v'), "99"), |v| v); let parts: Vec = base.split('.').map(str::parse).collect::>().ok()?; let rc: u64 = rc.parse().ok()?; if parts.len() != 3 || parts[0] > 1_000_000 || parts[1] > 99 || parts[2] > 99 || rc > 99 { return None; } Some(parts[0] * 1_000_000 + parts[1] * 10_000 + parts[2] * 100 + rc) } fn http() -> Result { reqwest::Client::builder().redirect(reqwest::redirect::Policy::none()) .connect_timeout(Duration::from_secs(10)).read_timeout(Duration::from_secs(60)) .build().map_err(|e| e.to_string()) } #[tauri::command] pub async fn update_check(app: AppHandle, updater: State<'_, Updater>) -> Result, String> { let mut saved = updater.release.lock().await; *saved = None; let server = crate::store::load_server(&app)?.ok_or("No server selected")?; let catalog: Catalog = http()?.get(format!("{server}/api/v1/app/clients")).send().await .map_err(|e| e.to_string())?.error_for_status().map_err(|e| e.to_string())? .json().await.map_err(|e| e.to_string())?; let Some(build) = catalog.clients.into_iter().find(|b| b.client == client()) else { return Ok(None); }; let current = version_code(&app.package_info().version.to_string()).ok_or("Invalid installed version")?; if version_code(&build.version).ok_or("Invalid release version")? <= current { return Ok(None); } let notes = if build.version == catalog.version { catalog.notes_md } else { String::new() }; let release = Release { build, notes }; validate_build(&release.build)?; *saved = Some((server, release.clone())); Ok(Some(release)) } fn validate_build(build: &Build) -> Result<(), String> { let safe_name = !build.filename.is_empty() && build.filename.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)); let extension = Path::new(&build.filename).extension().and_then(|s| s.to_str()).unwrap_or(""); let supported = match client() { "windows" => extension == "exe" || extension == "msi", "linux" => extension == "deb" || extension == "rpm", _ => extension == "dmg" }; let checksum = build.sha256.as_deref().is_some_and(|s| s.len() == 64 && s.bytes().all(|c| c.is_ascii_hexdigit())); if !safe_name || !supported || !checksum || build.size == 0 || build.size > 1024 * 1024 * 1024 { return Err("Unsupported installer or missing checksum".into()); } // Restrict to the existing, public Pipu client-file route; no arbitrary URL from a release. if build.download_url != format!("/api/v1/app/download/{}/{}", client(), build.version) { return Err("Invalid installer URL".into()); } Ok(()) } #[tauri::command] pub async fn update_install(app: AppHandle, updater: State<'_, Updater>) -> Result<(), String> { let saved = updater.release.lock().await; let (server, release) = saved.as_ref().ok_or("Check for an update first")?; if crate::store::load_server(&app)?.as_ref() != Some(server) { return Err("Server changed".into()); } let build = &release.build; validate_build(build)?; let dir = app.path().app_cache_dir().map_err(|e| e.to_string())?.join("updates"); std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; let path = dir.join(&build.filename); let part = dir.join(format!("{}.part", uuid::Uuid::new_v4())); let result = async { let mut response = http()?.get(format!("{server}{}", build.download_url)).send().await .map_err(|e| e.to_string())?.error_for_status().map_err(|e| e.to_string())?; let mut file = std::fs::File::create(&part).map_err(|e| e.to_string())?; let mut hash = Sha256::new(); let mut bytes = 0u64; while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? { bytes += chunk.len() as u64; if bytes > build.size { return Err("Installer exceeds expected size".to_string()); } hash.update(&chunk); file.write_all(&chunk).map_err(|e| e.to_string())?; let _ = app.emit("update-progress", (bytes as f64 / build.size as f64).to_string()); } if bytes != build.size || format!("{:x}", hash.finalize()) != build.sha256.as_ref().unwrap().to_lowercase() { return Err("Installer checksum mismatch".into()); } file.sync_all().map_err(|e| e.to_string())?; drop(file); if path.exists() { std::fs::remove_file(&path).map_err(|e| e.to_string())?; } std::fs::rename(&part, &path).map_err(|e| e.to_string())?; launch(&path)?; Ok(()) }.await; let _ = std::fs::remove_file(&part); result } fn launch(path: &Path) -> Result<(), String> { #[cfg(target_os = "windows")] { use std::os::windows::process::CommandExt; let mut command = if path.extension().and_then(|s| s.to_str()) == Some("msi") { let mut cmd = std::process::Command::new("msiexec.exe"); cmd.arg("/i").arg(path); cmd } else { std::process::Command::new(path) }; command.creation_flags(0x08000000).spawn().map_err(|e| e.to_string())?; } #[cfg(target_os = "linux")] { std::process::Command::new("xdg-open").arg(path).spawn().map_err(|e| e.to_string())?; } #[cfg(target_os = "macos")] { std::process::Command::new("open").arg(path).spawn().map_err(|e| e.to_string())?; } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn release_order() { assert!(version_code("1.7.2") > version_code("1.7.2-rc.3")); assert!(version_code("1.8.0-rc.1") > version_code("1.7.99")); assert_eq!(version_code("1.7.1"), Some(1070199)); assert_eq!(version_code("1.7.100"), None); assert_eq!(version_code("1.7.1;cmd"), None); } #[test] fn installer_is_restricted_to_verified_server_asset() { let extension = match client() { "windows" => "exe", "linux" => "deb", _ => "dmg" }; let mut build = Build { client: client().into(), version: "1.8.0".into(), filename: format!("pipu-1.8.0.{extension}"), size: 42, sha256: Some("a".repeat(64)), download_url: format!("/api/v1/app/download/{}/1.8.0", client()) }; assert!(validate_build(&build).is_ok()); build.download_url = "https://other.example/installer.exe".into(); assert!(validate_build(&build).is_err()); build.download_url = format!("/api/v1/app/download/{}/1.8.0", client()); build.filename = "../installer.exe".into(); assert!(validate_build(&build).is_err()); build.filename = format!("pipu.{extension}"); build.sha256 = None; assert!(validate_build(&build).is_err()); } }