summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDavid Lönnhager <david.l@mullvad.net>2020-11-23 18:41:36 +0100
committerDavid Lönnhager <david.l@mullvad.net>2020-11-23 18:41:36 +0100
commitf6cc8319c978ef4a2caf23c626e39ebcdb21529f (patch)
tree0960bd63af11c93f3ebb09da2c9207a3f6bfa8a1
parent79f67878b68592843720234e814f469d06e1d779 (diff)
parent63f258cf70a8ded3bbf8b8427931d1cbf71c133f (diff)
downloadmullvadvpn-f6cc8319c978ef4a2caf23c626e39ebcdb21529f.tar.xz
mullvadvpn-f6cc8319c978ef4a2caf23c626e39ebcdb21529f.zip
Merge branch 'fix-downgrade'
-rw-r--r--CHANGELOG.md3
-rw-r--r--Cargo.lock2
-rw-r--r--dist-assets/windows/installer.nsh45
-rw-r--r--mullvad-daemon/src/version_check.rs132
-rw-r--r--mullvad-setup/Cargo.toml2
-rw-r--r--mullvad-setup/src/main.rs52
-rw-r--r--mullvad-types/src/version.rs173
7 files changed, 282 insertions, 127 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index acf0b093a6..96beef42f8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -54,6 +54,9 @@ Line wrap the file at 100 chars. Th
- Try to connect even if VPN permission is denied, so that the app shows an error message saying
that the VPN permission was denied.
+#### Windows
+- Fully uninstall the app when it is downgraded. Traffic is not blocked.
+
#### Linux
- Make route monitor ignore loopback routes.
- Increase NetworkManager device readiness timeout to 15 seconds.
diff --git a/Cargo.lock b/Cargo.lock
index a602a316f2..ae329be543 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1404,10 +1404,12 @@ dependencies = [
"clap",
"env_logger",
"err-derive",
+ "lazy_static",
"mullvad-daemon",
"mullvad-management-interface",
"mullvad-paths",
"mullvad-rpc",
+ "mullvad-types",
"talpid-core",
"talpid-types",
"tokio",
diff --git a/dist-assets/windows/installer.nsh b/dist-assets/windows/installer.nsh
index 93f62f9703..80c4ba998b 100644
--- a/dist-assets/windows/installer.nsh
+++ b/dist-assets/windows/installer.nsh
@@ -35,6 +35,11 @@
!define ERROR_SERVICE_MARKED_FOR_DELETE 1072
!define ERROR_SERVICE_DEPENDENCY_DELETED 1075
+# mullvad-setup status codes
+!define MVSETUP_OK 0
+!define MVSETUP_ERROR 1
+!define MVSETUP_VERSION_NOT_OLDER 2
+
# Override electron-builder generated application settings key.
# electron-builder uses a GUID here rather than the application name.
!define INSTALL_REGISTRY_KEY "Software\${PRODUCT_NAME}"
@@ -704,6 +709,8 @@
Pop $0
Pop $0
+ WriteRegStr HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\${APP_GUID}" "NewVersion" "${VERSION}"
+
Pop $0
!macroend
@@ -821,23 +828,46 @@
# Check command line arguments
Var /GLOBAL FullUninstall
+ Var /GLOBAL Silent
+ Var /GLOBAL NewVersion
${GetParameters} $0
${GetOptions} $0 "/S" $1
${If} ${Errors}
- Push 1
+ Push 0
log::Initialize ${LOG_VOID}
${Else}
- Push 0
+ Push 1
log::Initialize ${LOG_FILE}
${EndIf}
- Pop $FullUninstall
+
+ Pop $Silent
log::Log "Running uninstaller for ${PRODUCT_NAME} ${VERSION}"
${ExtractMullvadSetup}
- ${If} $FullUninstall != 1
+ ${If} $Silent == 1
+ ReadRegStr $NewVersion HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\${APP_GUID}" "NewVersion"
+
+ nsExec::ExecToStack '"$TEMP\mullvad-setup.exe" is-older-version $0'
+ Pop $0
+ Pop $1
+
+ ${If} $0 == ${MVSETUP_OK}
+ ${OrIf} $NewVersion == ""
+ log::Log "Downgrading. Performing a full uninstall"
+ Push 1
+ ${Else}
+ Push 0
+ ${EndIf}
+ ${Else}
+ Push 1
+ ${EndIf}
+
+ Pop $FullUninstall
+
+ ${If} $FullUninstall == 0
# Save the target tunnel state if we're upgrading
nsExec::ExecToStack '"$TEMP\mullvad-setup.exe" prepare-restart'
Pop $0
@@ -864,7 +894,6 @@
${ExtractTapDriver}
${RemoveBrandedTap}
- # If not ran silently
${If} $FullUninstall == 1
${ClearFirewallRules}
${ClearAccountHistory}
@@ -874,8 +903,10 @@
${RemoveWintun}
${RemoveLogsAndCache}
- MessageBox MB_ICONQUESTION|MB_YESNO "Would you like to remove settings files as well?" IDNO customRemoveFiles_after_remove_settings
- ${RemoveSettings}
+ ${If} $Silent != 1
+ MessageBox MB_ICONQUESTION|MB_YESNO "Would you like to remove settings files as well?" IDNO customRemoveFiles_after_remove_settings
+ ${RemoveSettings}
+ ${EndIf}
customRemoveFiles_after_remove_settings:
${EndIf}
diff --git a/mullvad-daemon/src/version_check.rs b/mullvad-daemon/src/version_check.rs
index 183082f457..d0895671ba 100644
--- a/mullvad-daemon/src/version_check.rs
+++ b/mullvad-daemon/src/version_check.rs
@@ -4,11 +4,9 @@ use crate::{
};
use futures::{channel::mpsc, stream::FusedStream, FutureExt, SinkExt, StreamExt, TryFutureExt};
use mullvad_rpc::{rest::MullvadRestHandle, AppVersionProxy};
-use mullvad_types::version::AppVersionInfo;
-use regex::Regex;
+use mullvad_types::version::{AppVersionInfo, ParsedAppVersion};
use serde::{Deserialize, Serialize};
use std::{
- cmp::{Ord, Ordering, PartialOrd},
fs,
future::Future,
io,
@@ -22,10 +20,8 @@ use tokio::fs::File;
const VERSION_INFO_FILENAME: &str = "version-info.json";
lazy_static::lazy_static! {
- static ref STABLE_REGEX: Regex = Regex::new(r"^(\d{4})\.(\d+)$").unwrap();
- static ref BETA_REGEX: Regex = Regex::new(r"^(\d{4})\.(\d+)-beta(\d+)$").unwrap();
- static ref APP_VERSION: Option<AppVersion> = AppVersion::from_str(PRODUCT_VERSION);
- static ref IS_DEV_BUILD: bool = APP_VERSION.is_none();
+ static ref APP_VERSION: ParsedAppVersion = ParsedAppVersion::from_str(PRODUCT_VERSION).unwrap();
+ static ref IS_DEV_BUILD: bool = APP_VERSION.is_dev();
}
const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(15);
@@ -175,13 +171,15 @@ impl VersionUpdater {
&mut self,
response: mullvad_rpc::AppVersionResponse,
) -> AppVersionInfo {
- let suggested_upgrade = APP_VERSION.and_then(|current_version| {
+ let suggested_upgrade = if !*IS_DEV_BUILD {
Self::suggested_upgrade(
- &current_version,
+ &*APP_VERSION,
&response,
self.show_beta_releases || is_beta_version(),
)
- });
+ } else {
+ None
+ };
AppVersionInfo {
supported: response.supported,
@@ -192,17 +190,17 @@ impl VersionUpdater {
}
fn suggested_upgrade(
- current_version: &AppVersion,
+ current_version: &ParsedAppVersion,
response: &mullvad_rpc::AppVersionResponse,
show_beta: bool,
) -> Option<String> {
let stable_version = response
.latest_stable
.as_ref()
- .and_then(|stable| AppVersion::from_str(stable));
+ .and_then(|stable| ParsedAppVersion::from_str(stable));
let beta_version = if show_beta {
- AppVersion::from_str(&response.latest_beta)
+ ParsedAppVersion::from_str(&response.latest_beta)
} else {
None
};
@@ -322,107 +320,11 @@ pub fn load_cache(cache_dir: &Path) -> AppVersionInfo {
}
}
-#[derive(Eq, PartialEq, Debug, Copy, Clone)]
-enum AppVersion {
- Stable(u32, u32),
- Beta(u32, u32, u32),
-}
-
-impl AppVersion {
- fn from_str(version: &str) -> Option<Self> {
- let get_int = |cap: &regex::Captures<'_>, idx| cap.get(idx)?.as_str().parse().ok();
-
- if let Some(caps) = STABLE_REGEX.captures(version) {
- let year = get_int(&caps, 1)?;
- let version = get_int(&caps, 2)?;
- Some(Self::Stable(year, version))
- } else if let Some(caps) = BETA_REGEX.captures(version) {
- let year = get_int(&caps, 1)?;
- let version = get_int(&caps, 2)?;
- let beta_version = get_int(&caps, 3)?;
- Some(Self::Beta(year, version, beta_version))
- } else {
- None
- }
- }
-}
-
-impl Ord for AppVersion {
- fn cmp(&self, other: &Self) -> Ordering {
- use AppVersion::*;
- match (self, other) {
- (Stable(year, version), Stable(other_year, other_version)) => {
- year.cmp(other_year).then(version.cmp(other_version))
- }
- // A stable version of the same year and version is always greater than a beta
- (Stable(year, version), Beta(other_year, other_version, _)) => year
- .cmp(other_year)
- .then(version.cmp(other_version))
- .then(Ordering::Greater),
- (
- Beta(year, version, beta_version),
- Beta(other_year, other_version, other_beta_version),
- ) => year
- .cmp(other_year)
- .then(version.cmp(other_version))
- .then(beta_version.cmp(other_beta_version)),
- (Beta(year, version, _beta_version), Stable(other_year, other_version)) => year
- .cmp(other_year)
- .then(version.cmp(other_version))
- .then(Ordering::Less),
- }
- }
-}
-
-impl PartialOrd for AppVersion {
- fn partial_cmp(&self, other: &AppVersion) -> Option<Ordering> {
- Some(self.cmp(other))
- }
-}
-
-impl ToString for AppVersion {
- fn to_string(&self) -> String {
- match self {
- Self::Stable(year, version) => format!("{}.{}", year, version),
- Self::Beta(year, version, beta_version) => {
- format!("{}.{}-beta{}", year, version, beta_version)
- }
- }
- }
-}
-
#[cfg(test)]
mod test {
use super::*;
#[test]
- fn test_version_regex() {
- assert!(STABLE_REGEX.is_match("2020.4"));
- assert!(!STABLE_REGEX.is_match("2020.4-beta3"));
- assert!(BETA_REGEX.is_match("2020.4-beta3"));
- assert!(!STABLE_REGEX.is_match("2020.5-beta1-dev-f16be4"));
- assert!(!STABLE_REGEX.is_match("2020.5-dev-f16be4"));
- assert!(!BETA_REGEX.is_match("2020.5-beta1-dev-f16be4"));
- assert!(!BETA_REGEX.is_match("2020.5-dev-f16be4"));
- assert!(!BETA_REGEX.is_match("2020.4"));
- }
-
- #[test]
- fn test_version_parsing() {
- let tests = vec![
- ("2020.4", Some(AppVersion::Stable(2020, 4))),
- ("2020.4-beta3", Some(AppVersion::Beta(2020, 4, 3))),
- ("2020.15-beta1-dev-f16be4", None),
- ("2020.15-dev-f16be4", None),
- ("", None),
- ];
-
- for (input, expected_output) in tests {
- assert_eq!(AppVersion::from_str(&input), expected_output,);
- }
- }
-
- #[test]
fn test_version_upgrade_suggestions() {
let app_version_info = mullvad_rpc::AppVersionResponse {
supported: true,
@@ -431,13 +333,13 @@ mod test {
latest_beta: "2020.5-beta3".to_string(),
};
- let older_stable = AppVersion::from_str("2020.3").unwrap();
- let current_stable = AppVersion::from_str("2020.4").unwrap();
- let newer_stable = AppVersion::from_str("2021.5").unwrap();
+ let older_stable = ParsedAppVersion::from_str("2020.3").unwrap();
+ let current_stable = ParsedAppVersion::from_str("2020.4").unwrap();
+ let newer_stable = ParsedAppVersion::from_str("2021.5").unwrap();
- let older_beta = AppVersion::from_str("2020.3-beta3").unwrap();
- let current_beta = AppVersion::from_str("2020.5-beta3").unwrap();
- let newer_beta = AppVersion::from_str("2021.5-beta3").unwrap();
+ let older_beta = ParsedAppVersion::from_str("2020.3-beta3").unwrap();
+ let current_beta = ParsedAppVersion::from_str("2020.5-beta3").unwrap();
+ let newer_beta = ParsedAppVersion::from_str("2021.5-beta3").unwrap();
assert_eq!(
VersionUpdater::suggested_upgrade(&older_stable, &app_version_info, false),
diff --git a/mullvad-setup/Cargo.toml b/mullvad-setup/Cargo.toml
index 5f509b65d6..aa816e47c6 100644
--- a/mullvad-setup/Cargo.toml
+++ b/mullvad-setup/Cargo.toml
@@ -15,6 +15,7 @@ path = "src/main.rs"
clap = "2.32"
env_logger = "0.7"
err-derive = "0.2.1"
+lazy_static = "1.1.0"
mullvad-management-interface = { path = "../mullvad-management-interface" }
@@ -23,6 +24,7 @@ tokio = { version = "0.2", features = [ "rt-threaded" ] }
mullvad-daemon = { path = "../mullvad-daemon" }
mullvad-paths = { path = "../mullvad-paths" }
mullvad-rpc = { path = "../mullvad-rpc" }
+mullvad-types = { path = "../mullvad-types" }
talpid-core = { path = "../talpid-core" }
talpid-types = { path = "../talpid-types" }
diff --git a/mullvad-setup/src/main.rs b/mullvad-setup/src/main.rs
index 396b86fa2b..f03ddb352c 100644
--- a/mullvad-setup/src/main.rs
+++ b/mullvad-setup/src/main.rs
@@ -2,12 +2,25 @@ use clap::{crate_authors, crate_description, crate_name, SubCommand};
use mullvad_daemon::account_history;
use mullvad_management_interface::new_rpc_client;
use mullvad_rpc::MullvadRpcRuntime;
+use mullvad_types::version::ParsedAppVersion;
use std::{path::PathBuf, process};
use talpid_core::firewall::{self, Firewall, FirewallArguments};
use talpid_types::ErrorExt;
pub const PRODUCT_VERSION: &str = include_str!(concat!(env!("OUT_DIR"), "/product-version.txt"));
+lazy_static::lazy_static! {
+ static ref APP_VERSION: ParsedAppVersion = ParsedAppVersion::from_str(PRODUCT_VERSION).unwrap();
+ static ref IS_DEV_BUILD: bool = APP_VERSION.is_dev();
+}
+
+#[repr(i32)]
+enum ExitStatus {
+ Ok = 0,
+ Error = 1,
+ VersionNotOlder = 2,
+}
+
#[cfg(windows)]
mod daemon_paths;
@@ -46,6 +59,9 @@ pub enum Error {
#[error(display = "Failed to initialize account history")]
ClearAccountHistoryError(#[error(source)] account_history::Error),
+
+ #[error(display = "Cannot parse the version string")]
+ ParseVersionStringError,
}
#[tokio::main]
@@ -58,6 +74,13 @@ async fn main() {
SubCommand::with_name("reset-firewall")
.about("Remove any firewall rules introduced by the daemon"),
SubCommand::with_name("clear-history").about("Clear account history"),
+ SubCommand::with_name("is-older-version")
+ .about("Checks whether the given version is older than the current version")
+ .arg(
+ clap::Arg::with_name("OLDVERSION")
+ .required(true)
+ .help("Version string to compare the current version"),
+ ),
];
let app = clap::App::new(crate_name!())
@@ -72,19 +95,38 @@ async fn main() {
.subcommands(subcommands);
let matches = app.get_matches();
- let result = match matches.subcommand_name().expect("Subcommand has no name") {
- "prepare-restart" => prepare_restart().await,
- "reset-firewall" => reset_firewall().await,
- "clear-history" => clear_history().await,
+ let result = match matches.subcommand() {
+ ("prepare-restart", _) => prepare_restart().await,
+ ("reset-firewall", _) => reset_firewall().await,
+ ("clear-history", _) => clear_history().await,
+ ("is-older-version", Some(sub_matches)) => {
+ let old_version = sub_matches.value_of("OLDVERSION").unwrap();
+ match is_older_version(old_version).await {
+ // Returning exit status
+ Ok(status) => process::exit(status as i32),
+ Err(error) => Err(error),
+ }
+ }
_ => unreachable!("No command matched"),
};
if let Err(e) = result {
eprintln!("{}", e.display_chain());
- process::exit(1);
+ process::exit(ExitStatus::Error as i32);
}
}
+async fn is_older_version(old_version: &str) -> Result<ExitStatus, Error> {
+ let parsed_version =
+ ParsedAppVersion::from_str(old_version).ok_or(Error::ParseVersionStringError)?;
+
+ Ok(if parsed_version < *APP_VERSION {
+ ExitStatus::Ok
+ } else {
+ ExitStatus::VersionNotOlder
+ })
+}
+
async fn prepare_restart() -> Result<(), Error> {
let mut rpc = new_rpc_client().await.map_err(Error::RpcConnectionError)?;
rpc.prepare_restart(())
diff --git a/mullvad-types/src/version.rs b/mullvad-types/src/version.rs
index 47f26f2de0..5bbe10618c 100644
--- a/mullvad-types/src/version.rs
+++ b/mullvad-types/src/version.rs
@@ -1,6 +1,15 @@
#[cfg(target_os = "android")]
use jnix::IntoJava;
+use regex::Regex;
use serde::{Deserialize, Serialize};
+use std::cmp::{Ord, Ordering, PartialOrd};
+
+lazy_static::lazy_static! {
+ static ref STABLE_REGEX: Regex = Regex::new(r"^(\d{4})\.(\d+)$").unwrap();
+ static ref BETA_REGEX: Regex = Regex::new(r"^(\d{4})\.(\d+)-beta(\d+)$").unwrap();
+ static ref DEV_REGEX: Regex = Regex::new(r"^(\d{4})\.(\d+)(\.\d+)?(-beta(\d+))?-dev-(\w+)$").unwrap();
+}
+
/// AppVersionInfo represents the current stable and the current latest release versions of the
/// Mullvad VPN app.
@@ -27,3 +36,167 @@ pub struct AppVersionInfo {
}
pub type AppVersion = String;
+
+
+/// Parses a version string into a type that can be used for comparisons.
+#[derive(Eq, PartialEq, Debug, Clone)]
+pub enum ParsedAppVersion {
+ Stable(u32, u32),
+ Beta(u32, u32, u32),
+ Dev(u32, u32, Option<u32>, String),
+}
+
+impl ParsedAppVersion {
+ pub fn from_str(version: &str) -> Option<Self> {
+ let get_int = |cap: &regex::Captures<'_>, idx| cap.get(idx)?.as_str().parse().ok();
+
+ if let Some(caps) = STABLE_REGEX.captures(version) {
+ let year = get_int(&caps, 1)?;
+ let version = get_int(&caps, 2)?;
+ Some(Self::Stable(year, version))
+ } else if let Some(caps) = BETA_REGEX.captures(version) {
+ let year = get_int(&caps, 1)?;
+ let version = get_int(&caps, 2)?;
+ let beta_version = get_int(&caps, 3)?;
+ Some(Self::Beta(year, version, beta_version))
+ } else if let Some(caps) = DEV_REGEX.captures(version) {
+ let year = get_int(&caps, 1)?;
+ let version = get_int(&caps, 2)?;
+ let beta_version = caps.get(4).map(|_| get_int(&caps, 5).unwrap());
+ let dev_hash = caps.get(6)?.as_str().to_string();
+ Some(Self::Dev(year, version, beta_version, dev_hash))
+ } else {
+ None
+ }
+ }
+
+ pub fn is_dev(&self) -> bool {
+ match self {
+ ParsedAppVersion::Dev(..) => true,
+ _ => false,
+ }
+ }
+}
+
+impl Ord for ParsedAppVersion {
+ fn cmp(&self, other: &Self) -> Ordering {
+ use ParsedAppVersion::*;
+ match (self, other) {
+ (Stable(year, version), Stable(other_year, other_version)) => {
+ year.cmp(other_year).then(version.cmp(other_version))
+ }
+ // A stable version of the same year and version is always greater than a beta
+ (Stable(year, version), Beta(other_year, other_version, _)) => year
+ .cmp(other_year)
+ .then(version.cmp(other_version))
+ .then(Ordering::Greater),
+ // We assume that a dev version of the same year and version is newer
+ (Stable(year, version), Dev(other_year, other_version, ..)) => year
+ .cmp(other_year)
+ .then(version.cmp(other_version))
+ .then(Ordering::Less),
+
+ (
+ Beta(year, version, beta_version),
+ Beta(other_year, other_version, other_beta_version),
+ ) => year
+ .cmp(other_year)
+ .then(version.cmp(other_version))
+ .then(beta_version.cmp(other_beta_version)),
+ (Beta(year, version, _beta_version), Stable(other_year, other_version)) => year
+ .cmp(other_year)
+ .then(version.cmp(other_version))
+ .then(Ordering::Less),
+ // We assume that a dev version of the same year and version is newer
+ (Beta(year, version, _), Dev(other_year, other_version, ..)) => year
+ .cmp(other_year)
+ .then(version.cmp(other_version))
+ .then(Ordering::Less),
+
+ // Dev versions of the same year and version are assumed to be equal
+ (Dev(year, version, ..), Dev(other_year, other_version, ..)) => {
+ year.cmp(other_year).then(version.cmp(other_version))
+ }
+ (Dev(year, version, ..), Stable(other_year, other_version)) => year
+ .cmp(other_year)
+ .then(version.cmp(other_version))
+ .then(Ordering::Greater),
+ (Dev(year, version, ..), Beta(other_year, other_version, _)) => year
+ .cmp(other_year)
+ .then(version.cmp(other_version))
+ .then(Ordering::Greater),
+ }
+ }
+}
+
+impl PartialOrd for ParsedAppVersion {
+ fn partial_cmp(&self, other: &ParsedAppVersion) -> Option<Ordering> {
+ Some(self.cmp(other))
+ }
+}
+
+impl ToString for ParsedAppVersion {
+ fn to_string(&self) -> String {
+ match self {
+ Self::Stable(year, version) => format!("{}.{}", year, version),
+ Self::Beta(year, version, beta_version) => {
+ format!("{}.{}-beta{}", year, version, beta_version)
+ }
+ Self::Dev(year, version, beta_version, hash) => {
+ if let Some(beta_version) = beta_version {
+ format!("{}.{}-beta{}-dev-{}", year, version, beta_version, hash)
+ } else {
+ format!("{}.{}-dev-{}", year, version, hash)
+ }
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+
+ #[test]
+ fn test_version_regex() {
+ assert!(STABLE_REGEX.is_match("2020.4"));
+ assert!(!STABLE_REGEX.is_match("2020.4-beta3"));
+ assert!(BETA_REGEX.is_match("2020.4-beta3"));
+ assert!(!STABLE_REGEX.is_match("2020.5-beta1-dev-f16be4"));
+ assert!(!STABLE_REGEX.is_match("2020.5-dev-f16be4"));
+ assert!(!BETA_REGEX.is_match("2020.5-beta1-dev-f16be4"));
+ assert!(!BETA_REGEX.is_match("2020.5-dev-f16be4"));
+ assert!(!BETA_REGEX.is_match("2020.4"));
+ assert!(DEV_REGEX.is_match("2020.5-dev-f16be4"));
+ assert!(DEV_REGEX.is_match("2020.5-beta1-dev-f16be4"));
+ assert!(!DEV_REGEX.is_match("2020.5"));
+ assert!(!DEV_REGEX.is_match("2020.5-beta1"));
+ }
+
+ #[test]
+ fn test_version_parsing() {
+ let tests = vec![
+ ("2020.4", Some(ParsedAppVersion::Stable(2020, 4))),
+ ("2020.4-beta3", Some(ParsedAppVersion::Beta(2020, 4, 3))),
+ (
+ "2020.15-beta1-dev-f16be4",
+ Some(ParsedAppVersion::Dev(
+ 2020,
+ 15,
+ Some(1),
+ "f16be4".to_string(),
+ )),
+ ),
+ (
+ "2020.15-dev-f16be4",
+ Some(ParsedAppVersion::Dev(2020, 15, None, "f16be4".to_string())),
+ ),
+ ("2020.15-9000", None),
+ ("", None),
+ ];
+
+ for (input, expected_output) in tests {
+ assert_eq!(ParsedAppVersion::from_str(&input), expected_output,);
+ }
+ }
+}