diff options
| author | Markus Pettersson <markus.pettersson@mullvad.net> | 2025-10-16 15:57:54 +0200 |
|---|---|---|
| committer | Joakim Hulthe <joakim.hulthe@mullvad.net> | 2025-10-23 10:22:39 +0200 |
| commit | 53d22740f271f806a42763c148d21949724d783e (patch) | |
| tree | f7d1b522d8faebb4e86137ca256669460f64866b | |
| parent | 826d3b3bf4a09a3409a7f8bc415e56154525ff7d (diff) | |
| parent | 309a2569fd9567622f010da63a00baa13b8fecfd (diff) | |
| download | mullvadvpn-53d22740f271f806a42763c148d21949724d783e.tar.xz mullvadvpn-53d22740f271f806a42763c148d21949724d783e.zip | |
Merge branch 'respect-version-rollout-rate-in-daemon-des-2226' into prepare-2025.13
| -rw-r--r-- | .github/workflows/downloader.yml | 2 | ||||
| -rw-r--r-- | CHANGELOG.md | 1 | ||||
| -rw-r--r-- | mullvad-api/src/version.rs | 4 | ||||
| -rw-r--r-- | mullvad-daemon/src/lib.rs | 24 | ||||
| -rw-r--r-- | mullvad-daemon/src/version/check.rs | 31 | ||||
| -rw-r--r-- | mullvad-daemon/src/version/router.rs | 4 | ||||
| -rw-r--r-- | mullvad-management-interface/src/types/conversions/settings.rs | 6 | ||||
| -rw-r--r-- | mullvad-types/src/settings/mod.rs | 9 | ||||
| -rw-r--r-- | mullvad-update/Cargo.toml | 5 | ||||
| -rw-r--r-- | mullvad-update/mullvad-release/src/main.rs | 15 | ||||
| -rw-r--r-- | mullvad-update/mullvad-release/src/platform.rs | 16 | ||||
| -rw-r--r-- | mullvad-update/src/format/mod.rs | 16 | ||||
| -rw-r--r-- | mullvad-update/src/snapshots/mullvad_update__version__test__rollout_threshold_uniqueness-2.snap | 5 | ||||
| -rw-r--r-- | mullvad-update/src/snapshots/mullvad_update__version__test__rollout_threshold_uniqueness.snap | 5 | ||||
| -rw-r--r-- | mullvad-update/src/version.rs | 152 | ||||
| -rw-r--r-- | test/Cargo.lock | 1 |
16 files changed, 255 insertions, 41 deletions
diff --git a/.github/workflows/downloader.yml b/.github/workflows/downloader.yml index 631a1bceeb..159a333c18 100644 --- a/.github/workflows/downloader.yml +++ b/.github/workflows/downloader.yml @@ -68,7 +68,7 @@ jobs: env: # If the file is larger than this, a regression has probably been introduced. # You should think twice before increasing this limit. - MAX_BINARY_SIZE: 3210000 + MAX_BINARY_SIZE: 3225000 steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e77847b94..517ceecf49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Line wrap the file at 100 chars. Th issues where users get stuck in the out-of-time view. - Update Electron from 36.5.0 to 37.6.0. - Run version check hourly and when interacting with the app instead of once per day. +- Add support for gradual rollouts of new app releases ### Fixed #### macOS diff --git a/mullvad-api/src/version.rs b/mullvad-api/src/version.rs index ff1ec63788..b4cc16ddd2 100644 --- a/mullvad-api/src/version.rs +++ b/mullvad-api/src/version.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use http::StatusCode; use http::header; -use mullvad_update::version::{VersionInfo, VersionParameters, is_version_supported}; +use mullvad_update::version::{Rollout, VersionInfo, VersionParameters, is_version_supported}; type AppVersion = String; @@ -107,9 +107,9 @@ impl AppVersionProxy { &self, platform: &str, architecture: mullvad_update::format::Architecture, - rollout: f32, lowest_metadata_version: usize, platform_version: Option<String>, + rollout: Rollout, etag: Option<String>, ) -> impl Future<Output = Result<Option<AppVersionResponse2>, rest::Error>> + use<> { let service = self.handle.service.clone(); diff --git a/mullvad-daemon/src/lib.rs b/mullvad-daemon/src/lib.rs index d50e32480a..f1116e4875 100644 --- a/mullvad-daemon/src/lib.rs +++ b/mullvad-daemon/src/lib.rs @@ -71,6 +71,8 @@ use mullvad_types::{ version::AppVersionInfo, wireguard::{PublicKey, QuantumResistantState, RotationInterval}, }; +#[cfg(not(target_os = "android"))] +use mullvad_update::version::generate_rollout_seed; use relay_list::{RELAYS_FILENAME, RelayListUpdater, RelayListUpdaterHandle}; use settings::SettingsPersister; use std::collections::BTreeSet; @@ -734,6 +736,7 @@ impl Daemon { let settings_event_listener = management_interface.notifier().clone(); let mut settings = SettingsPersister::load(&config.settings_dir).await; + settings.register_change_listener(move |settings| { // Notify management interface server of changes to the settings settings_event_listener.notify_settings(settings.to_owned()); @@ -934,12 +937,33 @@ impl Daemon { on_relay_list_update, ); + #[cfg(not(target_os = "android"))] + let rollout = { + settings + .update(|settings| { + settings + .rollout_threshold_seed + .get_or_insert_with(generate_rollout_seed); + }) + .await + .map_err(Error::SettingsError)?; + let seed = settings + .rollout_threshold_seed + .expect("Rollout seed must have been initialized"); + let version = mullvad_version::VERSION + .parse() + .expect("App version to be parsable"); + mullvad_update::version::Rollout::threshold(seed, version) + }; + #[cfg(target_os = "android")] + let rollout = mullvad_update::version::SUPPORTED_VERSION; let version_handle = version::router::spawn_version_router( api_handle.clone(), api_handle.availability.clone(), config.cache_dir.clone(), internal_event_tx.to_specialized_sender(), settings.show_beta_releases, + rollout, app_upgrade_broadcast, ); diff --git a/mullvad-daemon/src/version/check.rs b/mullvad-daemon/src/version/check.rs index 3e74c0a064..71f0bbb745 100644 --- a/mullvad-daemon/src/version/check.rs +++ b/mullvad-daemon/src/version/check.rs @@ -6,8 +6,7 @@ use futures::{ use mullvad_api::{ availability::ApiAvailability, rest::MullvadRestHandle, version::AppVersionProxy, }; - -use mullvad_update::version::VersionInfo; +use mullvad_update::version::{Rollout, VersionInfo}; use mullvad_version::Version; use serde::{Deserialize, Serialize}; use std::{ @@ -94,6 +93,7 @@ impl VersionUpdater { cache_dir: PathBuf, update_sender: mpsc::UnboundedSender<VersionCache>, refresh_rx: mpsc::UnboundedReceiver<()>, + rollout: Rollout, ) { // load the last known AppVersionInfo from cache let last_app_version_info = load_cache(&cache_dir).await; @@ -118,6 +118,7 @@ impl VersionUpdater { version_proxy, platform_version, }, + rollout, ), ); } @@ -190,6 +191,7 @@ impl VersionUpdaterInner { mut refresh_rx: mpsc::UnboundedReceiver<()>, update: UpdateContext, api: ApiContext, + rollout: Rollout, ) { // If this is a dev build, there's no need to pester the API for version checks. if !*CHECK_ENABLED { @@ -202,13 +204,20 @@ impl VersionUpdaterInner { let update = |info| Box::pin(update.update(info)) as BoxFuture<'static, _>; let do_version_check = |min_metadata_version, last_platform_check, etag| { - do_version_check(api.clone(), min_metadata_version, last_platform_check, etag) + do_version_check( + api.clone(), + min_metadata_version, + last_platform_check, + rollout, + etag, + ) }; let do_version_check_in_background = |min_metadata_version, last_platform_check, etag| { do_version_check_in_background( api.clone(), min_metadata_version, last_platform_check, + rollout, etag, ) }; @@ -364,6 +373,7 @@ fn do_version_check( api: ApiContext, min_metadata_version: usize, last_platform_check: Option<SystemTime>, + _rollout: Rollout, etag: Option<String>, ) -> BoxFuture<'static, Result<Option<VersionCache>, Error>> { let api_handle = api.api_handle.clone(); @@ -373,6 +383,8 @@ fn do_version_check( &api, min_metadata_version, last_platform_check, + #[cfg(not(target_os = "android"))] + _rollout, etag.clone(), ) }; @@ -398,10 +410,18 @@ fn do_version_check_in_background( api: ApiContext, min_metadata_version: usize, last_platform_check: Option<SystemTime>, + _rollout: Rollout, etag: Option<String>, ) -> BoxFuture<'static, Result<Option<VersionCache>, Error>> { let when_available = api.api_handle.wait_background(); - let version_cache = version_check_inner(&api, min_metadata_version, last_platform_check, etag); + let version_cache = version_check_inner( + &api, + min_metadata_version, + last_platform_check, + #[cfg(not(target_os = "android"))] + _rollout, + etag, + ); Box::pin(async move { when_available.await.map_err(Error::ApiCheck)?; version_cache.await @@ -414,6 +434,7 @@ fn version_check_inner( api: &ApiContext, min_metadata_version: usize, last_platform_check: Option<SystemTime>, + rollout: Rollout, etag: Option<String>, ) -> impl Future<Output = Result<Option<VersionCache>, Error>> + use<> { let add_platform_headers = should_include_platform_headers(last_platform_check); @@ -430,9 +451,9 @@ fn version_check_inner( let endpoint = api.version_proxy.version_check_2( PLATFORM, architecture, - mullvad_update::version::SUPPORTED_VERSION, min_metadata_version, add_platform_headers.then(|| api.platform_version.clone()), + rollout, etag, ); diff --git a/mullvad-daemon/src/version/router.rs b/mullvad-daemon/src/version/router.rs index 991f633db9..7f878a94bc 100644 --- a/mullvad-daemon/src/version/router.rs +++ b/mullvad-daemon/src/version/router.rs @@ -7,7 +7,7 @@ use mullvad_api::{availability::ApiAvailability, rest::MullvadRestHandle}; use mullvad_types::version::{AppVersionInfo, SuggestedUpgrade}; #[cfg(in_app_upgrade)] use mullvad_update::app::{AppDownloader, AppDownloaderParameters, HttpAppDownloader}; -use mullvad_update::version::VersionInfo; +use mullvad_update::version::{Rollout, VersionInfo}; use talpid_core::mpsc::Sender; #[cfg(in_app_upgrade)] use talpid_types::ErrorExt; @@ -210,6 +210,7 @@ pub(crate) fn spawn_version_router( cache_dir: PathBuf, version_event_sender: DaemonEventSender<AppVersionInfo>, beta_program: bool, + rollout: Rollout, app_upgrade_broadcast: AppUpgradeBroadcast, ) -> VersionRouterHandle { let (tx, rx) = mpsc::unbounded(); @@ -232,6 +233,7 @@ pub(crate) fn spawn_version_router( cache_dir.clone(), new_version_tx, refresh_version_check_rx, + rollout, ) .await; diff --git a/mullvad-management-interface/src/types/conversions/settings.rs b/mullvad-management-interface/src/types/conversions/settings.rs index 8e1275b72b..64d2245e7a 100644 --- a/mullvad-management-interface/src/types/conversions/settings.rs +++ b/mullvad-management-interface/src/types/conversions/settings.rs @@ -205,6 +205,12 @@ impl TryFrom<proto::Settings> for mullvad_types::settings::Settings { )?, recents: Some(vec![]), update_default_location: settings.update_default_location, + // HACK: The deamon should never read this random settings blob from a random client. + // We should look into separating the serializable settings object that pass accross + // gRPC from the daemon's trusted settings. There are multiple fields that would not be + // included in the serializable settings, such as the below value. + #[cfg(not(target_os = "android"))] + rollout_threshold_seed: None, }) } } diff --git a/mullvad-types/src/settings/mod.rs b/mullvad-types/src/settings/mod.rs index 221d04ee47..cb86af4492 100644 --- a/mullvad-types/src/settings/mod.rs +++ b/mullvad-types/src/settings/mod.rs @@ -109,6 +109,13 @@ pub struct Settings { pub settings_version: SettingsVersion, /// Stores the user's recently connected locations. If None recents have been disabled by the user. pub recents: Option<Vec<Recent>>, + /// A randomly generated number used as input when determining if the client should update. Note that this + /// number is not solely responsible for determining _when_ the client should be updated, but + /// it is expected to be fairly unique. + /// + /// This is an Option to make the Default implementation deterministic. + #[cfg(not(target_os = "android"))] + pub rollout_threshold_seed: Option<u32>, } #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] @@ -283,6 +290,8 @@ impl Default for Settings { split_tunnel: SplitTunnelSettings::default(), settings_version: CURRENT_SETTINGS_VERSION, recents: Some(vec![]), + #[cfg(not(target_os = "android"))] + rollout_threshold_seed: None, } } } diff --git a/mullvad-update/Cargo.toml b/mullvad-update/Cargo.toml index 85a85d94c5..4d1223eb3c 100644 --- a/mullvad-update/Cargo.toml +++ b/mullvad-update/Cargo.toml @@ -13,7 +13,7 @@ workspace = true [features] default = [] sign = ["rand08", "clap"] -client = ["reqwest", "sha2", "tokio", "thiserror"] +client = ["reqwest", "tokio", "thiserror"] [dependencies] anyhow = { workspace = true } @@ -23,12 +23,12 @@ ed25519-dalek = { version = "2.1", features = ["zeroize", "rand_core"] } hex = { version = "0.4" } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +sha2 = { workspace = true } zeroize = { version = "1.8", features = ["zeroize_derive"] } log = { workspace = true } itertools = { workspace = true } reqwest = { workspace = true, optional = true } -sha2 = { workspace = true, optional = true } strum = { workspace = true, features = ["derive"], optional = true } tokio = { workspace = true, features = ["rt-multi-thread", "fs", "process", "macros"], optional = true } vec1 = { workspace = true } @@ -41,6 +41,7 @@ clap = { workspace = true, optional = true } # TODO Upgrading to rand 0.9 is blocked on ed25519-dalek dropping their dependency on rand_core 0.6 # (as of ed25519-dalek 2.2.0) rand08 = { package = "rand", version = "0.8.5", optional = true } +rand = { workspace = true } thiserror = { workspace = true, optional = true } diff --git a/mullvad-update/mullvad-release/src/main.rs b/mullvad-update/mullvad-release/src/main.rs index f5d6dd9181..d9bc523aca 100644 --- a/mullvad-update/mullvad-release/src/main.rs +++ b/mullvad-update/mullvad-release/src/main.rs @@ -15,7 +15,7 @@ use platform::Platform; use mullvad_update::{ api::HttpVersionInfoProvider, format::{self, SignedResponse, key}, - version::Rollout, + version::{FULLY_ROLLED_OUT, Rollout}, }; use crate::io_util::wait_for_confirm; @@ -30,7 +30,7 @@ mod platform; const DEFAULT_EXPIRY_MONTHS: usize = 6; /// Rollout to use when not specified -const DEFAULT_ROLLOUT: f32 = 1.; +const DEFAULT_ROLLOUT: Rollout = FULLY_ROLLED_OUT; /// Filename for latest.json metadata const LATEST_FILENAME: &str = "latest.json"; @@ -76,9 +76,9 @@ pub enum Opt { version: mullvad_version::Version, /// Platforms to add releases for. All if none are specified platforms: Vec<Platform>, - /// Rollout fraction 0-1. The default is 1, i.e. 100% + /// Rollout fraction to set (0 = not rolled out, 1 = fully rolled out). #[arg(long, default_value_t = DEFAULT_ROLLOUT)] - rollout: f32, + rollout: Rollout, }, /// Remove release from `work/` @@ -95,9 +95,9 @@ pub enum Opt { version: mullvad_version::Version, /// Platforms to remove releases for. All if none are specified platforms: Vec<Platform>, - /// Rollout percentage. The default is 1 + /// If set, modify the rollout fraction. #[arg(long)] - rollout: Option<f32>, + rollout: Option<Rollout>, }, /// Sign using an ed25519 key and output the signed metadata to `signed/` @@ -124,11 +124,12 @@ pub enum Opt { QueryLatest { /// Platforms to query for. All if none are specified platforms: Vec<Platform>, - /// Rollout threshold to use (.0 = not rolled out, 1 = fully rolled out). + /// Rollout threshold to use (0 = not rolled out, 1 = fully rolled out). /// /// By default, any non-zero rollout is accepted. /// Setting the value to zero will also show supported versions that have /// been released but are currently not being rolled out. + // TODO: this prints 0%, but should print 1.1920929e-7 #[arg(long, default_value_t = mullvad_update::version::SUPPORTED_VERSION)] rollout: Rollout, }, diff --git a/mullvad-update/mullvad-release/src/platform.rs b/mullvad-update/mullvad-release/src/platform.rs index 3ebfcaaf37..d7114e484a 100644 --- a/mullvad-update/mullvad-release/src/platform.rs +++ b/mullvad-update/mullvad-release/src/platform.rs @@ -4,7 +4,9 @@ use anyhow::{Context, anyhow, bail}; use mullvad_update::{ api::{HttpVersionInfoProvider, MetaRepositoryPlatform}, format::{self, key}, - version::{MIN_VERIFY_METADATA_VERSION, VersionArchitecture, VersionInfo, VersionParameters}, + version::{ + MIN_VERIFY_METADATA_VERSION, Rollout, VersionArchitecture, VersionInfo, VersionParameters, + }, }; use std::{ cmp::Ordering, @@ -226,7 +228,7 @@ impl Platform { version: &mullvad_version::Version, changes: &str, base_urls: &[String], - rollout: f32, + rollout: Rollout, ) -> anyhow::Result<()> { let installers = self.installers(version, base_urls).await?; @@ -356,7 +358,7 @@ impl Platform { pub async fn modify_release( &self, version: &mullvad_version::Version, - rollout: Option<f32>, + rollout: Option<Rollout>, ) -> anyhow::Result<()> { let work_path = self.work_path(); println!("Modifying {version} in {}", work_path.display()); @@ -388,7 +390,7 @@ impl Platform { } /// Return the latest release for platforms in `signed/` - pub async fn query_latest(&self, rollout: f32) -> anyhow::Result<VersionQueryOutput> { + pub async fn query_latest(&self, rollout: Rollout) -> anyhow::Result<VersionQueryOutput> { let response = self.read_signed().await?; // Grab version info for all architectures @@ -477,10 +479,8 @@ fn print_release_info(release: &format::Release) { let architectures = architectures.join(", "); println!( - "- {} ({}) ({}%)", - release.version, - architectures, - (release.rollout * 100.) as u32 + "- {} ({}) ({})", + release.version, architectures, release.rollout, ); } diff --git a/mullvad-update/src/format/mod.rs b/mullvad-update/src/format/mod.rs index 790f38ee4b..d82537f49e 100644 --- a/mullvad-update/src/format/mod.rs +++ b/mullvad-update/src/format/mod.rs @@ -15,6 +15,8 @@ use std::fmt::Display; use serde::{Deserialize, Serialize}; +use crate::version::{FULLY_ROLLED_OUT, Rollout}; + pub mod deserializer; pub mod key; #[cfg(feature = "sign")] @@ -74,7 +76,7 @@ pub struct Release { /// Fraction of users that should receive the new version #[serde(default = "complete_rollout")] #[serde(skip_serializing_if = "is_complete_rollout")] - pub rollout: f32, + pub rollout: Rollout, } impl PartialEq for Release { @@ -90,12 +92,14 @@ impl PartialOrd for Release { } /// A full rollout includes all users -fn complete_rollout() -> f32 { - 1. +fn complete_rollout() -> Rollout { + FULLY_ROLLED_OUT } -fn is_complete_rollout(b: impl std::borrow::Borrow<f32>) -> bool { - (b.borrow() - complete_rollout()).abs() < f32::EPSILON +fn is_complete_rollout(b: impl std::borrow::Borrow<Rollout>) -> bool { + // TODO: do we actually need this? if so, should we bake it into Rollout::eq? + //(b.borrow() - complete_rollout()).abs() < f32::EPSILON + b.borrow() == &FULLY_ROLLED_OUT } /// App installer @@ -171,7 +175,7 @@ mod test { ); // rollout *should* be serialized if not equal to default value - let rollout = 0.99; + let rollout = Rollout::try_from(0.99).unwrap(); let serialized = serde_json::to_value(Release { version: "2024.1".parse().unwrap(), changelog: "".to_owned(), diff --git a/mullvad-update/src/snapshots/mullvad_update__version__test__rollout_threshold_uniqueness-2.snap b/mullvad-update/src/snapshots/mullvad_update__version__test__rollout_threshold_uniqueness-2.snap new file mode 100644 index 0000000000..e089b68ca7 --- /dev/null +++ b/mullvad-update/src/snapshots/mullvad_update__version__test__rollout_threshold_uniqueness-2.snap @@ -0,0 +1,5 @@ +--- +source: mullvad-update/src/version.rs +expression: "rollout_threshold(seed, v20255)" +--- +0.68220514 diff --git a/mullvad-update/src/snapshots/mullvad_update__version__test__rollout_threshold_uniqueness.snap b/mullvad-update/src/snapshots/mullvad_update__version__test__rollout_threshold_uniqueness.snap new file mode 100644 index 0000000000..754c45deb0 --- /dev/null +++ b/mullvad-update/src/snapshots/mullvad_update__version__test__rollout_threshold_uniqueness.snap @@ -0,0 +1,5 @@ +--- +source: mullvad-update/src/version.rs +expression: "rollout_threshold(seed, v20254)" +--- +0.98061204 diff --git a/mullvad-update/src/version.rs b/mullvad-update/src/version.rs index 0fe6d7d66b..b350bdf452 100644 --- a/mullvad-update/src/version.rs +++ b/mullvad-update/src/version.rs @@ -4,11 +4,17 @@ //! //! The main input here is [VersionParameters], and the main output is [VersionInfo]. -use std::cmp::Ordering; +use std::{ + cmp::Ordering, + fmt::{self, Display}, + ops::RangeInclusive, + str::FromStr, +}; -use anyhow::Context; +use anyhow::{Context, bail}; use itertools::Itertools; use mullvad_version::PreStableType; +use serde::{Deserialize, Serialize, de::Error}; use crate::format::{self, Installer, Response}; @@ -30,17 +36,22 @@ pub struct VersionParameters { } /// Rollout threshold. Any version in the response below this threshold will be ignored -pub type Rollout = f32; +/// +/// INVARIANT: The inner f32 must be in the `VALID_ROLLOUT` range. +#[derive(Debug, Clone, Copy, PartialOrd, PartialEq)] +pub struct Rollout(f32); /// Accept *any* version (rollout >= 0) when querying for app info. -pub const IGNORE: Rollout = 0.; +pub const IGNORE: Rollout = Rollout(0.); /// Accept any version (rollout > 0) when querying for app info. /// Only versions with a non-zero rollout are supported. -pub const SUPPORTED_VERSION: Rollout = f32::EPSILON; +pub const SUPPORTED_VERSION: Rollout = Rollout(f32::EPSILON); /// Accept only fully rolled out versions (rollout >= 1) when querying for app info. -pub const FULLY_ROLLED_OUT: Rollout = 1.; +pub const FULLY_ROLLED_OUT: Rollout = Rollout(1.); + +pub const VALID_ROLLOUT: RangeInclusive<f32> = 0.0..=1.0; /// Installer architecture pub type VersionArchitecture = format::Architecture; @@ -153,6 +164,94 @@ pub fn is_version_supported( .any(|release| release.version.eq(¤t_version)) } +impl Rollout { + /// Calculate the threshold used to determine if a client is included in the current rollout of + /// some release. + /// + /// Invariant: 0.0 < threshold <= 1.0 + /// + /// 0.0 is a special-cased rollout value reserved for complete rollbacks. See [IGNORE]. + pub fn threshold(rollout_threshold_seed: u32, version: mullvad_version::Version) -> Self { + use rand::{Rng, SeedableRng, rngs::SmallRng}; + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(rollout_threshold_seed.to_string()); + hasher.update(version.to_string()); + let hash = hasher.finalize(); + let seed: &[u8; 32] = hash.first_chunk().expect("SHA256 hash is 32 bytes"); + let mut rng = SmallRng::from_seed(*seed); + let threshold = rng.random_range(SUPPORTED_VERSION.0..=FULLY_ROLLED_OUT.0); + Self::try_from(threshold).expect("threshold is within the Rollout domain") + } +} + +impl TryFrom<f32> for Rollout { + type Error = anyhow::Error; + + fn try_from(rollout: f32) -> Result<Self, Self::Error> { + if !rollout.is_finite() { + bail!("rollout value must be a finite number, but was {rollout}"); + } + + if !VALID_ROLLOUT.contains(&rollout) { + bail!( + "rollout value {rollout} is outside valid range {}..={}", + VALID_ROLLOUT.start(), + VALID_ROLLOUT.end(), + ); + } + + Ok(Rollout(rollout)) + } +} + +// TODO: the mullvad-release cli might rely on this being formatted as an f32 +impl Display for Rollout { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}%", (self.0 * 100.) as u32) + } +} + +// TODO: the mullvad-release cli might rely on this being formatted as an f32 +impl FromStr for Rollout { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + let rollout: f32 = s.parse()?; + Rollout::try_from(rollout) + } +} + +impl<'de> Deserialize<'de> for Rollout { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let rollout = f32::deserialize(deserializer)?; + + Rollout::try_from(rollout) + .map_err(|e| e.to_string()) + .map_err(D::Error::custom) + } +} + +impl Serialize for Rollout { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + self.0.serialize(serializer) + } +} + +/// Generate a special seed used to calculate at which rollout percentage a client should be +/// notified about a new release. +/// +/// See [Rollout::threshold] for details. +pub fn generate_rollout_seed() -> u32 { + rand::random() +} + #[cfg(test)] mod test { use std::str::FromStr; @@ -174,7 +273,7 @@ mod test { let params = VersionParameters { architecture: VersionArchitecture::X86, - rollout: 1., + rollout: FULLY_ROLLED_OUT, allow_empty: false, lowest_metadata_version: 0, }; @@ -196,7 +295,7 @@ mod test { let params = VersionParameters { architecture: VersionArchitecture::Arm64, - rollout: 0.01, + rollout: SUPPORTED_VERSION, allow_empty: false, lowest_metadata_version: 0, }; @@ -218,7 +317,7 @@ mod test { let params = VersionParameters { architecture: VersionArchitecture::X86, - rollout: 0.01, + rollout: SUPPORTED_VERSION, allow_empty: true, lowest_metadata_version: 0, }; @@ -287,4 +386,39 @@ mod test { Ok(()) } + + const BAD_ROLLOUT_EXAMPLES: &[f32] = &[ + -f32::EPSILON, + 1.0 + f32::EPSILON, + f32::NAN, + f32::INFINITY, + f32::NEG_INFINITY, + ]; + + #[test] + fn test_rollout_deserialize_bad() { + for &bad_rollout in BAD_ROLLOUT_EXAMPLES { + let rollout_str = bad_rollout.to_string(); + serde_json::from_str::<Rollout>(&rollout_str) + .expect_err("must fail to deserialize bad rollout"); + } + } + + #[test] + /// Check that the implementation of [rollout_threshold] yields different threshold values as + /// app version number progresses. + /// + /// Note that there is a chance for repetition - we are effectively mapping a 256 byte hash to + /// the fractional part of an [f32], which is a much smaller domain. + fn test_rollout_threshold_uniqueness() { + let seed = 4; // Chosen by fair dice roll. Guaranteed to be random. + let v20254: mullvad_version::Version = "2025.4".parse().unwrap(); + let v20255: mullvad_version::Version = "2025.5".parse().unwrap(); + assert_ne!( + Rollout::threshold(seed, v20254.clone()), + Rollout::threshold(seed, v20255.clone()) + ); + assert_yaml_snapshot!(Rollout::threshold(seed, v20254)); + assert_yaml_snapshot!(Rollout::threshold(seed, v20255)); + } } diff --git a/test/Cargo.lock b/test/Cargo.lock index 81a7640654..c86ab5db36 100644 --- a/test/Cargo.lock +++ b/test/Cargo.lock @@ -2170,6 +2170,7 @@ dependencies = [ "log", "mullvad-api-constants", "mullvad-version", + "rand 0.9.1", "reqwest", "serde", "serde_json", |
