diff options
| author | David Lönnhager <david.l@mullvad.net> | 2025-02-14 20:38:24 +0100 |
|---|---|---|
| committer | David Lönnhager <david.l@mullvad.net> | 2025-03-05 23:32:05 +0100 |
| commit | 2c8c3cae9ec86c592ccc4403ce2da899a328e65f (patch) | |
| tree | e565aecde2b65a6e6c499768e2976540cdfcc227 | |
| parent | 41672123ef0b5110cae22edc2c141dfbc05c3ad4 (diff) | |
| download | mullvadvpn-2c8c3cae9ec86c592ccc4403ce2da899a328e65f.tar.xz mullvadvpn-2c8c3cae9ec86c592ccc4403ce2da899a328e65f.zip | |
Add API client for mullvad-update
| -rw-r--r-- | installer-downloader/src/controller.rs | 38 | ||||
| -rw-r--r-- | installer-downloader/tests/controller.rs | 45 | ||||
| -rw-r--r-- | mullvad-update/src/api.rs | 260 | ||||
| -rw-r--r-- | mullvad-update/src/format/deserializer.rs | 8 | ||||
| -rw-r--r-- | mullvad-update/src/format/key.rs | 9 | ||||
| -rw-r--r-- | mullvad-update/src/lib.rs | 1 | ||||
| -rw-r--r-- | mullvad-update/src/snapshots/mullvad_update__api__test__http_version_provider.snap (renamed from mullvad-update/src/snapshots/mullvad_update__api__test__api_version_info_provider_parser_x86.snap) | 0 | ||||
| -rw-r--r-- | mullvad-update/src/snapshots/mullvad_update__version__test__version_info_parser_arm64.snap (renamed from mullvad-update/src/snapshots/mullvad_update__api__test__api_version_info_provider_parser_arm64.snap) | 2 | ||||
| -rw-r--r-- | mullvad-update/src/snapshots/mullvad_update__version__test__version_info_parser_x86.snap | 83 | ||||
| -rw-r--r-- | mullvad-update/src/version.rs | 205 |
10 files changed, 431 insertions, 220 deletions
diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index 6dbf9255f3..100fcf1796 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -5,8 +5,9 @@ use crate::resource; use crate::ui_downloader::{UiAppDownloader, UiAppDownloaderParameters, UiProgressUpdater}; use mullvad_update::{ - api::{self, Version, VersionInfoProvider, VersionParameters}, + api::VersionInfoProvider, app::{self, AppDownloader}, + version::{Version, VersionArchitecture, VersionInfo, VersionParameters}, }; use std::future::Future; @@ -15,7 +16,7 @@ use tokio::sync::{mpsc, oneshot}; /// Actions handled by an async worker task in [handle_action_messages]. enum TaskMessage { - SetVersionInfo(api::VersionInfo), + SetVersionInfo(VersionInfo), BeginDownload, Cancel, } @@ -40,16 +41,24 @@ pub struct AppController {} /// Public entry function for registering a [AppDelegate]. pub fn initialize_controller<T: AppDelegate + 'static>(delegate: &mut T) { - use mullvad_update::{api::ApiVersionInfoProvider, app::HttpAppDownloader}; + use mullvad_update::{api::HttpVersionInfoProvider, app::HttpAppDownloader}; // App downloader to use type Downloader<T> = HttpAppDownloader<UiProgressUpdater<T>>; - // Version info provider to use - type VersionInfoProvider = ApiVersionInfoProvider; // Directory provider to use type DirProvider = TempDirProvider; - AppController::initialize::<_, Downloader<T>, VersionInfoProvider, DirProvider>(delegate) + // Version info provider to use + const TEST_PUBKEY: &str = "4d35f5376f1f58c41b2a0ee4600ae7811eace354f100227e853994deef38942d"; + let verifying_key = + mullvad_update::format::key::VerifyingKey::from_hex(TEST_PUBKEY).expect("valid key"); + let version_provider = HttpVersionInfoProvider { + url: "https://releases.mullvad.net/thing".to_owned(), + pinned_certificate: None, + verifying_key, + }; + + AppController::initialize::<_, Downloader<T>, _, DirProvider>(delegate, version_provider) } impl AppController { @@ -57,10 +66,10 @@ impl AppController { /// /// Providing the downloader and version info fetcher as type arguments, they're decoupled from /// the logic of [AppController], allowing them to be mocked. - pub fn initialize<D, A, V, DirProvider>(delegate: &mut D) + pub fn initialize<D, A, V, DirProvider>(delegate: &mut D, version_provider: V) where D: AppDelegate + 'static, - V: VersionInfoProvider + 'static, + V: VersionInfoProvider + Send + 'static, A: From<UiAppDownloaderParameters<D>> + AppDownloader + 'static, DirProvider: DirectoryProvider, { @@ -76,7 +85,11 @@ impl AppController { task_rx, )); delegate.set_status_text(resource::FETCH_VERSION_DESC); - tokio::spawn(fetch_app_version_info::<D, V>(delegate, task_tx.clone())); + tokio::spawn(fetch_app_version_info::<D, V>( + delegate, + task_tx.clone(), + version_provider, + )); Self::register_user_action_callbacks(delegate, task_tx); } @@ -99,23 +112,24 @@ impl AppController { fn fetch_app_version_info<Delegate, VersionProvider>( delegate: &mut Delegate, download_tx: mpsc::Sender<TaskMessage>, + version_provider: VersionProvider, ) -> impl Future<Output = ()> where Delegate: AppDelegate, - VersionProvider: VersionInfoProvider, + VersionProvider: VersionInfoProvider + Send, { let queue = delegate.queue(); async move { let version_params = VersionParameters { // TODO: detect current architecture - architecture: api::VersionArchitecture::X86, + architecture: VersionArchitecture::X86, // For the downloader, the rollout version is always preferred rollout: 1., }; // TODO: handle errors, retry - let Ok(version_info) = VersionProvider::get_version_info(version_params).await else { + let Ok(version_info) = version_provider.get_version_info(version_params).await else { queue.queue_main(move |self_| { self_.set_status_text("Failed to fetch version info"); }); diff --git a/installer-downloader/tests/controller.rs b/installer-downloader/tests/controller.rs index 2076b44a65..03a4842ec7 100644 --- a/installer-downloader/tests/controller.rs +++ b/installer-downloader/tests/controller.rs @@ -8,9 +8,10 @@ use insta::assert_yaml_snapshot; use installer_downloader::controller::{AppController, DirectoryProvider}; use installer_downloader::delegate::{AppDelegate, AppDelegateQueue}; use installer_downloader::ui_downloader::UiAppDownloaderParameters; -use mullvad_update::api::{Version, VersionInfo, VersionInfoProvider, VersionParameters}; +use mullvad_update::api::VersionInfoProvider; use mullvad_update::app::{AppDownloader, DownloadError}; use mullvad_update::fetch::ProgressUpdater; +use mullvad_update::version::{Version, VersionInfo, VersionParameters}; use std::path::{Path, PathBuf}; use std::sync::{Arc, LazyLock, Mutex}; use std::time::Duration; @@ -31,7 +32,7 @@ static FAKE_VERSION: LazyLock<VersionInfo> = LazyLock::new(|| VersionInfo { #[async_trait::async_trait] impl VersionInfoProvider for FakeVersionInfoProvider { - async fn get_version_info(_params: VersionParameters) -> anyhow::Result<VersionInfo> { + async fn get_version_info(&self, _params: VersionParameters) -> anyhow::Result<VersionInfo> { Ok(FAKE_VERSION.clone()) } } @@ -283,12 +284,10 @@ impl AppDelegate for FakeAppDelegate { #[tokio::test(start_paused = true)] async fn test_fetch_version() { let mut delegate = FakeAppDelegate::default(); - AppController::initialize::< - _, - FakeAppDownloaderHappyPath, - FakeVersionInfoProvider, - FakeDirectoryProvider<true>, - >(&mut delegate); + AppController::initialize::<_, FakeAppDownloaderHappyPath, _, FakeDirectoryProvider<true>>( + &mut delegate, + FakeVersionInfoProvider {}, + ); // The app should start out by fetching the current app version assert_yaml_snapshot!(delegate.state); @@ -308,12 +307,10 @@ async fn test_fetch_version() { #[tokio::test(start_paused = true)] async fn test_download() { let mut delegate = FakeAppDelegate::default(); - AppController::initialize::< - _, - FakeAppDownloaderHappyPath, - FakeVersionInfoProvider, - FakeDirectoryProvider<true>, - >(&mut delegate); + AppController::initialize::<_, FakeAppDownloaderHappyPath, _, FakeDirectoryProvider<true>>( + &mut delegate, + FakeVersionInfoProvider {}, + ); // Wait for the version info tokio::time::sleep(Duration::from_secs(1)).await; @@ -355,12 +352,10 @@ async fn test_download() { #[tokio::test(start_paused = true)] async fn test_failed_verification() { let mut delegate = FakeAppDelegate::default(); - AppController::initialize::< - _, - FakeAppDownloaderVerifyFail, - FakeVersionInfoProvider, - FakeDirectoryProvider<true>, - >(&mut delegate); + AppController::initialize::<_, FakeAppDownloaderVerifyFail, _, FakeDirectoryProvider<true>>( + &mut delegate, + FakeVersionInfoProvider {}, + ); // Wait for the version info tokio::time::sleep(Duration::from_secs(1)).await; @@ -394,12 +389,10 @@ async fn test_failed_verification() { #[tokio::test(start_paused = true)] async fn test_failed_directory_creation() { let mut delegate = FakeAppDelegate::default(); - AppController::initialize::< - _, - FakeAppDownloaderHappyPath, - FakeVersionInfoProvider, - FakeDirectoryProvider<false>, - >(&mut delegate); + AppController::initialize::<_, FakeAppDownloaderHappyPath, _, FakeDirectoryProvider<false>>( + &mut delegate, + FakeVersionInfoProvider {}, + ); // Wait for the version info tokio::time::sleep(Duration::from_secs(1)).await; diff --git a/mullvad-update/src/api.rs b/mullvad-update/src/api.rs index debadb0682..864c0745a2 100644 --- a/mullvad-update/src/api.rs +++ b/mullvad-update/src/api.rs @@ -1,182 +1,84 @@ -//! Fetch information about app versions from the Mullvad API +//! This module implements fetching of information about app versions use anyhow::Context; use crate::format; - -/// Parameters for [VersionInfoProvider] -#[derive(Debug)] -pub struct VersionParameters { - /// Architecture to retrieve data for - pub architecture: VersionArchitecture, - /// Rollout threshold. Any version in the response below this threshold will be ignored - pub rollout: f32, -} - -/// Installer architecture -pub type VersionArchitecture = format::Architecture; +use crate::version::{VersionInfo, VersionParameters}; /// See [module-level](self) docs. #[async_trait::async_trait] pub trait VersionInfoProvider { /// Return info about the stable version - async fn get_version_info(params: VersionParameters) -> anyhow::Result<VersionInfo>; -} - -/// Contains information about all versions -#[derive(Debug, Clone)] -#[cfg_attr(test, derive(serde::Serialize))] -pub struct VersionInfo { - /// Stable version info - pub stable: Version, - /// Beta version info (if available and newer than `stable`). - /// If latest stable version is newer, this will be `None`. - pub beta: Option<Version>, + async fn get_version_info(&self, params: VersionParameters) -> anyhow::Result<VersionInfo>; } -/// Contains information about a version for the current target -#[derive(Debug, Clone)] -#[cfg_attr(test, derive(serde::Serialize))] -pub struct Version { - /// Version - pub version: mullvad_version::Version, - /// URLs to use for downloading the app installer - pub urls: Vec<String>, - /// Size of installer, in bytes - pub size: usize, - /// Version changelog - pub changelog: String, - /// App installer checksum - pub sha256: [u8; 32], +/// Obtain version data using a GET request +pub struct HttpVersionInfoProvider { + /// Endpoint for GET request + pub url: String, + /// Accepted root certificate. Defaults are used unless specified + pub pinned_certificate: Option<reqwest::Certificate>, + /// Key to use for verifying the response + pub verifying_key: format::key::VerifyingKey, } -/// Obtain version data from the Mullvad API -pub struct ApiVersionInfoProvider; - #[async_trait::async_trait] -impl VersionInfoProvider for ApiVersionInfoProvider { - async fn get_version_info(params: VersionParameters) -> anyhow::Result<VersionInfo> { - // FIXME: Replace with actual API response - use format::*; - - const TEST_PUBKEY: &str = - "4d35f5376f1f58c41b2a0ee4600ae7811eace354f100227e853994deef38942d"; - let pubkey = hex::decode(TEST_PUBKEY).unwrap(); - let verifying_key = - ed25519_dalek::VerifyingKey::from_bytes(&pubkey.try_into().unwrap()).unwrap(); - - let response = SignedResponse::deserialize_and_verify( - format::key::VerifyingKey(verifying_key), - include_bytes!("../test-version-response.json"), - )?; +impl VersionInfoProvider for HttpVersionInfoProvider { + async fn get_version_info(&self, params: VersionParameters) -> anyhow::Result<VersionInfo> { + let raw_json = Self::get(&self.url, self.pinned_certificate.clone()).await?; + let response = + format::SignedResponse::deserialize_and_verify(&self.verifying_key, &raw_json)?; VersionInfo::try_from_response(¶ms, response.signed) } } -/// Helper used to lift the relevant installer out of the array in [format::Release] -#[derive(Clone)] -struct IntermediateVersion { - version: mullvad_version::Version, - changelog: String, - installer: format::Installer, -} - -impl VersionInfo { - /// Convert signed response data to public version type - /// NOTE: `response` is assumed to be verified and untampered. It is not verified. - fn try_from_response( - params: &VersionParameters, - response: format::Response, - ) -> anyhow::Result<Self> { - let mut releases = response.releases; +impl HttpVersionInfoProvider { + /// Maximum size of the GET response, in bytes + const SIZE_LIMIT: usize = 1024 * 1024; - // Sort releases by version - releases.sort_by(|a, b| mullvad_version::Version::version_ordering(&a.version, &b.version)); + /// Perform a simple GET request, with a size limit, and return it as bytes + async fn get( + url: &str, + pinned_certificate: Option<reqwest::Certificate>, + ) -> anyhow::Result<Vec<u8>> { + let mut req_builder = reqwest::Client::builder(); - // Fail if there are duplicate versions. - // Check this before anything else so that it's rejected indepentently of `params`. - // Important! This must occur after sorting - if let Some(dup_version) = Self::find_duplicate_version(&releases) { - anyhow::bail!("API response contains at least one duplicated version: {dup_version}"); + if let Some(pinned_certificate) = pinned_certificate { + req_builder = req_builder + .tls_built_in_root_certs(false) + .add_root_certificate(pinned_certificate); } - // Filter releases based on rollout and architecture - let releases: Vec<_> = releases - .into_iter() - // Filter out releases that are not rolled out to us - .filter(|release| release.rollout >= params.rollout) - // Include only installers for the requested architecture - .flat_map(|release| { - release - .installers - .into_iter() - .filter(|installer| params.architecture == installer.architecture) - // Map each artifact to a [IntermediateVersion] - .map(move |installer| { - IntermediateVersion { - version: release.version.clone(), - changelog: release.changelog.clone(), - installer, - } - }) - }) - .collect(); + // Initiate GET request + let mut req = req_builder + .build()? + .get(url) + .send() + .await + .context("Failed to fetch version")?; - // Find latest stable version - let stable = releases - .iter() - .rfind(|release| release.version.is_stable() && !release.version.is_dev()); - let Some(stable) = stable.cloned() else { - anyhow::bail!("No stable version found"); - }; + // Fail if content length exceeds limit + let content_len_limit = Self::SIZE_LIMIT.try_into().expect("Invalid size limit"); + if req.content_length() > Some(content_len_limit) { + anyhow::bail!("Version info exceeded limit: {} bytes", Self::SIZE_LIMIT); + } - // Find the latest beta version - let beta = releases - .iter() - // Find most recent beta version - .rfind(|release| release.version.beta().is_some() && !release.version.is_dev()) - // If the latest beta version is older than latest stable, dispose of it - .filter(|release| release.version.version_ordering(&stable.version).is_gt()) - .cloned(); + let mut read_n = 0; + let mut data = vec![]; - Ok(Self { - stable: Version::try_from(stable)?, - beta: beta.map(|beta| Version::try_from(beta)).transpose()?, - }) - } + while let Some(chunk) = req.chunk().await.context("Failed to retrieve chunk")? { + read_n += chunk.len(); - /// Returns the first duplicated version found in `releases`. - /// `None` is returned if there are no duplicates. - /// NOTE: `releases` MUST be sorted - fn find_duplicate_version(releases: &[format::Release]) -> Option<&mullvad_version::Version> { - releases - .windows(2) - .find(|pair| { - mullvad_version::Version::version_ordering(&pair[0].version, &pair[1].version) - .is_eq() - }) - .map(|pair| &pair[0].version) - } -} + // Fail if content length exceeds limit + if read_n > Self::SIZE_LIMIT { + anyhow::bail!("Version info exceeded limit: {} bytes", Self::SIZE_LIMIT); + } -impl TryFrom<IntermediateVersion> for Version { - type Error = anyhow::Error; - - fn try_from(version: IntermediateVersion) -> Result<Self, Self::Error> { - // Convert hex checksum to bytes - let sha256 = hex::decode(version.installer.sha256) - .context("Invalid checksum hex")? - .try_into() - .map_err(|_| anyhow::anyhow!("Invalid checksum length"))?; + data.extend_from_slice(&chunk); + } - Ok(Version { - version: version.version, - size: version.installer.size, - urls: version.installer.urls, - changelog: version.changelog, - sha256, - }) + Ok(data) } } @@ -184,47 +86,51 @@ impl TryFrom<IntermediateVersion> for Version { mod test { use insta::assert_yaml_snapshot; + use crate::version::VersionArchitecture; + use super::*; // These tests rely on `insta` for snapshot testing. If they fail due to snapshot assertions, // then most likely the snapshots need to be updated. The most convenient way to review // changes to, and update, snapshots are by running `cargo insta review`. - /// Test parsing of API responses (rollout 1, x86) - #[test] - fn test_api_version_info_provider_parser_x86() -> anyhow::Result<()> { - let response = format::SignedResponse::deserialize_and_verify_insecure(include_bytes!( - "../test-version-response.json" - ))?; + /// Test HTTP version info provider + /// + /// We're not testing the correctness of [version] here, only the HTTP client + #[tokio::test] + async fn test_http_version_provider() -> anyhow::Result<()> { + let verifying_key = crate::format::key::VerifyingKey::from_hex( + "4d35f5376f1f58c41b2a0ee4600ae7811eace354f100227e853994deef38942d", + ) + .expect("valid key"); + + // Start HTTP server + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/version") + // Respond with some version response payload + .with_body(include_bytes!("../test-version-response.json")) + .create(); + let url = format!("{}/version", server.url()); + + // Construct query and provider let params = VersionParameters { architecture: VersionArchitecture::X86, rollout: 1., }; - - // Expect: The available latest versions for X86, where the rollout is 1. - let info = VersionInfo::try_from_response(¶ms, response.signed.clone())?; - - assert_yaml_snapshot!(info); - - Ok(()) - } - - /// Test parsing of API responses (rollout 0.01, arm64) - #[test] - fn test_api_version_info_provider_parser_arm64() -> anyhow::Result<()> { - let response = format::SignedResponse::deserialize_and_verify_insecure(include_bytes!( - "../test-version-response.json" - ))?; - - let params = VersionParameters { - architecture: VersionArchitecture::Arm64, - rollout: 0.01, + let info_provider = HttpVersionInfoProvider { + url, + pinned_certificate: None, + verifying_key, }; - let info = VersionInfo::try_from_response(¶ms, response.signed)?; + let info = info_provider + .get_version_info(params) + .await + .context("Expected valid version info")?; - // Expect: The available latest versions for arm64, where the rollout is .01. + // Expect: Our query should yield some version response assert_yaml_snapshot!(info); Ok(()) diff --git a/mullvad-update/src/format/deserializer.rs b/mullvad-update/src/format/deserializer.rs index 6c86f7c01b..422d90d5b3 100644 --- a/mullvad-update/src/format/deserializer.rs +++ b/mullvad-update/src/format/deserializer.rs @@ -9,7 +9,7 @@ use super::{PartialSignedResponse, SignedResponse}; impl SignedResponse { /// Deserialize some bytes to JSON, and verify them, including signature and expiry. /// If successful, the deserialized data is returned. - pub fn deserialize_and_verify(key: VerifyingKey, bytes: &[u8]) -> Result<Self, anyhow::Error> { + pub fn deserialize_and_verify(key: &VerifyingKey, bytes: &[u8]) -> Result<Self, anyhow::Error> { Self::deserialize_and_verify_at_time(key, bytes, chrono::Utc::now()) } @@ -30,7 +30,7 @@ impl SignedResponse { /// Deserialize some bytes to JSON, and verify them, including signature and expiry. /// If successful, the deserialized data is returned. fn deserialize_and_verify_at_time( - key: VerifyingKey, + key: &VerifyingKey, bytes: &[u8], current_time: chrono::DateTime<chrono::Utc>, ) -> Result<Self, anyhow::Error> { @@ -108,7 +108,7 @@ mod test { ed25519_dalek::VerifyingKey::from_bytes(&pubkey.try_into().unwrap()).unwrap(); SignedResponse::deserialize_and_verify_at_time( - VerifyingKey(verifying_key), + &VerifyingKey(verifying_key), include_bytes!("../../test-version-response.json"), // It's 1970 again chrono::DateTime::UNIX_EPOCH, @@ -117,7 +117,7 @@ mod test { // Reject expired data SignedResponse::deserialize_and_verify_at_time( - VerifyingKey(verifying_key), + &VerifyingKey(verifying_key), include_bytes!("../../test-version-response.json"), // In the year 3000 chrono::DateTime::from_str("3000-01-01T00:00:00Z").unwrap(), diff --git a/mullvad-update/src/format/key.rs b/mullvad-update/src/format/key.rs index b0eed5b4cb..0033ec30a1 100644 --- a/mullvad-update/src/format/key.rs +++ b/mullvad-update/src/format/key.rs @@ -74,6 +74,15 @@ impl Serialize for SecretKey { #[derive(Debug, PartialEq, Eq)] pub struct VerifyingKey(pub ed25519_dalek::VerifyingKey); +impl VerifyingKey { + pub fn from_hex(s: &str) -> anyhow::Result<Self> { + let bytes = bytes_from_hex::<{ ed25519_dalek::PUBLIC_KEY_LENGTH }>(s)?; + Ok(Self( + ed25519_dalek::VerifyingKey::from_bytes(&bytes).context("Invalid ed25519 key")?, + )) + } +} + impl<'de> Deserialize<'de> for VerifyingKey { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where diff --git a/mullvad-update/src/lib.rs b/mullvad-update/src/lib.rs index 89fbdee2ed..f6f0b74e10 100644 --- a/mullvad-update/src/lib.rs +++ b/mullvad-update/src/lib.rs @@ -5,6 +5,7 @@ pub mod app; pub mod dir; pub mod fetch; pub mod verify; +pub mod version; /// Parser and serializer for version metadata pub mod format; diff --git a/mullvad-update/src/snapshots/mullvad_update__api__test__api_version_info_provider_parser_x86.snap b/mullvad-update/src/snapshots/mullvad_update__api__test__http_version_provider.snap index 1cb23ff5e5..1cb23ff5e5 100644 --- a/mullvad-update/src/snapshots/mullvad_update__api__test__api_version_info_provider_parser_x86.snap +++ b/mullvad-update/src/snapshots/mullvad_update__api__test__http_version_provider.snap diff --git a/mullvad-update/src/snapshots/mullvad_update__api__test__api_version_info_provider_parser_arm64.snap b/mullvad-update/src/snapshots/mullvad_update__version__test__version_info_parser_arm64.snap index 91e6e90389..8b2f63d5c6 100644 --- a/mullvad-update/src/snapshots/mullvad_update__api__test__api_version_info_provider_parser_arm64.snap +++ b/mullvad-update/src/snapshots/mullvad_update__version__test__version_info_parser_arm64.snap @@ -1,5 +1,5 @@ --- -source: mullvad-update/src/api.rs +source: mullvad-update/src/version.rs expression: info snapshot_kind: text --- diff --git a/mullvad-update/src/snapshots/mullvad_update__version__test__version_info_parser_x86.snap b/mullvad-update/src/snapshots/mullvad_update__version__test__version_info_parser_x86.snap new file mode 100644 index 0000000000..2a59903dbf --- /dev/null +++ b/mullvad-update/src/snapshots/mullvad_update__version__test__version_info_parser_x86.snap @@ -0,0 +1,83 @@ +--- +source: mullvad-update/src/version.rs +expression: info +snapshot_kind: text +--- +stable: + version: "2025.2" + urls: + - "https://releases.mullvad.net/desktop/releases/2025.2/MullvadVPN-2025.2.exe" + size: 101384672 + changelog: "[macos] Adding support for quicfuscator\n[windows] Less bugs" + sha256: + - 244 + - 178 + - 87 + - 19 + - 209 + - 63 + - 40 + - 25 + - 163 + - 0 + - 242 + - 255 + - 169 + - 77 + - 150 + - 116 + - 99 + - 170 + - 238 + - 160 + - 211 + - 87 + - 251 + - 215 + - 71 + - 154 + - 40 + - 17 + - 84 + - 186 + - 4 + - 96 +beta: + version: 2025.3-beta1 + urls: + - "https://releases.mullvad.net/desktop/releases/2025.3-beta1/MullvadVPN-2025.3-beta1_x64.exe" + size: 106297504 + changelog: "[macos] Adding support for quicfuscator\n[windows] Less bugs" + sha256: + - 12 + - 86 + - 154 + - 160 + - 145 + - 46 + - 185 + - 54 + - 5 + - 168 + - 80 + - 115 + - 68 + - 125 + - 66 + - 186 + - 12 + - 166 + - 18 + - 54 + - 27 + - 239 + - 120 + - 239 + - 4 + - 239 + - 3 + - 142 + - 128 + - 177 + - 84 + - 3 diff --git a/mullvad-update/src/version.rs b/mullvad-update/src/version.rs new file mode 100644 index 0000000000..c62e0c8113 --- /dev/null +++ b/mullvad-update/src/version.rs @@ -0,0 +1,205 @@ +//! This module is used to extract the latest versions out of a raw [format::Response] using a query +//! [VersionParameters]. It also contains additional logic for filtering and validating the raw +//! deserialized response. +//! +//! The main input here is [VersionParameters], and the main output is [VersionInfo]. + +use std::cmp::Ordering; + +use anyhow::Context; +use mullvad_version::PreStableType; + +use crate::format; + +/// Query type for [VersionInfo] +#[derive(Debug)] +pub struct VersionParameters { + /// Architecture to retrieve data for + pub architecture: VersionArchitecture, + /// Rollout threshold. Any version in the response below this threshold will be ignored + pub rollout: f32, +} + +/// Installer architecture +pub type VersionArchitecture = format::Architecture; + +/// Version information derived from querying a [format::Response] using [VersionParameters] +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(serde::Serialize))] +pub struct VersionInfo { + /// Stable version info + pub stable: Version, + /// Beta version info (if available and newer than `stable`). + /// If latest stable version is newer, this will be `None`. + pub beta: Option<Version>, +} + +/// Contains information about a version for the current target +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(serde::Serialize))] +pub struct Version { + /// Version + pub version: mullvad_version::Version, + /// URLs to use for downloading the app installer + pub urls: Vec<String>, + /// Size of installer, in bytes + pub size: usize, + /// Version changelog + pub changelog: String, + /// App installer checksum + pub sha256: [u8; 32], +} + +/// Helper used to lift the relevant installer out of the array in [format::Release] +#[derive(Clone)] +struct IntermediateVersion { + version: mullvad_version::Version, + changelog: String, + installer: format::Installer, +} + +impl VersionInfo { + /// Convert signed response data to public version type + /// NOTE: `response` is assumed to be verified and untampered. It is not verified. + pub fn try_from_response( + params: &VersionParameters, + response: format::Response, + ) -> anyhow::Result<Self> { + let mut releases = response.releases; + + // Sort releases by version + releases.sort_by(|a, b| a.version.partial_cmp(&b.version).unwrap_or(Ordering::Equal)); + + // Fail if there are duplicate versions. + // Check this before anything else so that it's rejected indepentently of `params`. + // Important! This must occur after sorting + if let Some(dup_version) = Self::find_duplicate_version(&releases) { + anyhow::bail!("API response contains at least one duplicated version: {dup_version}"); + } + + // Filter releases based on rollout and architecture + let releases: Vec<_> = releases + .into_iter() + // Filter out releases that are not rolled out to us + .filter(|release| release.rollout >= params.rollout) + // Include only installers for the requested architecture + .flat_map(|release| { + release + .installers + .into_iter() + .filter(|installer| params.architecture == installer.architecture) + // Map each artifact to a [IntermediateVersion] + .map(move |installer| { + IntermediateVersion { + version: release.version.clone(), + changelog: release.changelog.clone(), + installer, + } + }) + }) + .collect(); + + // Find latest stable version + let stable = releases + .iter() + .rfind(|release| release.version.pre_stable.is_none() && !release.version.is_dev()); + let Some(stable) = stable.cloned() else { + anyhow::bail!("No stable version found"); + }; + + // Find the latest beta version + let beta = releases + .iter() + // Find most recent beta version + .rfind(|release| matches!(release.version.pre_stable, Some(PreStableType::Beta(_))) && !release.version.is_dev()) + // If the latest beta version is older than latest stable, dispose of it + .filter(|release| release.version > stable.version) + .cloned(); + + Ok(Self { + stable: Version::try_from(stable)?, + beta: beta.map(|beta| Version::try_from(beta)).transpose()?, + }) + } + + /// Returns the first duplicated version found in `releases`. + /// `None` is returned if there are no duplicates. + /// NOTE: `releases` MUST be sorted + fn find_duplicate_version(releases: &[format::Release]) -> Option<&mullvad_version::Version> { + releases + .windows(2) + .find(|pair| &pair[0].version == &pair[1].version) + .map(|pair| &pair[0].version) + } +} + +impl TryFrom<IntermediateVersion> for Version { + type Error = anyhow::Error; + + fn try_from(version: IntermediateVersion) -> Result<Self, Self::Error> { + // Convert hex checksum to bytes + let sha256 = hex::decode(version.installer.sha256) + .context("Invalid checksum hex")? + .try_into() + .map_err(|_| anyhow::anyhow!("Invalid checksum length"))?; + + Ok(Version { + version: version.version, + size: version.installer.size, + urls: version.installer.urls, + changelog: version.changelog, + sha256, + }) + } +} + +#[cfg(test)] +mod test { + use insta::assert_yaml_snapshot; + + use super::*; + + // These tests rely on `insta` for snapshot testing. If they fail due to snapshot assertions, + // then most likely the snapshots need to be updated. The most convenient way to review + // changes to, and update, snapshots are by running `cargo insta review`. + + /// Test version info response handler (rollout 1, x86) + #[test] + fn test_version_info_parser_x86() -> anyhow::Result<()> { + let response = format::SignedResponse::deserialize_and_verify_insecure(include_bytes!( + "../test-version-response.json" + ))?; + + let params = VersionParameters { + architecture: VersionArchitecture::X86, + rollout: 1., + }; + + // Expect: The available latest versions for X86, where the rollout is 1. + let info = VersionInfo::try_from_response(¶ms, response.signed.clone())?; + + assert_yaml_snapshot!(info); + + Ok(()) + } + + /// Test version info response handler (rollout 0.01, arm64) + #[test] + fn test_version_info_parser_arm64() -> anyhow::Result<()> { + let response = format::SignedResponse::deserialize_and_verify_insecure(include_bytes!( + "../test-version-response.json" + ))?; + + let params = VersionParameters { + architecture: VersionArchitecture::Arm64, + rollout: 0.01, + }; + + let info = VersionInfo::try_from_response(¶ms, response.signed)?; + + // Expect: The available latest versions for arm64, where the rollout is .01. + assert_yaml_snapshot!(info); + + Ok(()) + } +} |
