diff options
| author | Markus Pettersson <markus.pettersson@mullvad.net> | 2025-05-21 14:33:50 +0200 |
|---|---|---|
| committer | David Lönnhager <david.l@mullvad.net> | 2025-06-12 12:02:24 +0200 |
| commit | 84635cf866b1eb18463042c6c60e61e9dcaeba47 (patch) | |
| tree | 4e812b7841d2115f2ea45a646f17fce8095022eb | |
| parent | d6dd0ce2c73c2ed18ed190b418ce401e75f76b8d (diff) | |
| download | mullvadvpn-offline-mode-old.tar.xz mullvadvpn-offline-mode-old.zip | |
Add support for running old installer through loader when offline on Windowsoffline-mode-old
Co-authored-by: Sebastian Holmin <sebastian.holmin@mullvad.net>
Co-authored-by: Joakim Hulthe <joakim.hulthe@mullvad.net>
Co-authored-by: David Lönnhager <david.l@mullvad.net>
26 files changed, 993 insertions, 228 deletions
diff --git a/Cargo.lock b/Cargo.lock index 6d761edd51..638ccd770c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2096,6 +2096,7 @@ dependencies = [ "log", "mullvad-paths", "mullvad-update", + "mullvad-version", "native-windows-gui", "objc_id", "rand 0.8.5", @@ -2183,15 +2184,6 @@ dependencies = [ [[package]] name = "itertools" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", -] - -[[package]] -name = "itertools" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" @@ -2716,7 +2708,7 @@ dependencies = [ "clap", "clap_complete", "futures", - "itertools 0.10.5", + "itertools 0.14.0", "mullvad-management-interface", "mullvad-types", "mullvad-version", @@ -2978,7 +2970,7 @@ dependencies = [ "chrono", "intersection-derive", "ipnetwork", - "itertools 0.12.1", + "itertools 0.14.0", "log", "mullvad-types", "proptest", @@ -3054,7 +3046,9 @@ dependencies = [ "ed25519-dalek", "hex", "insta", + "itertools 0.14.0", "json-canon", + "log", "mockito", "mullvad-version", "rand 0.8.5", diff --git a/Cargo.toml b/Cargo.toml index 333772b495..5c573830e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -119,6 +119,7 @@ clap = { version = "4.4.18", features = ["cargo", "derive"] } once_cell = "1.16" serde = "1.0.204" serde_json = "1.0.122" +itertools = "0.14" pnet_packet = "0.35.0" ipnetwork = "0.20" diff --git a/installer-downloader/Cargo.toml b/installer-downloader/Cargo.toml index 16df7fb614..4b55bdd01f 100644 --- a/installer-downloader/Cargo.toml +++ b/installer-downloader/Cargo.toml @@ -31,6 +31,7 @@ tokio = { workspace = true, features = ["rt-multi-thread", "fs"] } talpid-platform-metadata = { path = "../talpid-platform-metadata" } mullvad-update = { path = "../mullvad-update", features = ["client"] } +mullvad-version = { path = "../mullvad-version" } [target.'cfg(target_os = "windows")'.dependencies] native-windows-gui = { version = "1.0.12", features = ["embed-resource", "frame", "image-decoder", "progress-bar"], default-features = false } diff --git a/installer-downloader/build.rs b/installer-downloader/build.rs index 781046c9c0..c3163a8c81 100644 --- a/installer-downloader/build.rs +++ b/installer-downloader/build.rs @@ -4,7 +4,7 @@ use std::env; fn main() -> anyhow::Result<()> { let target_os = env::var("CARGO_CFG_TARGET_OS").context("Missing 'CARGO_CFG_TARGET_OS")?; match target_os.as_str() { - "windows" => win_main(), + //"windows" => win_main(), _ => Ok(()), } } diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index cf63154719..aa93586df6 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -3,18 +3,20 @@ use crate::{ delegate::{AppDelegate, AppDelegateQueue}, environment::Environment, - resource, + resource::{self, VERIFYING_CACHED}, temp::DirectoryProvider, ui_downloader::{UiAppDownloader, UiAppDownloaderParameters, UiProgressUpdater}, }; use mullvad_update::{ - api::{HttpVersionInfoProvider, MetaRepositoryPlatform, VersionInfoProvider}, - app::{self, AppDownloader, HttpAppDownloader}, + api::{HttpVersionInfoProvider, MetaRepositoryPlatform}, + app::{self, AppCache, AppDownloader, DownloadedInstaller, HttpAppDownloader}, + local::{AppCacheDir, METADATA_FILENAME}, version::{Version, VersionInfo, VersionParameters}, + version_provider::VersionInfoProvider, }; use rand::seq::SliceRandom; -use std::path::PathBuf; +use std::{cmp::Ordering, path::PathBuf}; use tokio::{ sync::{mpsc, oneshot}, task::JoinHandle, @@ -31,6 +33,17 @@ enum TaskMessage { /// See the [module-level docs](self). pub struct AppController {} +struct WorkingDirectory { + pub directory: PathBuf, +} + +impl WorkingDirectory { + pub async fn new<D: DirectoryProvider>() -> anyhow::Result<WorkingDirectory> { + let directory = D::create_download_dir().await?; + Ok(Self { directory }) + } +} + /// Public entry function for registering a [AppDelegate]. /// /// This function uses the Mullvad API to fetch the current releases, a hardcoded public key to @@ -45,7 +58,13 @@ pub fn initialize_controller<T: AppDelegate + 'static>(delegate: &mut T, environ let platform = MetaRepositoryPlatform::current().expect("current platform must be supported"); let version_provider = HttpVersionInfoProvider::from(platform); - AppController::initialize::<_, Downloader<T>, _, DirProvider>( + #[cfg(target_os = "windows")] + type CacheDir = AppCacheDir; + + #[cfg(target_os = "macos")] + type CacheDir = NoopAppCacheDir; + + AppController::initialize::<_, Downloader<T>, CacheDir, DirProvider>( delegate, version_provider, environment, @@ -57,14 +76,14 @@ impl AppController { /// /// This function lets the caller provide a version information provider, download client, etc., /// which is useful for testing. - pub fn initialize<D, A, V, DirProvider>( + pub fn initialize<D, A, C, DirProvider>( delegate: &mut D, - version_provider: V, + mut version_provider: impl VersionInfoProvider + Send + 'static, environment: Environment, ) where D: AppDelegate + 'static, - V: VersionInfoProvider + Send + 'static, A: From<UiAppDownloaderParameters<D>> + AppDownloader + 'static, + C: AppCache + 'static, DirProvider: DirectoryProvider + 'static, { delegate.hide_download_progress(); @@ -78,8 +97,57 @@ impl AppController { let queue = delegate.queue(); let task_tx_clone = task_tx.clone(); tokio::spawn(async move { - let version_info = - fetch_app_version_info::<D, V>(queue.clone(), version_provider, environment).await; + let working_dir = loop { + match WorkingDirectory::new::<DirProvider>().await { + Ok(directory) => break directory, + Err(err) => { + log::error!("Failed to create temporary directory: {err:?}"); + + let (retry_tx, mut retry_rx) = mpsc::channel(1); + + queue.queue_main(move |self_| { + self_.clear_status_text(); + self_.hide_download_button(); + self_.hide_beta_text(); + self_.hide_stable_text(); + + let cancel_tx = retry_tx.clone(); + self_.on_error_message_cancel(move || { + let _ = cancel_tx.try_send(false); + }); + self_.on_error_message_retry(move || { + let _ = retry_tx.try_send(true); + }); + + self_.show_error_message(crate::delegate::ErrorMessage { + status_text: resource::CREATE_TEMPDIR_FAILED.to_owned(), + cancel_button_text: + resource::CREATE_TEMPDIR_FAILED_CANCEL_BUTTON_TEXT.to_owned(), + retry_button_text: + resource::CREATE_TEMPDIR_FAILED_RETRY_BUTTON_TEXT.to_owned(), + }); + }); + + if !retry_rx.recv().await.unwrap() { + queue.queue_main(|self_| { + self_.quit(); + }); + std::future::pending::<()>().await; + } + } + } + }; + + let metadata_path = working_dir.directory.join(METADATA_FILENAME); + version_provider.set_metadata_dump_path(metadata_path); + + let version_info = fetch_app_version_info::<D, C>( + queue.clone(), + version_provider, + &working_dir, + environment, + ) + .await; let version_label = format_latest_version(&version_info.stable); let has_beta = version_info.beta.is_some(); queue.queue_main(move |self_| { @@ -90,10 +158,11 @@ impl AppController { } }); - ActionMessageHandler::<D, A>::run::<DirProvider>( + ActionMessageHandler::<D, A>::run( queue, task_tx_clone, task_rx, + working_dir, version_info, ) .await; @@ -126,14 +195,15 @@ impl AppController { } /// Background task that fetches app version data. -async fn fetch_app_version_info<Delegate, VersionProvider>( +async fn fetch_app_version_info<Delegate, Cache>( queue: Delegate::Queue, - version_provider: VersionProvider, + version_provider: impl VersionInfoProvider + Send, + working_directory: &WorkingDirectory, Environment { architecture }: Environment, ) -> VersionInfo where - Delegate: AppDelegate, - VersionProvider: VersionInfoProvider + Send, + Delegate: AppDelegate + 'static, + Cache: AppCache + 'static, { loop { queue.queue_main(|self_| { @@ -149,42 +219,91 @@ where lowest_metadata_version: 0, }; - let err = match version_provider.get_version_info(version_params).await { - Ok(version_info) => { - return version_info; - } + let err = match version_provider.get_version_info(&version_params).await { + Ok(version_info) => return version_info, Err(err) => err, }; log::error!("Failed to get version info: {err:?}"); - enum Action { + enum Action<Cache: AppCache> { Retry, Cancel, + InstallExistingVersion { + cached_app_installer: Cache::Installer, + }, } - let (action_tx, mut action_rx) = mpsc::channel(1); + let (action_tx, mut action_rx) = mpsc::channel::<Action<Cache>>(1); - // show error message (needs to happen on the UI (main) thread) - // send Action when user presses a button to continue - queue.queue_main(move |self_| { - self_.hide_download_button(); + // Check if we've already downloaded an installer. + // If so, the user will be given the option to run it. + let cache = Cache::new(working_directory.directory.clone(), version_params); + // Present the 'first' available installer. In this case, we will always present + // the latest app version installer available, as we suspect that this is the app + // version the user wants to install. This could be made more dynamic (i.e. a user + // interaction instead). + let installer = cache.get_metadata().await.ok().and_then(|m| { + cache + .get_cached_installers(m) + .into_iter() + .max_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)) + }); - let (retry_tx, cancel_tx) = (action_tx.clone(), action_tx); + match installer { + Some(cached_app_installer) => { + queue.queue_main(move |self_| { + self_.hide_download_button(); - self_.clear_status_text(); - self_.on_error_message_retry(move || { - let _ = retry_tx.try_send(Action::Retry); - }); - self_.on_error_message_cancel(move || { - let _ = cancel_tx.try_send(Action::Cancel); - }); - self_.show_error_message(crate::delegate::ErrorMessage { - status_text: resource::FETCH_VERSION_ERROR_DESC.to_owned(), - cancel_button_text: resource::FETCH_VERSION_ERROR_CANCEL_BUTTON_TEXT.to_owned(), - retry_button_text: resource::FETCH_VERSION_ERROR_RETRY_BUTTON_TEXT.to_owned(), - }); - }); + let (retry_tx, cancel_tx) = (action_tx.clone(), action_tx); + + self_.clear_status_text(); + self_.on_error_message_retry(move || { + let _ = retry_tx.try_send(Action::Retry); + }); + + self_.show_error_message(crate::delegate::ErrorMessage { + status_text: resource::FETCH_VERSION_ERROR_DESC_WITH_EXISTING_DOWNLOAD + .replace("%s", &cached_app_installer.version().to_string()), + cancel_button_text: resource::FETCH_VERSION_ERROR_INSTALL_BUTTON_TEXT + .to_owned(), + retry_button_text: resource::FETCH_VERSION_ERROR_RETRY_BUTTON_TEXT + .to_owned(), + }); + self_.on_error_message_cancel(move || { + let _ = cancel_tx.try_send(Action::InstallExistingVersion { + cached_app_installer: cached_app_installer.clone(), + }); + }); + }); + } + _ => { + log::info!("Couldn't find a downloaded installer"); + // show error message (needs to happen on the UI (main) thread) + // send Action when user presses a button to continue + queue.queue_main(move |self_| { + self_.hide_download_button(); + + let (retry_tx, cancel_tx) = (action_tx.clone(), action_tx); + + self_.clear_status_text(); + self_.on_error_message_retry(move || { + let _ = retry_tx.try_send(Action::Retry); + }); + + self_.show_error_message(crate::delegate::ErrorMessage { + status_text: resource::FETCH_VERSION_ERROR_DESC.to_owned(), + cancel_button_text: resource::FETCH_VERSION_ERROR_CANCEL_BUTTON_TEXT + .to_owned(), + retry_button_text: resource::FETCH_VERSION_ERROR_RETRY_BUTTON_TEXT + .to_owned(), + }); + self_.on_error_message_cancel(move || { + let _ = cancel_tx.try_send(Action::Cancel); + }); + }) + } + }; // wait for user to press either button let action = action_rx.recv().await.expect("sender unexpectedly dropped"); @@ -200,6 +319,59 @@ where self_.quit(); }); } + Action::InstallExistingVersion { + cached_app_installer: installer, + } => { + let (action_tx, mut action_rx) = mpsc::channel::<Option<Action<Cache>>>(1); + let (retry_tx, cancel_tx) = (action_tx.clone(), action_tx.clone()); + + queue.queue_main(|self_| { + self_.hide_error_message(); + self_.clear_download_text(); + self_.hide_download_button(); + self_.hide_beta_text(); + self_.hide_stable_text(); + self_.show_cancel_button(); + self_.disable_cancel_button(); + self_.hide_download_progress(); + self_.set_status_text(VERIFYING_CACHED); + + let ui_installer = UiAppDownloader::new(&*self_, installer); + + self_.on_error_message_retry(move || { + let _ = retry_tx.try_send(Some(Action::Retry)); + }); + self_.on_error_message_cancel(move || { + let _ = cancel_tx.try_send(Some(Action::Cancel)); + }); + + tokio::spawn(async move { + if let Err(err) = app::install_and_upgrade(ui_installer).await { + log::error!("install_and_upgrade failed: {err:?}"); + } else { + let _ = action_tx.send(None).await; + } + }); + }); + + match action_rx.recv().await.unwrap() { + None => { + // If the verification was successful, wait for the app to shut down instead of looping + let () = std::future::pending().await; + } + Some(Action::Retry) => { + log::debug!("Retrying to fetch version info"); + continue; + } + Some(Action::Cancel) => { + log::debug!("Cancelling fetching version info"); + queue.queue_main(|self_| { + self_.quit(); + }); + } + Some(Action::InstallExistingVersion { .. }) => unreachable!(), + } + } } } } @@ -221,7 +393,7 @@ struct ActionMessageHandler< version_info: VersionInfo, active_download: Option<JoinHandle<()>>, target_version: TargetVersion, - temp_dir: anyhow::Result<PathBuf>, + working_directory: WorkingDirectory, _marker: std::marker::PhantomData<A>, } @@ -230,21 +402,20 @@ impl<D: AppDelegate + 'static, A: From<UiAppDownloaderParameters<D>> + AppDownlo ActionMessageHandler<D, A> { /// Run the [ActionMessageHandler] actor until the end of the program/execution - async fn run<DP: DirectoryProvider>( + async fn run( queue: D::Queue, tx: mpsc::Sender<TaskMessage>, mut rx: mpsc::Receiver<TaskMessage>, + working_directory: WorkingDirectory, version_info: VersionInfo, ) { - let temp_dir = DP::create_download_dir().await; - let mut handler = Self { queue, tx, version_info, active_download: None, target_version: TargetVersion::Stable, - temp_dir, + working_directory, _marker: std::marker::PhantomData, }; @@ -306,28 +477,7 @@ impl<D: AppDelegate + 'static, A: From<UiAppDownloaderParameters<D>> + AppDownlo }); }); - // Create temporary dir - let download_dir = match &self.temp_dir { - Ok(dir) => dir.clone(), - Err(error) => { - log::error!("Failed to create temporary directory: {error:?}"); - - self.queue.queue_main(move |self_| { - self_.clear_status_text(); - self_.hide_download_button(); - self_.hide_beta_text(); - self_.hide_stable_text(); - - self_.show_error_message(crate::delegate::ErrorMessage { - status_text: resource::DOWNLOAD_FAILED_DESC.to_owned(), - cancel_button_text: resource::DOWNLOAD_FAILED_CANCEL_BUTTON_TEXT.to_owned(), - retry_button_text: resource::DOWNLOAD_FAILED_RETRY_BUTTON_TEXT.to_owned(), - }); - }); - return; - } - }; - + let download_dir = self.working_directory.directory.clone(); log::debug!("Download directory: {}", download_dir.display()); // Begin download @@ -366,7 +516,7 @@ impl<D: AppDelegate + 'static, A: From<UiAppDownloaderParameters<D>> + AppDownlo let ui_downloader = UiAppDownloader::new(self_, downloader); let _ = tx.send(tokio::spawn(async move { - if let Err(err) = app::install_and_upgrade(ui_downloader).await { + if let Err(err) = app::download_install_and_upgrade(ui_downloader).await { log::error!("install_and_upgrade failed: {err:?}"); } })); diff --git a/installer-downloader/src/resource.rs b/installer-downloader/src/resource.rs index 393218b5b1..04396546a1 100644 --- a/installer-downloader/src/resource.rs +++ b/installer-downloader/src/resource.rs @@ -31,6 +31,15 @@ pub const DOWNLOAD_BUTTON_TEXT: &str = "Download & install"; /// Dimensions of download button (including padding) pub const DOWNLOAD_BUTTON_SIZE: (usize, usize) = (150, 40); +/// Failed to create temporary directory +pub const CREATE_TEMPDIR_FAILED: &str = "Failed to create temporary directory for artifacts. Please check if you have enough space on your hard drive."; + +/// Displayed when createing a temporary directory fails (retry button) +pub const CREATE_TEMPDIR_FAILED_RETRY_BUTTON_TEXT: &str = "Try again"; + +/// Displayed when createing a temporary directory (cancel button) +pub const CREATE_TEMPDIR_FAILED_CANCEL_BUTTON_TEXT: &str = "Cancel"; + /// Cancel button text pub const CANCEL_BUTTON_TEXT: &str = "Cancel"; @@ -41,7 +50,10 @@ pub const FETCH_VERSION_DESC: &str = "Loading version details..."; pub const LATEST_VERSION_PREFIX: &str = "Version"; /// Displayed while fetching version info from the API failed -pub const FETCH_VERSION_ERROR_DESC: &str = "Failed to load version details, please try again or make sure you have the latest installer downloader."; +pub const FETCH_VERSION_ERROR_DESC: &str = "Failed to load version details, please try again or make sure you have the latest installer loader."; + +/// Displayed while fetching version info from the API failed +pub const FETCH_VERSION_ERROR_DESC_WITH_EXISTING_DOWNLOAD: &str = "Failed to fetch new version details, please try again or install the already downloaded version (%s)."; /// Displayed while fetching version info from the API failed (retry button) pub const FETCH_VERSION_ERROR_RETRY_BUTTON_TEXT: &str = "Try again"; @@ -49,6 +61,10 @@ pub const FETCH_VERSION_ERROR_RETRY_BUTTON_TEXT: &str = "Try again"; /// Displayed while fetching version info from the API failed (cancel button) pub const FETCH_VERSION_ERROR_CANCEL_BUTTON_TEXT: &str = "Cancel"; +/// Displayed while fetching version info from the API failed, +/// but there exists a previously downloaded installer (install button) +pub const FETCH_VERSION_ERROR_INSTALL_BUTTON_TEXT: &str = "Install"; + /// The first part of "Downloading from \<some url\>... (x%)", displayed during download pub const DOWNLOADING_DESC_PREFIX: &str = "Downloading from"; @@ -64,6 +80,9 @@ pub const DOWNLOAD_FAILED_RETRY_BUTTON_TEXT: &str = "Try again"; /// Displayed when download fails (cancel button) pub const DOWNLOAD_FAILED_CANCEL_BUTTON_TEXT: &str = "Cancel"; +/// Displayed verifying a cached app installer. +pub const VERIFYING_CACHED: &str = "Verifying..."; + /// Displayed when download fails pub const VERIFICATION_FAILED_DESC: &str = "Failed to verify download, please try downloading again or contact our support by sending an email to support@mullvadvpn.net with a description of what happened."; diff --git a/installer-downloader/src/ui_downloader.rs b/installer-downloader/src/ui_downloader.rs index a31e9e75eb..fdf7737b61 100644 --- a/installer-downloader/src/ui_downloader.rs +++ b/installer-downloader/src/ui_downloader.rs @@ -6,13 +6,13 @@ use crate::{ resource, }; use mullvad_update::{ - app::{self, AppDownloader, AppDownloaderParameters}, + app::{self, AppDownloader, AppDownloaderParameters, DownloadedInstaller, VerifiedInstaller}, fetch, }; /// [AppDownloader] that delegates the actual work to some underlying `downloader` and uses it to /// update a UI. -pub struct UiAppDownloader<Delegate: AppDelegate, Downloader> { +pub struct UiAppDownloader<Delegate: AppDelegate + 'static, Downloader> { downloader: Downloader, /// Queue used to control the app UI queue: Delegate::Queue, @@ -21,9 +21,7 @@ pub struct UiAppDownloader<Delegate: AppDelegate, Downloader> { /// Parameters for [UiAppDownloader] pub type UiAppDownloaderParameters<Delegate> = AppDownloaderParameters<UiProgressUpdater<Delegate>>; -impl<Delegate: AppDelegate, Downloader: AppDownloader + Send + 'static> - UiAppDownloader<Delegate, Downloader> -{ +impl<Delegate: AppDelegate, Downloader: Send + 'static> UiAppDownloader<Delegate, Downloader> { /// Construct a [UiAppDownloader]. pub fn new(delegate: &Delegate, downloader: Downloader) -> Self { Self { @@ -36,15 +34,18 @@ impl<Delegate: AppDelegate, Downloader: AppDownloader + Send + 'static> impl<Delegate: AppDelegate, Downloader: AppDownloader + Send + 'static> AppDownloader for UiAppDownloader<Delegate, Downloader> { - async fn download_executable(&mut self) -> Result<(), app::DownloadError> { + async fn download_executable(self) -> Result<impl DownloadedInstaller, app::DownloadError> { match self.downloader.download_executable().await { - Ok(()) => { + Ok(installer) => { self.queue.queue_main(move |self_| { self_.set_download_text(resource::DOWNLOAD_COMPLETE_DESC); self_.disable_cancel_button(); }); - Ok(()) + Ok(UiAppDownloader::<Delegate, _> { + downloader: installer, + queue: self.queue, + }) } Err(err) => { self.queue.queue_main(move |self_| { @@ -65,15 +66,22 @@ impl<Delegate: AppDelegate, Downloader: AppDownloader + Send + 'static> AppDownl } } } +} - async fn verify(&mut self) -> Result<(), app::DownloadError> { +impl<Delegate: AppDelegate, Downloader: DownloadedInstaller + Send + 'static> DownloadedInstaller + for UiAppDownloader<Delegate, Downloader> +{ + async fn verify(self) -> Result<impl VerifiedInstaller, app::DownloadError> { match self.downloader.verify().await { - Ok(()) => { + Ok(verified) => { self.queue.queue_main(move |self_| { self_.set_download_text(resource::VERIFICATION_SUCCEEDED_DESC); }); - Ok(()) + Ok(UiAppDownloader::<Delegate, _> { + downloader: verified, + queue: self.queue, + }) } Err(error) => { self.queue.queue_main(move |self_| { @@ -97,7 +105,15 @@ impl<Delegate: AppDelegate, Downloader: AppDownloader + Send + 'static> AppDownl } } - async fn install(&mut self) -> Result<(), app::DownloadError> { + fn version(&self) -> &mullvad_version::Version { + self.downloader.version() + } +} + +impl<Delegate: AppDelegate, I: VerifiedInstaller + Send + 'static> VerifiedInstaller + for UiAppDownloader<Delegate, I> +{ + async fn install(self) -> Result<(), app::DownloadError> { match self.downloader.install().await { Ok(()) => { self.queue.queue_main(move |self_| { diff --git a/installer-downloader/tests/controller.rs b/installer-downloader/tests/controller.rs index 192eab1b9d..abadba72e2 100644 --- a/installer-downloader/tests/controller.rs +++ b/installer-downloader/tests/controller.rs @@ -9,8 +9,9 @@ use insta::assert_yaml_snapshot; use installer_downloader::controller::AppController; use mock::{ - FakeAppDelegate, FakeAppDownloaderHappyPath, FakeAppDownloaderVerifyFail, - FakeDirectoryProvider, FakeVersionInfoProvider, FAKE_ENVIRONMENT, + FakeAppCacheEmpty, FakeAppCacheHappyPath, FakeAppCacheVerifyFail, FakeAppDelegate, + FakeAppDownloaderHappyPath, FakeAppDownloaderVerifyFail, FakeDirectoryProvider, + FakeVersionInfoProvider, FAKE_ENVIRONMENT, }; use std::{ sync::{atomic::AtomicBool, Arc}, @@ -23,7 +24,12 @@ mod mock; #[tokio::test(start_paused = true)] async fn test_fetch_version() { let mut delegate = FakeAppDelegate::default(); - AppController::initialize::<_, FakeAppDownloaderHappyPath, _, FakeDirectoryProvider<true>>( + AppController::initialize::< + _, + FakeAppDownloaderHappyPath, + FakeAppCacheEmpty, + FakeDirectoryProvider<true>, + >( &mut delegate, FakeVersionInfoProvider::default(), FAKE_ENVIRONMENT, @@ -52,7 +58,12 @@ async fn test_fetch_version() { #[tokio::test(start_paused = true)] async fn test_download() { let mut delegate = FakeAppDelegate::default(); - AppController::initialize::<_, FakeAppDownloaderHappyPath, _, FakeDirectoryProvider<true>>( + AppController::initialize::< + _, + FakeAppDownloaderHappyPath, + FakeAppCacheEmpty, + FakeDirectoryProvider<true>, + >( &mut delegate, FakeVersionInfoProvider::default(), FAKE_ENVIRONMENT, @@ -99,10 +110,16 @@ async fn test_download() { async fn test_failed_fetch_version() { let mut delegate = FakeAppDelegate::default(); let fail_fetching = Arc::new(AtomicBool::new(true)); - AppController::initialize::<_, FakeAppDownloaderHappyPath, _, FakeDirectoryProvider<true>>( + AppController::initialize::< + _, + FakeAppDownloaderHappyPath, + FakeAppCacheEmpty, + FakeDirectoryProvider<true>, + >( &mut delegate, FakeVersionInfoProvider { fail_fetching: fail_fetching.clone(), + dump_metadata_to_file: None, }, FAKE_ENVIRONMENT, ); @@ -144,7 +161,12 @@ async fn test_failed_fetch_version() { #[tokio::test(start_paused = true)] async fn test_failed_verification() { let mut delegate = FakeAppDelegate::default(); - AppController::initialize::<_, FakeAppDownloaderVerifyFail, _, FakeDirectoryProvider<true>>( + AppController::initialize::< + _, + FakeAppDownloaderVerifyFail, + FakeAppCacheEmpty, + FakeDirectoryProvider<true>, + >( &mut delegate, FakeVersionInfoProvider::default(), FAKE_ENVIRONMENT, @@ -182,7 +204,12 @@ async fn test_failed_verification() { #[tokio::test(start_paused = true)] async fn test_failed_directory_creation() { let mut delegate = FakeAppDelegate::default(); - AppController::initialize::<_, FakeAppDownloaderHappyPath, _, FakeDirectoryProvider<false>>( + AppController::initialize::< + _, + FakeAppDownloaderHappyPath, + FakeAppCacheEmpty, + FakeDirectoryProvider<false>, + >( &mut delegate, FakeVersionInfoProvider::default(), FAKE_ENVIRONMENT, @@ -215,3 +242,103 @@ async fn test_failed_directory_creation() { // "Download failed" assert_yaml_snapshot!(delegate.state); } + +/// Test version check failing and using the app cache instead. +#[tokio::test(start_paused = true)] +async fn test_cached_app() { + let mut delegate = FakeAppDelegate::default(); + AppController::initialize::< + _, + FakeAppDownloaderHappyPath, + FakeAppCacheHappyPath, + FakeDirectoryProvider<true>, + >( + &mut delegate, + FakeVersionInfoProvider { + fail_fetching: Arc::new(AtomicBool::new(true)), + ..Default::default() + }, + FAKE_ENVIRONMENT, + ); + + // Wait for the version info + tokio::time::sleep(Duration::from_secs(1)).await; + + let queue = delegate.queue.clone(); + queue.run_callbacks(&mut delegate); + + tokio::time::sleep(Duration::from_secs(1)).await; + + let queue = delegate.queue.clone(); + queue.run_callbacks(&mut delegate); + + // "Download failed, but cached app available" + assert_yaml_snapshot!(delegate.state); + + // Try to install cached version + let cb = delegate + .error_cancel_callback + .take() + .expect("no download callback registered"); + cb(); + + // Wait for queued actions to complete + tokio::time::sleep(Duration::from_secs(1)).await; + + let queue = delegate.queue.clone(); + queue.run_callbacks(&mut delegate); + + tokio::time::sleep(Duration::from_secs(1)).await; + + let queue = delegate.queue.clone(); + queue.run_callbacks(&mut delegate); + + // "Verification succeeded, starting install" + assert_yaml_snapshot!(delegate.state); +} + +/// Test version check failing and using the app cache instead, but the cache contains a bad file. +#[tokio::test(start_paused = true)] +async fn test_cached_app_verify_fail() { + let mut delegate = FakeAppDelegate::default(); + AppController::initialize::< + _, + FakeAppDownloaderHappyPath, + FakeAppCacheVerifyFail, + FakeDirectoryProvider<true>, + >( + &mut delegate, + FakeVersionInfoProvider { + fail_fetching: Arc::new(AtomicBool::new(true)), + ..Default::default() + }, + FAKE_ENVIRONMENT, + ); + + // Wait for the version info + tokio::time::sleep(Duration::from_secs(1)).await; + + let queue = delegate.queue.clone(); + queue.run_callbacks(&mut delegate); + + // Try to install cached version + let cb = delegate + .error_cancel_callback + .take() + .expect("no download callback registered"); + cb(); + + tokio::time::sleep(Duration::from_secs(1)).await; + + // Wait for queued actions to complete + let queue = delegate.queue.clone(); + queue.run_callbacks(&mut delegate); + + tokio::time::sleep(Duration::from_secs(1)).await; + + let queue = delegate.queue.clone(); + queue.run_callbacks(&mut delegate); + + // "Download failed" + assert_yaml_snapshot!(delegate.state); +} diff --git a/installer-downloader/tests/mock.rs b/installer-downloader/tests/mock.rs index a193f2d9a7..e20e5375f7 100644 --- a/installer-downloader/tests/mock.rs +++ b/installer-downloader/tests/mock.rs @@ -6,11 +6,15 @@ use installer_downloader::delegate::{AppDelegate, AppDelegateQueue, ErrorMessage use installer_downloader::environment::{Architecture, Environment}; use installer_downloader::temp::DirectoryProvider; use installer_downloader::ui_downloader::UiAppDownloaderParameters; -use mullvad_update::api::VersionInfoProvider; -use mullvad_update::app::{AppDownloader, DownloadError}; +use mullvad_update::app::{ + AppCache, AppDownloader, DownloadError, DownloadedInstaller, VerifiedInstaller, +}; use mullvad_update::fetch::ProgressUpdater; +use mullvad_update::format::{Response, SignedResponse}; use mullvad_update::version::{Version, VersionInfo, VersionParameters}; +use mullvad_update::version_provider::VersionInfoProvider; use std::io; +use std::marker::PhantomData; use std::path::{Path, PathBuf}; use std::sync::atomic::AtomicBool; use std::sync::{Arc, LazyLock, Mutex}; @@ -20,6 +24,7 @@ use std::vec::Vec; #[derive(Default)] pub struct FakeVersionInfoProvider { pub fail_fetching: Arc<AtomicBool>, + pub dump_metadata_to_file: Option<PathBuf>, } pub static FAKE_VERSION: LazyLock<VersionInfo> = LazyLock::new(|| VersionInfo { @@ -38,12 +43,16 @@ pub const FAKE_ENVIRONMENT: Environment = Environment { }; impl VersionInfoProvider for FakeVersionInfoProvider { - async fn get_version_info(&self, _params: VersionParameters) -> anyhow::Result<VersionInfo> { + async fn get_version_info(&self, _params: &VersionParameters) -> anyhow::Result<VersionInfo> { if self.fail_fetching.load(std::sync::atomic::Ordering::SeqCst) { anyhow::bail!("Failed to fetch version info"); } Ok(FAKE_VERSION.clone()) } + + fn set_metadata_dump_path(&mut self, path: PathBuf) { + self.dump_metadata_to_file = Some(path); + } } pub struct FakeDirectoryProvider<const SUCCEED: bool> {} @@ -62,6 +71,15 @@ impl<const SUCCEEDED: bool> DirectoryProvider for FakeDirectoryProvider<SUCCEEDE /// Downloader for which all steps immediately succeed pub type FakeAppDownloaderHappyPath = FakeAppDownloader<true, true, true>; +/// Cache for which all steps immediately succeed +pub type FakeAppCacheHappyPath = FakeAppCache<true, FakeInstaller<true, true, true>>; + +/// Cache for which the verification step fails +pub type FakeAppCacheVerifyFail = FakeAppCache<true, FakeInstaller<true, false, false>>; + +/// A cache that returns nothing. +pub type FakeAppCacheEmpty = FakeAppCache<false, FakeInstaller<true, true, true>>; + /// Downloader for which the verification step fails pub type FakeAppDownloaderVerifyFail = FakeAppDownloader<true, false, false>; @@ -87,25 +105,71 @@ pub struct FakeAppDownloader< params: UiAppDownloaderParameters<FakeAppDelegate>, } +#[derive(Default, PartialEq, PartialOrd)] +pub struct FakeAppCache<const HAS_APP: bool, Installer: DownloadedInstaller + Clone + Default> { + _phantom: PhantomData<Installer>, +} + +#[derive(Default, Clone, PartialEq, PartialOrd)] +pub struct FakeInstaller< + const EXE_SUCCEED: bool, + const VERIFY_SUCCEED: bool, + const LAUNCH_SUCCEED: bool, +>; + impl<const EXE_SUCCEED: bool, const VERIFY_SUCCEED: bool, const LAUNCH_SUCCEED: bool> AppDownloader for FakeAppDownloader<EXE_SUCCEED, VERIFY_SUCCEED, LAUNCH_SUCCEED> { - async fn download_executable(&mut self) -> Result<(), DownloadError> { + async fn download_executable(mut self) -> Result<impl DownloadedInstaller, DownloadError> { self.params.app_progress.set_url(&self.params.app_url); self.params.app_progress.clear_progress(); if EXE_SUCCEED { self.params.app_progress.set_progress(1.); - Ok(()) + Ok(FakeInstaller::<EXE_SUCCEED, VERIFY_SUCCEED, LAUNCH_SUCCEED>) } else { Err(DownloadError::FetchApp(anyhow::anyhow!( "fetching app failed" ))) } } +} + +impl<const HAS_APP: bool, Installer> AppCache for FakeAppCache<HAS_APP, Installer> +where + Installer: DownloadedInstaller + Clone + Default + PartialEq + PartialOrd, +{ + type Installer = Installer; + + fn new(_directory: PathBuf, _version_params: VersionParameters) -> Self { + Self::default() + } + + fn get_cached_installers(self, _metadata: SignedResponse) -> Vec<Self::Installer> { + if HAS_APP { + vec![Installer::default()] + } else { + vec![] + } + } + #[allow(clippy::manual_async_fn)] + fn get_metadata( + &self, + ) -> impl std::future::Future<Output = anyhow::Result<SignedResponse>> + Send { + async { + Ok(SignedResponse { + signatures: vec![], + signed: Response::default(), + }) + } + } +} - async fn verify(&mut self) -> Result<(), DownloadError> { +impl<const EXE_SUCCEED: bool, const VERIFY_SUCCEED: bool, const LAUNCH_SUCCEED: bool> + DownloadedInstaller for FakeInstaller<EXE_SUCCEED, VERIFY_SUCCEED, LAUNCH_SUCCEED> +{ + async fn verify(self) -> Result<impl VerifiedInstaller, DownloadError> { if VERIFY_SUCCEED { - Ok(()) + Ok(self) } else { Err(DownloadError::Verification(anyhow::anyhow!( "verification failed" @@ -113,7 +177,20 @@ impl<const EXE_SUCCEED: bool, const VERIFY_SUCCEED: bool, const LAUNCH_SUCCEED: } } - async fn install(&mut self) -> Result<(), DownloadError> { + fn version(&self) -> &mullvad_version::Version { + &mullvad_version::Version { + year: 2042, + incremental: 1337, + pre_stable: None, + dev: None, + } + } +} + +impl<const EXE_SUCCEED: bool, const VERIFY_SUCCEED: bool, const LAUNCH_SUCCEED: bool> + VerifiedInstaller for FakeInstaller<EXE_SUCCEED, VERIFY_SUCCEED, LAUNCH_SUCCEED> +{ + async fn install(self) -> Result<(), DownloadError> { if LAUNCH_SUCCEED { Ok(()) } else { diff --git a/installer-downloader/tests/snapshots/controller__cached_app-2.snap b/installer-downloader/tests/snapshots/controller__cached_app-2.snap new file mode 100644 index 0000000000..237dd40717 --- /dev/null +++ b/installer-downloader/tests/snapshots/controller__cached_app-2.snap @@ -0,0 +1,52 @@ +--- +source: installer-downloader/tests/controller.rs +expression: delegate.state +--- +status_text: Verifying... +download_text: Verification successful. Starting install... +download_button_visible: false +cancel_button_visible: true +cancel_button_enabled: false +download_button_enabled: false +download_progress: 0 +download_progress_visible: false +beta_text_visible: false +stable_text_visible: false +error_message_visible: false +error_message: + status_text: "Failed to fetch new version details, please try again or install the already downloaded version (2042.1337)." + cancel_button_text: Install + retry_button_text: Try again +quit: true +call_log: + - hide_download_progress + - show_download_button + - disable_download_button + - hide_cancel_button + - hide_beta_text + - hide_stable_text + - on_download + - on_cancel + - on_beta_link + - on_stable_link + - show_download_button + - "set_status_text: Loading version details..." + - hide_error_message + - hide_download_button + - clear_status_text + - on_error_message_retry + - "show_error_message: Failed to fetch new version details, please try again or install the already downloaded version (2042.1337).. retry: Try again. cancel: Install" + - on_error_message_cancel + - hide_error_message + - clear_download_text + - hide_download_button + - hide_beta_text + - hide_stable_text + - show_cancel_button + - disable_cancel_button + - hide_download_progress + - "set_status_text: Verifying..." + - on_error_message_retry + - on_error_message_cancel + - "set_download_text: Verification successful. Starting install..." + - quit diff --git a/installer-downloader/tests/snapshots/controller__cached_app.snap b/installer-downloader/tests/snapshots/controller__cached_app.snap new file mode 100644 index 0000000000..cc62027c2c --- /dev/null +++ b/installer-downloader/tests/snapshots/controller__cached_app.snap @@ -0,0 +1,39 @@ +--- +source: installer-downloader/tests/controller.rs +expression: delegate.state +--- +status_text: "" +download_text: "" +download_button_visible: false +cancel_button_visible: false +cancel_button_enabled: false +download_button_enabled: false +download_progress: 0 +download_progress_visible: false +beta_text_visible: false +stable_text_visible: false +error_message_visible: true +error_message: + status_text: "Failed to fetch new version details, please try again or install the already downloaded version (2042.1337)." + cancel_button_text: Install + retry_button_text: Try again +quit: false +call_log: + - hide_download_progress + - show_download_button + - disable_download_button + - hide_cancel_button + - hide_beta_text + - hide_stable_text + - on_download + - on_cancel + - on_beta_link + - on_stable_link + - show_download_button + - "set_status_text: Loading version details..." + - hide_error_message + - hide_download_button + - clear_status_text + - on_error_message_retry + - "show_error_message: Failed to fetch new version details, please try again or install the already downloaded version (2042.1337).. retry: Try again. cancel: Install" + - on_error_message_cancel diff --git a/installer-downloader/tests/snapshots/controller__cached_app_verify_fail.snap b/installer-downloader/tests/snapshots/controller__cached_app_verify_fail.snap new file mode 100644 index 0000000000..f03fac0b04 --- /dev/null +++ b/installer-downloader/tests/snapshots/controller__cached_app_verify_fail.snap @@ -0,0 +1,56 @@ +--- +source: installer-downloader/tests/controller.rs +expression: delegate.state +--- +status_text: "" +download_text: "" +download_button_visible: false +cancel_button_visible: false +cancel_button_enabled: false +download_button_enabled: false +download_progress: 0 +download_progress_visible: false +beta_text_visible: false +stable_text_visible: false +error_message_visible: true +error_message: + status_text: "Failed to verify download, please try downloading again or contact our support by sending an email to support@mullvadvpn.net with a description of what happened." + cancel_button_text: Cancel + retry_button_text: Try again +quit: false +call_log: + - hide_download_progress + - show_download_button + - disable_download_button + - hide_cancel_button + - hide_beta_text + - hide_stable_text + - on_download + - on_cancel + - on_beta_link + - on_stable_link + - show_download_button + - "set_status_text: Loading version details..." + - hide_error_message + - hide_download_button + - clear_status_text + - on_error_message_retry + - "show_error_message: Failed to fetch new version details, please try again or install the already downloaded version (2042.1337).. retry: Try again. cancel: Install" + - on_error_message_cancel + - hide_error_message + - clear_download_text + - hide_download_button + - hide_beta_text + - hide_stable_text + - show_cancel_button + - disable_cancel_button + - hide_download_progress + - "set_status_text: Verifying..." + - on_error_message_retry + - on_error_message_cancel + - clear_status_text + - clear_download_text + - hide_download_progress + - hide_download_button + - hide_cancel_button + - "show_error_message: Failed to verify download, please try downloading again or contact our support by sending an email to support@mullvadvpn.net with a description of what happened.. retry: Try again. cancel: Cancel" diff --git a/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap b/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap index ae534f550e..80777d3d8e 100644 --- a/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap +++ b/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap @@ -7,14 +7,14 @@ download_text: "" download_button_visible: false cancel_button_visible: false cancel_button_enabled: false -download_button_enabled: true +download_button_enabled: false download_progress: 0 download_progress_visible: false beta_text_visible: false stable_text_visible: false error_message_visible: true error_message: - status_text: "Download failed, please check your internet connection or if you have enough space on your hard drive and try downloading again." + status_text: Failed to create temporary directory for artifacts. Please check if you have enough space on your hard drive. cancel_button_text: Cancel retry_button_text: Try again quit: false @@ -29,16 +29,10 @@ call_log: - on_cancel - on_beta_link - on_stable_link - - show_download_button - - "set_status_text: Loading version details..." - - hide_error_message - - "set_status_text: Version: 2025.1" - - enable_download_button - - hide_error_message - - on_error_message_retry - - on_error_message_cancel - clear_status_text - hide_download_button - hide_beta_text - hide_stable_text - - "show_error_message: Download failed, please check your internet connection or if you have enough space on your hard drive and try downloading again.. retry: Try again. cancel: Cancel" + - on_error_message_cancel + - on_error_message_retry + - "show_error_message: Failed to create temporary directory for artifacts. Please check if you have enough space on your hard drive.. retry: Try again. cancel: Cancel" diff --git a/installer-downloader/tests/snapshots/controller__failed_fetch_version-2.snap b/installer-downloader/tests/snapshots/controller__failed_fetch_version-2.snap index d9cdcf7d43..b71778ec39 100644 --- a/installer-downloader/tests/snapshots/controller__failed_fetch_version-2.snap +++ b/installer-downloader/tests/snapshots/controller__failed_fetch_version-2.snap @@ -1,6 +1,7 @@ --- source: installer-downloader/tests/controller.rs expression: delegate.state +snapshot_kind: text --- status_text: "Version: 2025.1" download_text: "" @@ -14,7 +15,7 @@ beta_text_visible: false stable_text_visible: false error_message_visible: false error_message: - status_text: "Failed to load version details, please try again or make sure you have the latest installer downloader." + status_text: "Failed to load version details, please try again or make sure you have the latest installer loader." cancel_button_text: Cancel retry_button_text: Try again quit: false @@ -35,8 +36,8 @@ call_log: - hide_download_button - clear_status_text - on_error_message_retry + - "show_error_message: Failed to load version details, please try again or make sure you have the latest installer loader.. retry: Try again. cancel: Cancel" - on_error_message_cancel - - "show_error_message: Failed to load version details, please try again or make sure you have the latest installer downloader.. retry: Try again. cancel: Cancel" - show_download_button - "set_status_text: Loading version details..." - hide_error_message diff --git a/installer-downloader/tests/snapshots/controller__failed_fetch_version.snap b/installer-downloader/tests/snapshots/controller__failed_fetch_version.snap index 8bb4a0ceea..7f550925b1 100644 --- a/installer-downloader/tests/snapshots/controller__failed_fetch_version.snap +++ b/installer-downloader/tests/snapshots/controller__failed_fetch_version.snap @@ -14,7 +14,7 @@ beta_text_visible: false stable_text_visible: false error_message_visible: true error_message: - status_text: "Failed to load version details, please try again or make sure you have the latest installer downloader." + status_text: "Failed to load version details, please try again or make sure you have the latest installer loader." cancel_button_text: Cancel retry_button_text: Try again quit: false @@ -35,5 +35,5 @@ call_log: - hide_download_button - clear_status_text - on_error_message_retry + - "show_error_message: Failed to load version details, please try again or make sure you have the latest installer loader.. retry: Try again. cancel: Cancel" - on_error_message_cancel - - "show_error_message: Failed to load version details, please try again or make sure you have the latest installer downloader.. retry: Try again. cancel: Cancel" diff --git a/mullvad-cli/Cargo.toml b/mullvad-cli/Cargo.toml index 70e7a63e1a..83911c2261 100644 --- a/mullvad-cli/Cargo.toml +++ b/mullvad-cli/Cargo.toml @@ -20,7 +20,7 @@ chrono = { workspace = true } clap = { workspace = true } thiserror = { workspace = true } futures = { workspace = true } -itertools = "0.10" +itertools = { workspace = true } natord = "1.0.9" mullvad-types = { path = "../mullvad-types", features = ["clap"] } diff --git a/mullvad-relay-selector/Cargo.toml b/mullvad-relay-selector/Cargo.toml index c113d401ba..dc3da3a5d6 100644 --- a/mullvad-relay-selector/Cargo.toml +++ b/mullvad-relay-selector/Cargo.toml @@ -14,7 +14,7 @@ workspace = true chrono = { workspace = true } thiserror = { workspace = true } ipnetwork = { workspace = true } -itertools = "0.12" +itertools = { workspace = true } log = { workspace = true } rand = "0.8.5" serde_json = { workspace = true } diff --git a/mullvad-update/Cargo.toml b/mullvad-update/Cargo.toml index 020391b635..201e0ea3b5 100644 --- a/mullvad-update/Cargo.toml +++ b/mullvad-update/Cargo.toml @@ -24,6 +24,8 @@ hex = { version = "0.4" } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } zeroize = { version = "1.8", features = ["zeroize_derive"] } +log = { workspace = true } +itertools = { workspace = true } reqwest = { version = "0.12.9", default-features = false, features = ["rustls-tls"], optional = true } sha2 = { workspace = true, optional = true } diff --git a/mullvad-update/src/client/api.rs b/mullvad-update/src/client/api.rs index d4806325e5..b037e9b1cf 100644 --- a/mullvad-update/src/client/api.rs +++ b/mullvad-update/src/client/api.rs @@ -1,12 +1,17 @@ //! This module implements fetching of information about app versions +use std::path::PathBuf; + use anyhow::Context; +use tokio::fs; #[cfg(test)] use vec1::Vec1; use crate::format; use crate::version::{VersionInfo, VersionParameters}; +use super::version_provider::VersionInfoProvider; + /// Available platforms in the default metadata repository #[derive(Debug, Clone, Copy)] pub enum MetaRepositoryPlatform { @@ -47,27 +52,24 @@ impl MetaRepositoryPlatform { } } -/// See [module-level](self) docs. -pub trait VersionInfoProvider { - /// Return info about the stable version - fn get_version_info( - &self, - params: VersionParameters, - ) -> impl std::future::Future<Output = anyhow::Result<VersionInfo>> + Send; -} - /// Obtain version data using a GET request pub struct HttpVersionInfoProvider { /// Endpoint for GET request url: String, /// Accepted root certificate. Defaults are used unless specified pinned_certificate: Option<reqwest::Certificate>, + /// If set, the response metadata will be serialized and written to this path + dump_to_path: Option<PathBuf>, } impl VersionInfoProvider for HttpVersionInfoProvider { - async fn get_version_info(&self, params: VersionParameters) -> anyhow::Result<VersionInfo> { + async fn get_version_info(&self, params: &VersionParameters) -> anyhow::Result<VersionInfo> { let response = self.get_versions(params.lowest_metadata_version).await?; - VersionInfo::try_from_response(¶ms, response.signed) + VersionInfo::try_from_response(params, response.signed) + } + + fn set_metadata_dump_path(&mut self, path: PathBuf) { + self.dump_to_path = Some(path); } } @@ -79,6 +81,7 @@ impl From<MetaRepositoryPlatform> for HttpVersionInfoProvider { HttpVersionInfoProvider { url: platform.url(), pinned_certificate: Some(crate::defaults::PINNED_CERTIFICATE.clone()), + dump_to_path: None, } } } @@ -136,7 +139,13 @@ impl HttpVersionInfoProvider { deserialize_fn: impl FnOnce(&[u8]) -> anyhow::Result<format::SignedResponse>, ) -> anyhow::Result<format::SignedResponse> { let raw_json = Self::get(&self.url, self.pinned_certificate.clone()).await?; - deserialize_fn(&raw_json) + let signed_response = deserialize_fn(&raw_json)?; + if let Some(path) = &self.dump_to_path { + fs::write(path, raw_json) + .await + .context("Failed to save cache")?; + } + Ok(signed_response) } /// Perform a simple GET request, with a size limit, and return it as bytes @@ -191,10 +200,12 @@ impl HttpVersionInfoProvider { #[cfg(test)] mod test { + use async_tempfile::TempDir; use insta::assert_yaml_snapshot; use vec1::vec1; use super::*; + use crate::{format::SignedResponse, local::METADATA_FILENAME}; // 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 @@ -220,10 +231,14 @@ mod test { let url = format!("{}/version", server.url()); + let temp_dump_dir = TempDir::new().await.unwrap(); + let temp_dump = temp_dump_dir.join(METADATA_FILENAME); + // Construct query and provider let info_provider = HttpVersionInfoProvider { url, pinned_certificate: None, + dump_to_path: Some(temp_dump.clone()), }; let info = info_provider @@ -234,6 +249,13 @@ mod test { // Expect: Our query should yield some version response assert_yaml_snapshot!(info); + // Expect: Dumped data should exist and look the same + let cached_data = fs::read(temp_dump).await.expect("expected dumped info"); + let cached_info = + SignedResponse::deserialize_and_verify_with_keys(&verifying_keys, &cached_data, 0) + .unwrap(); + assert_eq!(cached_info, info); + Ok(()) } } diff --git a/mullvad-update/src/client/app.rs b/mullvad-update/src/client/app.rs index 90dc4314e2..6145227407 100644 --- a/mullvad-update/src/client/app.rs +++ b/mullvad-update/src/client/app.rs @@ -2,13 +2,21 @@ //! This module implements the flow of downloading and verifying the app. -use std::{ffi::OsString, future::Future, path::PathBuf, time::Duration}; +use std::{ + ffi::OsString, + future::Future, + path::{Path, PathBuf}, + time::Duration, +}; +use anyhow::{bail, Context}; use tokio::{process::Command, time::timeout}; use crate::{ fetch::{self, ProgressUpdater}, + format::SignedResponse, verify::{AppVerifier, Sha256Verifier}, + version::VersionParameters, }; #[derive(Debug, thiserror::Error)] @@ -39,25 +47,53 @@ pub struct AppDownloaderParameters<AppProgress> { } /// See the [module-level documentation](self). -pub trait AppDownloader: Send { +pub trait AppDownloader: Send + 'static { /// Download the app binary. - fn download_executable(&mut self) -> impl Future<Output = Result<(), DownloadError>> + Send; + fn download_executable( + self, + ) -> impl Future<Output = Result<impl DownloadedInstaller, DownloadError>> + Send; +} + +/// A cache where we can find past [DownloadedInstaller]s +pub trait AppCache: Send { + type Installer: DownloadedInstaller + Clone + PartialOrd; + + fn new(directory: PathBuf, version_params: VersionParameters) -> Self; + fn get_metadata(&self) -> impl Future<Output = anyhow::Result<SignedResponse>> + Send; + fn get_cached_installers(self, metadata: SignedResponse) -> Vec<Self::Installer>; +} +pub trait DownloadedInstaller: Send + 'static { /// Verify the app signature. - fn verify(&mut self) -> impl Future<Output = Result<(), DownloadError>> + Send; + fn verify(self) -> impl Future<Output = Result<impl VerifiedInstaller, DownloadError>> + Send; + fn version(&self) -> &mullvad_version::Version; +} + +pub trait VerifiedInstaller: Send { /// Execute installer. - fn install(&mut self) -> impl Future<Output = Result<(), DownloadError>> + Send; + fn install(self) -> impl Future<Output = Result<(), DownloadError>> + Send; } /// How long to wait for the installer to exit before returning const INSTALLER_STARTUP_TIMEOUT: Duration = Duration::from_millis(500); -/// Download the app and signature, and verify the app's signature -pub async fn install_and_upgrade(mut downloader: impl AppDownloader) -> Result<(), DownloadError> { - downloader.download_executable().await?; - downloader.verify().await?; - downloader.install().await +/// Download the app and signature, and verify the installer's signature +pub async fn download_install_and_upgrade( + downloader: impl AppDownloader, +) -> Result<(), DownloadError> { + downloader + .download_executable() + .await? + .verify() + .await? + .install() + .await +} + +/// Verify and run the installer. +pub async fn install_and_upgrade(installer: impl DownloadedInstaller) -> Result<(), DownloadError> { + installer.verify().await?.install().await } #[derive(Clone)] @@ -79,40 +115,79 @@ impl<AppProgress: ProgressUpdater> From<AppDownloaderParameters<AppProgress>> } } +#[derive(Clone)] +pub struct InstallerFile<const VERIFIED: bool> { + path: PathBuf, + pub app_version: mullvad_version::Version, + pub app_size: usize, + pub app_sha256: [u8; 32], +} + +// TODO: Explain +impl<const VERIFIED: bool> PartialOrd for InstallerFile<VERIFIED> { + fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { + self.app_version.partial_cmp(&other.app_version) + } +} + +// TODO: Explain +impl<const VERIFIED: bool> PartialEq for InstallerFile<VERIFIED> { + fn eq(&self, other: &Self) -> bool { + self.app_version == other.app_version + } +} + impl<AppProgress: ProgressUpdater> AppDownloader for HttpAppDownloader<AppProgress> { - async fn download_executable(&mut self) -> Result<(), DownloadError> { - let bin_path = self.bin_path(); + async fn download_executable(mut self) -> Result<impl DownloadedInstaller, DownloadError> { + let bin_path = bin_path(&self.params.cache_dir, &self.params.app_version); fetch::get_to_file( - bin_path, + &bin_path, &self.params.app_url, &mut self.params.app_progress, fetch::SizeHint::Exact(self.params.app_size), ) .await - .map_err(DownloadError::FetchApp) - } + .map_err(DownloadError::FetchApp)?; - async fn verify(&mut self) -> Result<(), DownloadError> { - let bin_path = self.bin_path(); - let hash = self.hash_sha256(); + Ok(InstallerFile::<false> { + path: bin_path, + app_version: self.params.app_version, + app_size: self.params.app_size, + app_sha256: self.params.app_sha256, + }) + } +} - match Sha256Verifier::verify(&bin_path, *hash) +impl DownloadedInstaller for InstallerFile<false> { + async fn verify(self) -> Result<impl VerifiedInstaller, DownloadError> { + match Sha256Verifier::verify(&self.path, self.app_sha256) .await .map_err(DownloadError::Verification) { // Verification succeeded - Ok(()) => Ok(()), + Ok(()) => Ok(InstallerFile::<true> { + path: self.path, + app_version: self.app_version, + app_size: self.app_size, + app_sha256: self.app_sha256, + }), // Verification failed Err(err) => { // Attempt to clean up - let _ = tokio::fs::remove_file(bin_path).await; + let _ = tokio::fs::remove_file(&self.path).await; Err(err) } } } - async fn install(&mut self) -> Result<(), DownloadError> { - let launch_path = self.launch_path(); + fn version(&self) -> &mullvad_version::Version { + &self.app_version + } +} + +impl VerifiedInstaller for InstallerFile<true> { + async fn install(self) -> Result<(), DownloadError> { + let launch_path = &self.launch_path(); // Launch process let mut cmd = Command::new(launch_path); @@ -133,21 +208,61 @@ impl<AppProgress: ProgressUpdater> AppDownloader for HttpAppDownloader<AppProgre } } -impl<AppProgress> HttpAppDownloader<AppProgress> { - fn bin_path(&self) -> PathBuf { - #[cfg(windows)] - let bin_filename = format!("mullvad-{}.exe", self.params.app_version); +fn bin_path(cache_dir: &Path, app_version: &mullvad_version::Version) -> PathBuf { + #[cfg(windows)] + let bin_filename = format!("mullvad-{}.exe", app_version); - #[cfg(target_os = "macos")] - let bin_filename = format!("mullvad-{}.pkg", self.params.app_version); + #[cfg(target_os = "macos")] + let bin_filename = format!("mullvad-{}.pkg", app_version); - self.params.cache_dir.join(bin_filename) + cache_dir.join(bin_filename) +} + +impl InstallerFile<false> { + /// Create an unverified [InstallerFile] from a cache_dir and some metadata. + pub fn try_from_version( + cache_dir: &Path, + version: crate::version::Version, + ) -> anyhow::Result<Self> { + let path = bin_path(cache_dir, &version.version); + if !path.exists() { + bail!("Installer file does not exist at path: {}", path.display()); + } + Ok(Self { + path, + app_version: version.version, + app_size: version.size, + app_sha256: version.sha256, + }) } + pub fn try_from_installer( + cache_dir: &Path, + app_version: mullvad_version::Version, + installer: crate::format::Installer, + ) -> anyhow::Result<Self> { + let path = bin_path(cache_dir, &app_version); + if !path.exists() { + bail!("Installer file does not exist at path: {}", path.display()); + } + let app_sha256 = hex::decode(installer.sha256) + .context("Invalid checksum hex")? + .try_into() + .map_err(|_| anyhow::anyhow!("Invalid checksum length"))?; + Ok(Self { + path, + app_version, + app_size: installer.size, + app_sha256, + }) + } +} + +impl<const VERIFIED: bool> InstallerFile<VERIFIED> { fn launch_path(&self) -> PathBuf { #[cfg(target_os = "windows")] { - self.bin_path() + self.path.clone() } #[cfg(target_os = "macos")] @@ -166,11 +281,7 @@ impl<AppProgress> HttpAppDownloader<AppProgress> { #[cfg(target_os = "macos")] { - vec![self.bin_path().into()] + vec![self.path.clone().into_os_string()] } } - - fn hash_sha256(&self) -> &[u8; 32] { - &self.params.app_sha256 - } } diff --git a/mullvad-update/src/client/local.rs b/mullvad-update/src/client/local.rs new file mode 100644 index 0000000000..160e995ec3 --- /dev/null +++ b/mullvad-update/src/client/local.rs @@ -0,0 +1,79 @@ +//! This module implements fetching of information about app versions from disk. + +use anyhow::{bail, Context}; +use std::{path::PathBuf, vec}; +use tokio::fs; + +use crate::{format::SignedResponse, version::VersionParameters}; + +use super::app::{AppCache, InstallerFile}; + +pub struct AppCacheDir { + /// Path to directory containing the metadata file and the downloaded installer. + pub directory: PathBuf, + pub version_params: VersionParameters, +} + +pub const METADATA_FILENAME: &str = "metadata.json"; + +impl AppCache for AppCacheDir { + type Installer = InstallerFile<false>; + + fn new(directory: PathBuf, version_params: VersionParameters) -> Self { + Self { + directory, + version_params, + } + } + + async fn get_metadata(&self) -> anyhow::Result<crate::format::SignedResponse> { + let metadata_file = self.directory.join(METADATA_FILENAME); + let raw_json = fs::read(metadata_file) + .await + .context("Failed to read metadata.json")?; + let response = SignedResponse::deserialize_and_verify( + &raw_json, + self.version_params.lowest_metadata_version, + ) + .context("Failed to deserialize or verify metadata.json")?; + Ok(response) + } + + /// Get an iterator of cached installers for the current architecture + fn get_cached_installers(self, metadata: SignedResponse) -> Vec<Self::Installer> { + let releases = metadata.get_releases(); + releases + .into_iter() + // Map releases to their version and its installer for the current architecture + .flat_map(move |release| { + release + .installers + .into_iter() + .find(|installer| installer.architecture == self.version_params.architecture) + .map(|installer| (release.version, installer)) + }) + // Map to an `InstallerFile`, and filter out installers not present in cache + .filter_map(move |(version, installer)| { + InstallerFile::<false>::try_from_installer(&self.directory, version, installer).ok() + }).collect() + } +} + +/// App cacher that does not return anything +pub struct NoopAppCacheDir; + +impl AppCache for NoopAppCacheDir { + type Installer = InstallerFile<false>; + + fn new(_directory: PathBuf, _version_params: VersionParameters) -> Self { + NoopAppCacheDir + } + + fn get_cached_installers(self, _metadata: SignedResponse) -> Vec<Self::Installer> { + vec![] + } + + async fn get_metadata(&self) -> anyhow::Result<SignedResponse> { + bail!("NoopAppCacheDir can not present any metadata") + } +} diff --git a/mullvad-update/src/client/mod.rs b/mullvad-update/src/client/mod.rs index 4d8a4cc67a..d0ea4c6ac0 100644 --- a/mullvad-update/src/client/mod.rs +++ b/mullvad-update/src/client/mod.rs @@ -1,4 +1,6 @@ pub mod api; pub mod app; pub mod fetch; +pub mod local; pub mod verify; +pub mod version_provider; diff --git a/mullvad-update/src/client/version_provider.rs b/mullvad-update/src/client/version_provider.rs new file mode 100644 index 0000000000..d79bbf15a4 --- /dev/null +++ b/mullvad-update/src/client/version_provider.rs @@ -0,0 +1,14 @@ +use std::path::PathBuf; + +use crate::version::{VersionInfo, VersionParameters}; + +/// See [module-level](self) docs. +pub trait VersionInfoProvider { + /// Return info about the stable version + fn get_version_info( + &self, + params: &VersionParameters, + ) -> impl std::future::Future<Output = anyhow::Result<VersionInfo>> + Send; + + fn set_metadata_dump_path(&mut self, path: PathBuf); +} diff --git a/mullvad-update/src/format/mod.rs b/mullvad-update/src/format/mod.rs index 1e2b88bd5f..4b64950fe5 100644 --- a/mullvad-update/src/format/mod.rs +++ b/mullvad-update/src/format/mod.rs @@ -24,6 +24,7 @@ pub mod serializer; /// This type does not implement [serde::Deserialize] to prevent accidental deserialization without /// signature verification. #[derive(Debug, Serialize)] +#[cfg_attr(test, derive(PartialEq))] pub struct SignedResponse { /// Signatures of the canonicalized JSON of `signed` pub signatures: Vec<ResponseSignature>, @@ -31,10 +32,16 @@ pub struct SignedResponse { pub signed: Response, } +impl SignedResponse { + pub fn get_releases(self) -> Vec<Release> { + self.signed.releases + } +} + /// Helper type that leaves the signed data untouched /// Note that deserializing doesn't verify anything #[derive(Deserialize, Serialize)] -#[cfg_attr(test, derive(Debug))] +#[cfg_attr(test, derive(Debug, PartialEq))] struct PartialSignedResponse { /// Signatures of the canonicalized JSON of `signed` pub signatures: Vec<ResponseSignature>, @@ -45,7 +52,7 @@ struct PartialSignedResponse { /// Signed JSON response, not including the signature #[derive(Default, Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] -#[cfg_attr(test, derive(Clone))] +#[cfg_attr(test, derive(Clone, PartialEq))] pub struct Response { /// Version counter pub metadata_version: usize, @@ -71,6 +78,18 @@ pub struct Release { pub rollout: f32, } +impl PartialEq for Release { + fn eq(&self, other: &Self) -> bool { + self.version.eq(&other.version) + } +} + +impl PartialOrd for Release { + fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { + self.version.partial_cmp(&other.version) + } +} + /// A full rollout includes all users fn complete_rollout() -> f32 { 1. @@ -82,6 +101,7 @@ fn is_complete_rollout(b: impl std::borrow::Borrow<f32>) -> bool { /// App installer #[derive(Debug, Deserialize, Serialize, Clone)] +#[cfg_attr(test, derive(PartialEq))] pub struct Installer { /// Installer architecture pub architecture: Architecture, @@ -114,6 +134,7 @@ impl Display for Architecture { /// JSON response signature #[derive(Debug, Deserialize, Serialize)] +#[cfg_attr(test, derive(PartialEq))] #[serde(tag = "keytype")] #[serde(rename_all = "lowercase")] pub enum ResponseSignature { diff --git a/mullvad-update/src/version.rs b/mullvad-update/src/version.rs index 4b94e66b42..1b3a861784 100644 --- a/mullvad-update/src/version.rs +++ b/mullvad-update/src/version.rs @@ -7,9 +7,10 @@ use std::cmp::Ordering; use anyhow::Context; +use itertools::Itertools; use mullvad_version::PreStableType; -use crate::format; +use crate::format::{self, Installer}; /// Query type for [VersionInfo] #[derive(Debug)] @@ -77,71 +78,57 @@ impl VersionInfo { 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}"); + // Check this before anything else so that it's rejected independently of `params`. + if !response.releases.iter().map(|r| &r.version).all_unique() { + anyhow::bail!("API response contains multiple release for the same 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, - } + // Filter releases based on rollout, architecture and dev versions. + let available_versions: Vec<Version> = response.releases + .into_iter() + // Filter out releases that are not rolled out to us + .filter(|release| release.rollout >= params.rollout) + // Filter out dev versions + .filter(|release| !release.version.is_dev()) + .flat_map(|format::Release { version, changelog, installers, .. }| { + installers + .into_iter() + // Find installer for the requested architecture (assumed to be unique) + .find(|installer| params.architecture == installer.architecture) + // Map each artifact to a [IntermediateVersion] + .map(|Installer { urls, size, sha256,.. }| { + anyhow::Ok(Version { + version, + size, + urls, + changelog, + sha256: hex::decode(sha256) + .context("Invalid checksum hex")? + .try_into() + .map_err(|_| anyhow::anyhow!("Invalid checksum length"))?, }) - }) - .collect(); + }) + }).try_collect()?; // Find latest stable version - let stable = releases + let stable = available_versions .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"); - }; + .filter(|release| release.version.pre_stable.is_none()) + .max_by(|a, b| a.version.partial_cmp(&b.version).unwrap_or(Ordering::Equal)) + .context("No stable version found")? + .clone(); // Find the latest beta version - let beta = releases + let beta = available_versions .iter() - // Find most recent beta version - .rfind(|release| matches!(release.version.pre_stable, Some(PreStableType::Beta(_))) && !release.version.is_dev()) + .filter(|release| matches!(release.version.pre_stable, Some(PreStableType::Beta(_)))) // If the latest beta version is older than latest stable, dispose of it .filter(|release| release.version > stable.version) + .max_by(|a, b| a.version.partial_cmp(&b.version).unwrap_or(Ordering::Equal)) .cloned(); - Ok(Self { - stable: Version::try_from(stable)?, - beta: beta.map(Version::try_from).transpose()?, - }) - } - - /// Returns the first duplicated version found in `releases`. - /// `None` is returned if there are no duplicates. - /// NOTE: `releases` MUST be sorted on the version number - 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) + Ok(Self { stable, beta }) } } diff --git a/mullvad-version/src/lib.rs b/mullvad-version/src/lib.rs index 6dd411f1be..f97a730422 100644 --- a/mullvad-version/src/lib.rs +++ b/mullvad-version/src/lib.rs @@ -8,7 +8,7 @@ use regex_lite::Regex; /// The Mullvad VPN app product version pub const VERSION: &str = include_str!(concat!(env!("OUT_DIR"), "/product-version.txt")); -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Version { pub year: u32, pub incremental: u32, @@ -18,7 +18,7 @@ pub struct Version { pub dev: Option<String>, } -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq, Hash)] pub enum PreStableType { Alpha(u32), Beta(u32), |
