diff --git a/src-tauri/src/app_auto_updater.rs b/src-tauri/src/app_auto_updater.rs index 9cc77bd..399f499 100644 --- a/src-tauri/src/app_auto_updater.rs +++ b/src-tauri/src/app_auto_updater.rs @@ -768,42 +768,6 @@ impl AppAutoUpdater { .map(|a| a.browser_download_url.clone()) } - /// Extract the hex digest for `filename` from standard `sha256sum` output - /// (` `, optionally with the `*` binary-mode marker). - fn find_checksum_for_file(checksums_text: &str, filename: &str) -> Option { - checksums_text.lines().find_map(|line| { - let (hash, rest) = line.split_once(char::is_whitespace)?; - let name = rest.trim_start().trim_start_matches('*'); - if name == filename && hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()) { - Some(hash.to_ascii_lowercase()) - } else { - None - } - }) - } - - fn sha256_file(path: &Path) -> Result> { - use sha2::{Digest, Sha256}; - use std::io::Read; - let mut file = fs::File::open(path)?; - let mut hasher = Sha256::new(); - let mut buf = vec![0u8; 1024 * 1024]; - loop { - let n = file.read(&mut buf)?; - if n == 0 { - break; - } - hasher.update(&buf[..n]); - } - let digest = hasher.finalize(); - let mut hex = String::with_capacity(digest.len() * 2); - for byte in digest { - use std::fmt::Write; - let _ = write!(hex, "{byte:02x}"); - } - Ok(hex) - } - /// Fetch the release's SHA256SUMS.txt and return the expected digest for /// `filename`. Called BEFORE the (large) asset download so an unverifiable /// release is rejected without wasting the transfer. Every failure mode @@ -857,7 +821,7 @@ impl AppAutoUpdater { } }; - let Some(expected) = Self::find_checksum_for_file(&checksums_text, filename) else { + let Some(expected) = crate::checksum::find_checksum_for_file(&checksums_text, filename) else { log::warn!( "No checksum entry for {filename} in {}", Self::CHECKSUMS_ASSET_NAME @@ -877,7 +841,7 @@ impl AppAutoUpdater { expected: &str, asset_digest: Option<&str>, ) -> Result<(), Box> { - let actual = Self::sha256_file(file_path)?; + let actual = crate::checksum::sha256_file(file_path)?; let mut mismatch = !actual.eq_ignore_ascii_case(expected); @@ -2226,48 +2190,6 @@ mod tests { ); } - #[test] - fn test_find_checksum_for_file() { - let sums = "\ -0e5a4601745092b7d1c93c1e7e1c30d923be3d1e916b661bd53d1c0c9c7f0a11 Donut_0.29.0_aarch64.dmg -ABCDEF01745092B7D1C93C1E7E1C30D923BE3D1E916B661BD53D1C0C9C7F0A22 *Donut_0.29.0_x64.dmg -not-a-hash Donut_0.29.0_amd64.deb -"; - - // Plain entry. - assert_eq!( - AppAutoUpdater::find_checksum_for_file(sums, "Donut_0.29.0_aarch64.dmg").as_deref(), - Some("0e5a4601745092b7d1c93c1e7e1c30d923be3d1e916b661bd53d1c0c9c7f0a11") - ); - // Binary-mode marker is stripped; hash is normalized to lowercase. - assert_eq!( - AppAutoUpdater::find_checksum_for_file(sums, "Donut_0.29.0_x64.dmg").as_deref(), - Some("abcdef01745092b7d1c93c1e7e1c30d923be3d1e916b661bd53d1c0c9c7f0a22") - ); - // Entries with malformed hashes are rejected rather than trusted. - assert_eq!( - AppAutoUpdater::find_checksum_for_file(sums, "Donut_0.29.0_amd64.deb"), - None - ); - // Missing file. - assert_eq!( - AppAutoUpdater::find_checksum_for_file(sums, "Donut_0.29.0_arm64.deb"), - None - ); - } - - #[test] - fn test_sha256_file_matches_known_digest() { - let temp_dir = tempfile::TempDir::new().unwrap(); - let path = temp_dir.path().join("data.bin"); - std::fs::write(&path, b"hello world").unwrap(); - assert_eq!( - AppAutoUpdater::sha256_file(&path).unwrap(), - // sha256 of "hello world" - "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" - ); - } - #[test] fn test_find_checksums_url() { let assets = vec![ diff --git a/src-tauri/src/browser.rs b/src-tauri/src/browser.rs index 9f998c6..b22e1bb 100644 --- a/src-tauri/src/browser.rs +++ b/src-tauri/src/browser.rs @@ -25,6 +25,14 @@ impl BrowserType { } } + /// Brand name for user-facing strings. `as_str` is the internal id and is + /// the wrong thing to put in a message the user reads. + pub fn display_name(&self) -> &'static str { + match self { + BrowserType::Wayfern => "Wayfern", + } + } + pub fn from_str(s: &str) -> Result { match s { "wayfern" => Ok(BrowserType::Wayfern), diff --git a/src-tauri/src/checksum.rs b/src-tauri/src/checksum.rs new file mode 100644 index 0000000..802b4fc --- /dev/null +++ b/src-tauri/src/checksum.rs @@ -0,0 +1,178 @@ +//! SHA256 helpers shared by the app self-updater and the browser downloader. +//! +//! Both verify a downloaded artifact against a digest published beside it, so +//! the hashing and the `sha256sum` parsing live here instead of in either +//! caller. + +use std::path::Path; + +/// Stream `path` through SHA256 and return the lowercase hex digest. Reads in +/// 1 MiB blocks so a multi-gigabyte browser archive never lands in memory. +pub fn sha256_file(path: &Path) -> Result> { + use sha2::{Digest, Sha256}; + use std::io::Read; + let mut file = std::fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut buf = vec![0u8; 1024 * 1024]; + loop { + let n = file.read(&mut buf)?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + let digest = hasher.finalize(); + let mut hex = String::with_capacity(digest.len() * 2); + for byte in digest { + use std::fmt::Write; + let _ = write!(hex, "{byte:02x}"); + } + Ok(hex) +} + +/// Extract the hex digest for `filename` from standard `sha256sum` output +/// (` `, optionally with the `*` binary-mode marker). +pub fn find_checksum_for_file(checksums_text: &str, filename: &str) -> Option { + checksums_text.lines().find_map(|line| { + let (hash, rest) = line.split_once(char::is_whitespace)?; + let name = rest.trim_start().trim_start_matches('*'); + if name == filename && is_sha256_hex(hash) { + Some(hash.to_ascii_lowercase()) + } else { + None + } + }) +} + +/// Digest from a single-asset `.sha256` sidecar. +/// +/// Prefers the entry named `filename`, because a name binds the digest to the +/// asset it covers. Falls back to a lone digest only when the sidecar holds +/// exactly one: `sha256sum < file` writes `-` as the name and some publishers +/// emit the bare hash, and neither is ambiguous when it stands alone. A +/// sidecar listing several assets always needs the name to match. +pub fn parse_sidecar_digest(text: &str, filename: &str) -> Option { + if let Some(named) = find_checksum_for_file(text, filename) { + return Some(named); + } + + let mut digests = text + .lines() + .filter_map(|line| line.split_whitespace().next()) + .filter(|token| is_sha256_hex(token)); + let only = digests.next()?; + if digests.next().is_some() { + return None; + } + Some(only.to_ascii_lowercase()) +} + +fn is_sha256_hex(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const HELLO_WORLD_SHA256: &str = + "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"; + + #[test] + fn test_find_checksum_for_file() { + let sums = "\ +0e5a4601745092b7d1c93c1e7e1c30d923be3d1e916b661bd53d1c0c9c7f0a11 Donut_0.29.0_aarch64.dmg +ABCDEF01745092B7D1C93C1E7E1C30D923BE3D1E916B661BD53D1C0C9C7F0A22 *Donut_0.29.0_x64.dmg +not-a-hash Donut_0.29.0_amd64.deb +"; + + // Plain entry. + assert_eq!( + find_checksum_for_file(sums, "Donut_0.29.0_aarch64.dmg").as_deref(), + Some("0e5a4601745092b7d1c93c1e7e1c30d923be3d1e916b661bd53d1c0c9c7f0a11") + ); + // Binary-mode marker is stripped; hash is normalized to lowercase. + assert_eq!( + find_checksum_for_file(sums, "Donut_0.29.0_x64.dmg").as_deref(), + Some("abcdef01745092b7d1c93c1e7e1c30d923be3d1e916b661bd53d1c0c9c7f0a22") + ); + // Entries with malformed hashes are rejected rather than trusted. + assert_eq!(find_checksum_for_file(sums, "Donut_0.29.0_amd64.deb"), None); + // Missing file. + assert_eq!(find_checksum_for_file(sums, "Donut_0.29.0_arm64.deb"), None); + } + + #[test] + fn test_sha256_file_matches_known_digest() { + let temp_dir = tempfile::TempDir::new().unwrap(); + let path = temp_dir.path().join("data.bin"); + std::fs::write(&path, b"hello world").unwrap(); + assert_eq!(sha256_file(&path).unwrap(), HELLO_WORLD_SHA256); + } + + #[test] + fn test_parse_sidecar_digest_prefers_the_named_entry() { + // The real Wayfern sidecar shape: ` `, one asset per file. + let sidecar = format!("{HELLO_WORLD_SHA256} wayfern-151.0.7922.71_windows_x64.zip\n"); + assert_eq!( + parse_sidecar_digest(&sidecar, "wayfern-151.0.7922.71_windows_x64.zip").as_deref(), + Some(HELLO_WORLD_SHA256) + ); + } + + #[test] + fn test_parse_sidecar_digest_accepts_an_unnamed_lone_digest() { + // `sha256sum < file` writes `-` as the name, and some publishers emit the + // bare hash. Both cover the one asset the sidecar sits beside. + for sidecar in [ + format!("{HELLO_WORLD_SHA256} -\n"), + format!("{HELLO_WORLD_SHA256}\n"), + format!(" {HELLO_WORLD_SHA256} \n"), + ] { + assert_eq!( + parse_sidecar_digest(&sidecar, "wayfern.zip").as_deref(), + Some(HELLO_WORLD_SHA256), + "should accept lone digest in {sidecar:?}" + ); + } + } + + #[test] + fn test_parse_sidecar_digest_normalizes_case() { + let sidecar = format!("{} -\n", HELLO_WORLD_SHA256.to_ascii_uppercase()); + assert_eq!( + parse_sidecar_digest(&sidecar, "wayfern.zip").as_deref(), + Some(HELLO_WORLD_SHA256) + ); + } + + #[test] + fn test_parse_sidecar_digest_rejects_an_ambiguous_multi_entry_sidecar() { + let sidecar = format!( + "{HELLO_WORLD_SHA256} other.zip\n\ + ABCDEF01745092B7D1C93C1E7E1C30D923BE3D1E916B661BD53D1C0C9C7F0A22 another.zip\n" + ); + // Two candidates and neither is named `wayfern.zip`: guessing would defeat + // the point of the check. + assert_eq!(parse_sidecar_digest(&sidecar, "wayfern.zip"), None); + } + + #[test] + fn test_parse_sidecar_digest_rejects_junk() { + assert_eq!(parse_sidecar_digest("", "wayfern.zip"), None); + assert_eq!( + parse_sidecar_digest("not-a-hash wayfern.zip", "wayfern.zip"), + None + ); + // An HTML error page served with HTTP 200 must not read as a digest. + assert_eq!( + parse_sidecar_digest("404", "wayfern.zip"), + None + ); + // Right shape, wrong length. + assert_eq!( + parse_sidecar_digest("abc123 wayfern.zip", "wayfern.zip"), + None + ); + } +} diff --git a/src-tauri/src/downloader.rs b/src-tauri/src/downloader.rs index 9761b9b..da15191 100644 --- a/src-tauri/src/downloader.rs +++ b/src-tauri/src/downloader.rs @@ -15,6 +15,10 @@ use crate::events; // the UI can surface it and the caller can move on / retry. const STREAM_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); +// Sent on both the asset request and its checksum sidecar so the CDN sees one +// consistent client for the pair. +const DOWNLOAD_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"; + // Global state to track currently downloading browser-version pairs lazy_static::lazy_static! { static ref DOWNLOADING_BROWSERS: std::sync::Arc>> = @@ -233,6 +237,113 @@ impl Downloader { .await?; log::info!("Download URL resolved"); + // Every browser asset is published with a `.sha256` sidecar. Fetch + // it before the transfer starts: an asset nobody can verify costs one small + // request to reject here, or a wasted gigabyte to reject later. + let expected_sha256 = self + .fetch_expected_archive_checksum(&download_url, browser_type.display_name(), version) + .await?; + + let file_path = self + .stream_download( + browser_type.clone(), + version, + &download_url, + file_path, + cancel_token, + ) + .await?; + + // Hashing a multi-gigabyte archive takes seconds, so tell the UI what the + // pause is for instead of leaving the bar sitting at 100%. + let _ = events::emit( + "download-progress", + &DownloadProgress { + browser: browser_type.as_str().to_string(), + version: version.to_string(), + downloaded_bytes: 0, + total_bytes: None, + percentage: 100.0, + speed_bytes_per_sec: 0.0, + eta_seconds: None, + stage: "verifying".to_string(), + }, + ); + verify_archive_checksum( + &file_path, + &expected_sha256, + browser_type.display_name(), + version, + ) + .await?; + + Ok(file_path) + } + + /// Fetch `.sha256` and return the digest it publishes for the asset. + /// Every failure mode maps to `BROWSER_CHECKSUM_UNAVAILABLE`; the specifics + /// go to the log. + async fn fetch_expected_archive_checksum( + &self, + download_url: &str, + browser: &str, + version: &str, + ) -> Result> { + let unavailable = || -> Box { + serde_json::json!({ + "code": "BROWSER_CHECKSUM_UNAVAILABLE", + "params": { "browser": browser, "version": version } + }) + .to_string() + .into() + }; + + let sidecar_url = checksum_sidecar_url(download_url); + let response = match self + .client + .get(&sidecar_url) + .header("User-Agent", DOWNLOAD_USER_AGENT) + .send() + .await + { + Ok(response) if response.status().is_success() => response, + Ok(response) => { + log::warn!( + "Checksum sidecar request failed for {browser} {version}: HTTP {}", + response.status() + ); + return Err(unavailable()); + } + Err(e) => { + log::warn!("Checksum sidecar request failed for {browser} {version}: {e}"); + return Err(unavailable()); + } + }; + + let sidecar_text = match response.text().await { + Ok(text) => text, + Err(e) => { + log::warn!("Failed to read the checksum sidecar for {browser} {version}: {e}"); + return Err(unavailable()); + } + }; + + let asset_name = asset_filename_from_url(download_url); + let Some(expected) = crate::checksum::parse_sidecar_digest(&sidecar_text, asset_name) else { + log::warn!("No usable digest for {asset_name} in {sidecar_url}"); + return Err(unavailable()); + }; + Ok(expected) + } + + async fn stream_download( + &self, + browser_type: BrowserType, + version: &str, + download_url: &str, + file_path: PathBuf, + cancel_token: Option<&CancellationToken>, + ) -> Result> { // In-session resume: a large (~1GB) download over a flaky connection can // drop mid-stream. Rather than surfacing the first stall/chunk error as a // terminal failure (which forces the user to re-click and risks the CDN @@ -257,11 +368,8 @@ impl Downloader { for attempt in 0..=max_send_retries { let mut request = self .client - .get(&download_url) - .header( - "User-Agent", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", - ); + .get(download_url) + .header("User-Agent", DOWNLOAD_USER_AGENT); if existing_size > 0 { request = request.header("Range", format!("bytes={existing_size}-")); @@ -682,7 +790,7 @@ impl Downloader { }; let _ = events::emit("download-progress", &progress); - return Err(format!("Failed to download browser: {e}").into()); + return Err(contextualize("Failed to download browser", e)); } }; @@ -872,6 +980,74 @@ impl Downloader { } } +/// Offset of a URL's query or fragment, or its length when it has neither. +fn url_path_end(url: &str) -> usize { + url.find(['?', '#']).unwrap_or(url.len()) +} + +/// The `.sha256` published next to every browser asset. Any query or +/// fragment stays at the end so a signed URL keeps working. +fn checksum_sidecar_url(download_url: &str) -> String { + let (base, suffix) = download_url.split_at(url_path_end(download_url)); + format!("{base}.sha256{suffix}") +} + +/// Last path segment of `url`. This is the name a `sha256sum` sidecar records, +/// and it is not the local filename: the local one is built from the running +/// platform, while this one is whatever the publisher called the asset. +fn asset_filename_from_url(url: &str) -> &str { + url[..url_path_end(url)].rsplit('/').next().unwrap_or("") +} + +/// Compare the finished archive against the digest published beside it. A +/// mismatch means the bytes on disk are not the asset the manifest promised, +/// so the file is deleted instead of being handed to the extractor. +async fn verify_archive_checksum( + file_path: &Path, + expected: &str, + browser: &str, + version: &str, +) -> Result<(), Box> { + let hash_path = file_path.to_path_buf(); + let actual = tokio::task::spawn_blocking(move || crate::checksum::sha256_file(&hash_path)) + .await + .map_err(|e| -> Box { + format!("Checksum task failed: {e}").into() + })??; + + if actual.eq_ignore_ascii_case(expected) { + log::info!("Checksum verified for {browser} {version}: {actual}"); + return Ok(()); + } + + log::error!("Checksum mismatch for {browser} {version}: expected {expected}, got {actual}"); + let _ = std::fs::remove_file(file_path); + Err( + serde_json::json!({ + "code": "BROWSER_CHECKSUM_MISMATCH", + "params": { "browser": browser, "version": version } + }) + .to_string() + .into(), + ) +} + +/// Prefix the calling context onto a bare message, but leave an already-coded +/// backend error alone: `wrap_backend_error` only passes a payload through +/// when it still starts with `{`, so prefixing one would strip the code and +/// the frontend would fall back to the untranslated INTERNAL_ERROR text. +fn contextualize( + context: &str, + e: impl std::fmt::Display, +) -> Box { + let msg = e.to_string(); + if msg.starts_with('{') { + msg.into() + } else { + format!("{context}: {msg}").into() + } +} + /// Check if a specific browser-version pair is currently being downloaded pub fn is_downloading(browser: &str, version: &str) -> bool { let download_key = format!("{browser}-{version}"); @@ -1040,6 +1216,183 @@ mod tests { assert_eq!(downloaded_content.len(), test_content.len()); } + // Stand-in archive body for the checksum tests. Digests are computed from + // it rather than hardcoded, so the fixture and the assertion cannot drift. + const ARCHIVE_BODY: &[u8] = b"wayfern archive bytes"; + + fn digest_of(bytes: &[u8]) -> String { + let temp_dir = TempDir::new().unwrap(); + let path = temp_dir.path().join("archive.zip"); + std::fs::write(&path, bytes).unwrap(); + crate::checksum::sha256_file(&path).unwrap() + } + + #[test] + fn test_checksum_sidecar_url_appends_to_the_asset_path() { + assert_eq!( + checksum_sidecar_url("https://download.wayfern.com/wayfern-151_windows_x64.zip"), + "https://download.wayfern.com/wayfern-151_windows_x64.zip.sha256" + ); + // A signed URL keeps its query, so the sidecar stays reachable. + assert_eq!( + checksum_sidecar_url("https://cdn.example.com/a.zip?token=abc&exp=1"), + "https://cdn.example.com/a.zip.sha256?token=abc&exp=1" + ); + assert_eq!( + checksum_sidecar_url("https://cdn.example.com/a.zip#frag"), + "https://cdn.example.com/a.zip.sha256#frag" + ); + } + + #[test] + fn test_asset_filename_from_url_takes_the_publisher_name() { + // Deliberately different from the local filename, which is built from the + // running platform and would never match a sidecar entry. + assert_eq!( + asset_filename_from_url("https://download.wayfern.com/wayfern-151.0.7922.71_windows_x64.zip"), + "wayfern-151.0.7922.71_windows_x64.zip" + ); + assert_eq!( + asset_filename_from_url("https://cdn.example.com/dir/a.tar.xz?token=abc"), + "a.tar.xz" + ); + assert_eq!(asset_filename_from_url("https://cdn.example.com/"), ""); + } + + #[tokio::test] + async fn test_fetch_expected_archive_checksum_reads_the_sidecar() { + let server = MockServer::start().await; + let downloader = Downloader::new_for_test(); + let digest = digest_of(ARCHIVE_BODY); + + Mock::given(method("GET")) + .and(path("/wayfern-151_windows_x64.zip.sha256")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(format!("{digest} wayfern-151_windows_x64.zip\n")), + ) + .mount(&server) + .await; + + let url = format!("{}/wayfern-151_windows_x64.zip", server.uri()); + let expected = downloader + .fetch_expected_archive_checksum(&url, "wayfern", "151") + .await + .expect("sidecar should resolve"); + + assert_eq!(expected, digest); + } + + #[tokio::test] + async fn test_fetch_expected_archive_checksum_fails_when_the_sidecar_is_missing() { + let server = MockServer::start().await; + let downloader = Downloader::new_for_test(); + + Mock::given(method("GET")) + .and(path("/wayfern-151_windows_x64.zip.sha256")) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + + let url = format!("{}/wayfern-151_windows_x64.zip", server.uri()); + let error = downloader + .fetch_expected_archive_checksum(&url, "wayfern", "151") + .await + .expect_err("an unverifiable asset must not be downloaded") + .to_string(); + + assert!( + error.contains("BROWSER_CHECKSUM_UNAVAILABLE") && error.contains("151"), + "expected a coded, translatable error, got: {error}" + ); + } + + #[tokio::test] + async fn test_fetch_expected_archive_checksum_rejects_a_sidecar_without_a_digest() { + let server = MockServer::start().await; + let downloader = Downloader::new_for_test(); + + // A CDN that answers 200 with an error page must not be read as a digest. + Mock::given(method("GET")) + .and(path("/wayfern-151_windows_x64.zip.sha256")) + .respond_with(ResponseTemplate::new(200).set_body_string("404")) + .mount(&server) + .await; + + let url = format!("{}/wayfern-151_windows_x64.zip", server.uri()); + let error = downloader + .fetch_expected_archive_checksum(&url, "wayfern", "151") + .await + .expect_err("an unparsable sidecar must not pass") + .to_string(); + + assert!( + error.contains("BROWSER_CHECKSUM_UNAVAILABLE"), + "expected a coded error, got: {error}" + ); + } + + #[tokio::test] + async fn test_verify_archive_checksum_accepts_a_matching_digest() { + let temp_dir = TempDir::new().unwrap(); + let archive = temp_dir.path().join("wayfern.zip"); + std::fs::write(&archive, ARCHIVE_BODY).unwrap(); + let digest = crate::checksum::sha256_file(&archive).unwrap(); + + // Case is normalized on both sides, so an uppercase sidecar still matches. + verify_archive_checksum( + &archive, + &digest.to_ascii_uppercase(), + "wayfern", + "151.0.7922.71", + ) + .await + .expect("a matching digest should verify"); + + assert!(archive.exists(), "a verified archive must be kept"); + } + + #[tokio::test] + async fn test_verify_archive_checksum_rejects_and_deletes_a_mismatch() { + let temp_dir = TempDir::new().unwrap(); + let archive = temp_dir.path().join("wayfern.zip"); + // The file on disk is the wrong asset entirely, which is what a mislinked + // manifest slot delivers, while the sidecar describes the right one. + std::fs::write(&archive, b"a macOS disk image, not a windows zip").unwrap(); + let expected = digest_of(ARCHIVE_BODY); + + let error = verify_archive_checksum(&archive, &expected, "wayfern", "151.0.7922.71") + .await + .expect_err("a mismatched digest must fail") + .to_string(); + + assert!( + error.contains("BROWSER_CHECKSUM_MISMATCH") && error.contains("151.0.7922.71"), + "expected a coded, translatable error, got: {error}" + ); + assert!( + !archive.exists(), + "an archive that failed verification must be deleted, not extracted" + ); + } + + #[test] + fn test_contextualize_preserves_a_coded_backend_error() { + // A coded payload must survive untouched: wrap_backend_error only passes + // it through while it still starts with '{'. + let coded = r#"{"code":"BROWSER_CHECKSUM_MISMATCH","params":{"browser":"wayfern"}}"#; + assert_eq!( + contextualize("Failed to download browser", coded).to_string(), + coded + ); + + // A bare message still gets its context. + assert_eq!( + contextualize("Failed to download browser", "connection reset").to_string(), + "Failed to download browser: connection reset" + ); + } + #[test] fn test_clear_download_state_for_browser_removes_stuck_keys() { // Simulate a download future that was abandoned without running its own cleanup, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d3074fd..945e284 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -62,6 +62,7 @@ mod browser; mod browser_runner; mod browser_version_manager; mod cdp_target; +mod checksum; mod default_browser; pub mod dns_blocklist; mod downloaded_browsers_registry; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 52bf16b..036e7d5 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1890,6 +1890,8 @@ "camoufoxImportDeprecated": "Importing this profile type is no longer supported. Please use Wayfern instead.", "updateChecksumsUnavailable": "The update {{version}} could not be verified because its checksum file could not be retrieved. The update was not installed; it will be retried later.", "updateChecksumMismatch": "The downloaded update file {{file}} failed checksum verification and was discarded. Please try again.", + "browserChecksumUnavailable": "{{browser}} {{version}} could not be verified because its checksum file could not be retrieved. The download was stopped; it will be retried later.", + "browserChecksumMismatch": "The downloaded {{browser}} {{version}} archive failed checksum verification and was discarded. Please try again.", "nameCannotBeEmpty": "Name cannot be empty", "wayfernVersionNotAvailable": "Wayfern version {{requested}} is not available for download. The current version is {{current}}.", "profileNameExists": "A profile named \"{{name}}\" already exists", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index e5d1470..9531eb7 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1896,6 +1896,8 @@ "camoufoxImportDeprecated": "La importación de este tipo de perfil ya no es compatible. Utiliza Wayfern en su lugar.", "updateChecksumsUnavailable": "No se pudo verificar la actualización {{version}} porque no se pudo obtener su archivo de sumas de comprobación. La actualización no se instaló; se reintentará más tarde.", "updateChecksumMismatch": "El archivo de actualización descargado {{file}} no superó la verificación de suma de comprobación y fue descartado. Inténtalo de nuevo.", + "browserChecksumUnavailable": "No se pudo verificar {{browser}} {{version}} porque no se pudo obtener su archivo de sumas de comprobación. La descarga se detuvo; se reintentará más tarde.", + "browserChecksumMismatch": "El archivo descargado de {{browser}} {{version}} no superó la verificación de suma de comprobación y fue descartado. Inténtalo de nuevo.", "nameCannotBeEmpty": "El nombre no puede estar vacío", "wayfernVersionNotAvailable": "La versión {{requested}} de Wayfern no está disponible para descargar. La versión actual es {{current}}.", "profileNameExists": "Ya existe un perfil llamado \"{{name}}\"", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 4f56520..2b91366 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1896,6 +1896,8 @@ "camoufoxImportDeprecated": "L'importation de ce type de profil n'est plus prise en charge. Veuillez utiliser Wayfern à la place.", "updateChecksumsUnavailable": "La mise à jour {{version}} n'a pas pu être vérifiée car son fichier de sommes de contrôle n'a pas pu être récupéré. La mise à jour n'a pas été installée ; une nouvelle tentative aura lieu plus tard.", "updateChecksumMismatch": "Le fichier de mise à jour téléchargé {{file}} a échoué à la vérification de la somme de contrôle et a été supprimé. Veuillez réessayer.", + "browserChecksumUnavailable": "{{browser}} {{version}} n'a pas pu être vérifié car son fichier de sommes de contrôle n'a pas pu être récupéré. Le téléchargement a été interrompu ; une nouvelle tentative aura lieu plus tard.", + "browserChecksumMismatch": "L'archive {{browser}} {{version}} téléchargée a échoué à la vérification de la somme de contrôle et a été supprimée. Veuillez réessayer.", "nameCannotBeEmpty": "Le nom ne peut pas être vide", "wayfernVersionNotAvailable": "La version {{requested}} de Wayfern n'est pas disponible au téléchargement. La version actuelle est {{current}}.", "profileNameExists": "Un profil nommé « {{name}} » existe déjà", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 4f18246..15b28fd 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1889,6 +1889,8 @@ "camoufoxImportDeprecated": "このタイプのプロファイルのインポートはサポートされなくなりました。代わりにWayfernを使用してください。", "updateChecksumsUnavailable": "アップデート {{version}} のチェックサムファイルを取得できなかったため、検証できませんでした。アップデートはインストールされませんでした。後で再試行されます。", "updateChecksumMismatch": "ダウンロードしたアップデートファイル {{file}} はチェックサム検証に失敗したため破棄されました。もう一度お試しください。", + "browserChecksumUnavailable": "{{browser}} {{version}} のチェックサムファイルを取得できなかったため、検証できませんでした。ダウンロードは中止されました。後で再試行されます。", + "browserChecksumMismatch": "ダウンロードした {{browser}} {{version}} のアーカイブはチェックサム検証に失敗したため破棄されました。もう一度お試しください。", "nameCannotBeEmpty": "名前を空にすることはできません", "wayfernVersionNotAvailable": "Wayfernのバージョン{{requested}}はダウンロードできません。現在のバージョンは{{current}}です。", "profileNameExists": "「{{name}}」という名前のプロファイルは既に存在します", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 5d3635b..a817cfd 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -1889,6 +1889,8 @@ "camoufoxImportDeprecated": "이 유형의 프로필 가져오기는 더 이상 지원되지 않습니다. 대신 Wayfern을 사용하세요.", "updateChecksumsUnavailable": "업데이트 {{version}}의 체크섬 파일을 가져올 수 없어 검증하지 못했습니다. 업데이트가 설치되지 않았으며 나중에 다시 시도됩니다.", "updateChecksumMismatch": "다운로드한 업데이트 파일 {{file}}이(가) 체크섬 검증에 실패하여 삭제되었습니다. 다시 시도해 주세요.", + "browserChecksumUnavailable": "{{browser}} {{version}}의 체크섬 파일을 가져올 수 없어 검증하지 못했습니다. 다운로드가 중단되었으며 나중에 다시 시도됩니다.", + "browserChecksumMismatch": "다운로드한 {{browser}} {{version}} 아카이브가 체크섬 검증에 실패하여 삭제되었습니다. 다시 시도해 주세요.", "nameCannotBeEmpty": "이름은 비워둘 수 없습니다", "wayfernVersionNotAvailable": "Wayfern 버전 {{requested}}은(는) 다운로드할 수 없습니다. 현재 버전은 {{current}}입니다.", "profileNameExists": "\"{{name}}\" 이름의 프로필이 이미 있습니다", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index ddb7393..f5b3443 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1896,6 +1896,8 @@ "camoufoxImportDeprecated": "A importação deste tipo de perfil não é mais suportada. Use o Wayfern em vez disso.", "updateChecksumsUnavailable": "Não foi possível verificar a atualização {{version}} porque o arquivo de somas de verificação não pôde ser obtido. A atualização não foi instalada; será tentada novamente mais tarde.", "updateChecksumMismatch": "O arquivo de atualização baixado {{file}} falhou na verificação de soma de verificação e foi descartado. Tente novamente.", + "browserChecksumUnavailable": "Não foi possível verificar o {{browser}} {{version}} porque o arquivo de somas de verificação não pôde ser obtido. O download foi interrompido; será tentado novamente mais tarde.", + "browserChecksumMismatch": "O arquivo baixado do {{browser}} {{version}} falhou na verificação de soma de verificação e foi descartado. Tente novamente.", "nameCannotBeEmpty": "O nome não pode estar vazio", "wayfernVersionNotAvailable": "A versão {{requested}} do Wayfern não está disponível para download. A versão atual é {{current}}.", "profileNameExists": "Já existe um perfil chamado \"{{name}}\"", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 3a657ff..58a734a 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1903,6 +1903,8 @@ "camoufoxImportDeprecated": "Импорт профилей этого типа больше не поддерживается. Используйте Wayfern.", "updateChecksumsUnavailable": "Не удалось проверить обновление {{version}}: файл контрольных сумм не удалось получить. Обновление не было установлено; попытка будет повторена позже.", "updateChecksumMismatch": "Загруженный файл обновления {{file}} не прошёл проверку контрольной суммы и был удалён. Попробуйте ещё раз.", + "browserChecksumUnavailable": "Не удалось проверить {{browser}} {{version}}: файл контрольных сумм не удалось получить. Загрузка остановлена; попытка будет повторена позже.", + "browserChecksumMismatch": "Загруженный архив {{browser}} {{version}} не прошёл проверку контрольной суммы и был удалён. Попробуйте ещё раз.", "nameCannotBeEmpty": "Имя не может быть пустым", "wayfernVersionNotAvailable": "Версия Wayfern {{requested}} недоступна для загрузки. Текущая версия — {{current}}.", "profileNameExists": "Профиль с именем «{{name}}» уже существует", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index 2b3bf31..7e8f4d2 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -1889,6 +1889,8 @@ "camoufoxImportDeprecated": "Bu profil türünün içe aktarılması artık desteklenmiyor. Lütfen bunun yerine Wayfern kullanın.", "updateChecksumsUnavailable": "{{version}} güncellemesi doğrulanamadı çünkü sağlama toplamı dosyası alınamadı. Güncelleme yüklenmedi; daha sonra yeniden denenecek.", "updateChecksumMismatch": "İndirilen güncelleme dosyası {{file}} sağlama toplamı doğrulamasını geçemedi ve silindi. Lütfen yeniden deneyin.", + "browserChecksumUnavailable": "{{browser}} {{version}} doğrulanamadı çünkü sağlama toplamı dosyası alınamadı. İndirme durduruldu; daha sonra yeniden denenecek.", + "browserChecksumMismatch": "İndirilen {{browser}} {{version}} arşivi sağlama toplamı doğrulamasını geçemedi ve silindi. Lütfen yeniden deneyin.", "nameCannotBeEmpty": "Ad boş olamaz", "wayfernVersionNotAvailable": "Wayfern {{requested}} sürümü indirilemiyor. Güncel sürüm: {{current}}.", "profileNameExists": "\"{{name}}\" adlı bir profil zaten var", diff --git a/src/i18n/locales/vi.json b/src/i18n/locales/vi.json index 8345639..84b18ac 100644 --- a/src/i18n/locales/vi.json +++ b/src/i18n/locales/vi.json @@ -1889,6 +1889,8 @@ "camoufoxImportDeprecated": "Việc nhập loại hồ sơ này không còn được hỗ trợ. Vui lòng sử dụng Wayfern thay thế.", "updateChecksumsUnavailable": "Không thể xác minh bản cập nhật {{version}} vì không thể tải tệp checksum. Bản cập nhật chưa được cài đặt; sẽ thử lại sau.", "updateChecksumMismatch": "Tệp cập nhật đã tải xuống {{file}} không vượt qua kiểm tra checksum và đã bị loại bỏ. Vui lòng thử lại.", + "browserChecksumUnavailable": "Không thể xác minh {{browser}} {{version}} vì không thể tải tệp checksum. Quá trình tải xuống đã dừng; sẽ thử lại sau.", + "browserChecksumMismatch": "Kho lưu trữ {{browser}} {{version}} đã tải xuống không vượt qua kiểm tra checksum và đã bị loại bỏ. Vui lòng thử lại.", "nameCannotBeEmpty": "Tên không được để trống", "wayfernVersionNotAvailable": "Phiên bản Wayfern {{requested}} không có sẵn để tải xuống. Phiên bản hiện tại là {{current}}.", "profileNameExists": "Hồ sơ có tên \"{{name}}\" đã tồn tại", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 75372b2..3aa68a0 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1889,6 +1889,8 @@ "camoufoxImportDeprecated": "不再支持导入此类型的配置文件。请改用 Wayfern。", "updateChecksumsUnavailable": "无法验证更新 {{version}}:无法获取其校验和文件。更新未安装,稍后将重试。", "updateChecksumMismatch": "下载的更新文件 {{file}} 未通过校验和验证,已被丢弃。请重试。", + "browserChecksumUnavailable": "无法验证 {{browser}} {{version}}:无法获取其校验和文件。下载已停止,稍后将重试。", + "browserChecksumMismatch": "下载的 {{browser}} {{version}} 压缩包未通过校验和验证,已被丢弃。请重试。", "nameCannotBeEmpty": "名称不能为空", "wayfernVersionNotAvailable": "Wayfern 版本 {{requested}} 无法下载。当前版本为 {{current}}。", "profileNameExists": "名为“{{name}}”的配置文件已存在", diff --git a/src/lib/backend-errors.ts b/src/lib/backend-errors.ts index 2327179..9c4a0f4 100644 --- a/src/lib/backend-errors.ts +++ b/src/lib/backend-errors.ts @@ -48,6 +48,8 @@ export type BackendErrorCode = | "PROXY_SIDECAR_VERSION_MISMATCH" | "UPDATE_CHECKSUMS_UNAVAILABLE" | "UPDATE_CHECKSUM_MISMATCH" + | "BROWSER_CHECKSUM_UNAVAILABLE" + | "BROWSER_CHECKSUM_MISMATCH" | "UPDATE_PROFILES_RUNNING" | "UPDATE_PREPARATION_FAILED" | "PROFILE_NAME_EXISTS" @@ -287,6 +289,16 @@ export function translateBackendError(t: TFunction, err: unknown): string { return t("backendErrors.updateChecksumMismatch", { file: parsed.params?.file ?? "", }); + case "BROWSER_CHECKSUM_UNAVAILABLE": + return t("backendErrors.browserChecksumUnavailable", { + browser: parsed.params?.browser ?? "", + version: parsed.params?.version ?? "", + }); + case "BROWSER_CHECKSUM_MISMATCH": + return t("backendErrors.browserChecksumMismatch", { + browser: parsed.params?.browser ?? "", + version: parsed.params?.version ?? "", + }); case "UPDATE_PROFILES_RUNNING": return t("backendErrors.updateProfilesRunning"); case "UPDATE_PREPARATION_FAILED":