summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorMarkus Pettersson <markus.pettersson@mullvad.net>2025-10-16 15:57:54 +0200
committerJoakim Hulthe <joakim.hulthe@mullvad.net>2025-10-23 10:22:39 +0200
commit53d22740f271f806a42763c148d21949724d783e (patch)
treef7d1b522d8faebb4e86137ca256669460f64866b
parent826d3b3bf4a09a3409a7f8bc415e56154525ff7d (diff)
parent309a2569fd9567622f010da63a00baa13b8fecfd (diff)
downloadmullvadvpn-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.yml2
-rw-r--r--CHANGELOG.md1
-rw-r--r--mullvad-api/src/version.rs4
-rw-r--r--mullvad-daemon/src/lib.rs24
-rw-r--r--mullvad-daemon/src/version/check.rs31
-rw-r--r--mullvad-daemon/src/version/router.rs4
-rw-r--r--mullvad-management-interface/src/types/conversions/settings.rs6
-rw-r--r--mullvad-types/src/settings/mod.rs9
-rw-r--r--mullvad-update/Cargo.toml5
-rw-r--r--mullvad-update/mullvad-release/src/main.rs15
-rw-r--r--mullvad-update/mullvad-release/src/platform.rs16
-rw-r--r--mullvad-update/src/format/mod.rs16
-rw-r--r--mullvad-update/src/snapshots/mullvad_update__version__test__rollout_threshold_uniqueness-2.snap5
-rw-r--r--mullvad-update/src/snapshots/mullvad_update__version__test__rollout_threshold_uniqueness.snap5
-rw-r--r--mullvad-update/src/version.rs152
-rw-r--r--test/Cargo.lock1
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(&current_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",