summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--installer-downloader/src/controller.rs38
-rw-r--r--installer-downloader/tests/controller.rs45
-rw-r--r--mullvad-update/src/api.rs260
-rw-r--r--mullvad-update/src/format/deserializer.rs8
-rw-r--r--mullvad-update/src/format/key.rs9
-rw-r--r--mullvad-update/src/lib.rs1
-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.snap83
-rw-r--r--mullvad-update/src/version.rs205
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(&params, 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(&params, 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(&params, 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(&params, 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(&params, response.signed)?;
+
+ // Expect: The available latest versions for arm64, where the rollout is .01.
+ assert_yaml_snapshot!(info);
+
+ Ok(())
+ }
+}