From 690dcfd694388a40bf7ed8b0f8797942238f6f28 Mon Sep 17 00:00:00 2001 From: Lucas Fernandes Nogueira Date: Sat, 19 Sep 2026 20:57:51 -0300 Subject: [PATCH] Merge commit from fork * feat(updater): verify the version an update was signed for The endpoint response is fetched over TLS but is not signed, and the signature only covers the artifact, so a crafted response could pair an inflated version with an older release's url and signature to force a downgrade to a genuine but outdated build. Read the version back from the signature's trusted comment and reject an update whose announced version differs. Signatures carrying no version are only rejected under the new requireSignedVersion option, since older CLIs did not record one. * fix tests --- .changes/updater-require-signed-version.md | 23 ++ plugins/updater/src/config.rs | 22 ++ plugins/updater/src/error.rs | 16 + plugins/updater/src/updater.rs | 129 ++++++- .../updater/tests/app-updater/tests/update.rs | 351 ++++++++++++++++-- 5 files changed, 507 insertions(+), 34 deletions(-) create mode 100644 .changes/updater-require-signed-version.md diff --git a/.changes/updater-require-signed-version.md b/.changes/updater-require-signed-version.md new file mode 100644 index 000000000..be0e47d77 --- /dev/null +++ b/.changes/updater-require-signed-version.md @@ -0,0 +1,23 @@ +--- +"updater": minor +--- + +Add a `requireSignedVersion` configuration option that binds an update to the version it was signed for. + +The update endpoint response is fetched over TLS but is not itself signed, and the signature only covers the downloaded artifact. Anyone able to serve a crafted response could therefore pair an inflated `version` field with the `url` and `signature` of an older release and force a downgrade to a genuine but outdated build, because that older artifact carries a valid signature. + +The Tauri CLI records the version in the signature's trusted comment, which the signature covers. With this option enabled the updater compares that signed version against the one announced by the endpoint and rejects the update when they differ: + +```json +{ + "plugins": { + "updater": { + "requireSignedVersion": true + } + } +} +``` + +It defaults to `false` because releases signed before the CLI started recording the version carry none and would be rejected. Re-sign and re-publish every release your users can still update from before enabling it, otherwise an older signature can still be served to bypass the check. When the signature does carry a version, a mismatch is rejected whether or not the option is enabled. + +This is checked independently of `allowDowngrades`: it constrains which artifact a given version number may resolve to, not whether that version is newer than the running one. diff --git a/plugins/updater/src/config.rs b/plugins/updater/src/config.rs index 411956b9b..0fa97e8de 100644 --- a/plugins/updater/src/config.rs +++ b/plugins/updater/src/config.rs @@ -114,6 +114,25 @@ pub struct Config { pub endpoints: Vec, /// Signature public key. pub pubkey: String, + /// Require the update signature to carry the version it was signed for, and reject the + /// update when that version differs from the one announced by the update endpoint. + /// + /// The endpoint response is fetched over TLS but is not itself signed, and the signature + /// only covers the downloaded artifact. Without this flag, anyone able to serve a crafted + /// response can pair an inflated `version` field with the `url` and `signature` of an + /// older release and force a downgrade to a genuine but outdated build, since that older + /// artifact carries a valid signature. + /// + /// The signed version is read from the signature's trusted comment, which is covered by + /// the signature. Releases signed before the Tauri CLI started recording it carry no + /// version, so enabling this rejects them. Re-sign and re-publish every release your users + /// can still update from before turning this on. + /// + /// This is checked independently of the version comparison: it constrains which artifact a + /// given version number may resolve to, not whether that version is newer. + /// + /// The default value of this flag is `false`. + pub require_signed_version: bool, /// The Windows configuration for the updater. pub windows: Option, } @@ -135,6 +154,8 @@ impl<'de> Deserialize<'de> for Config { #[serde(default)] pub endpoints: Vec, pub pubkey: String, + #[serde(default, alias = "require-signed-version")] + pub require_signed_version: bool, pub windows: Option, } @@ -152,6 +173,7 @@ impl<'de> Deserialize<'de> for Config { dangerous_accept_invalid_hostnames: config.dangerous_accept_invalid_hostnames, endpoints: config.endpoints, pubkey: config.pubkey, + require_signed_version: config.require_signed_version, windows: config.windows, }) } diff --git a/plugins/updater/src/error.rs b/plugins/updater/src/error.rs index c44f98627..6621fe553 100644 --- a/plugins/updater/src/error.rs +++ b/plugins/updater/src/error.rs @@ -89,6 +89,22 @@ pub enum Error { /// The configured updater endpoint must use a secure protocol like `https` #[error("The configured updater endpoint must use a secure protocol like `https`.")] InsecureTransportProtocol, + /// The version the artifact was signed for does not match the version announced by the + /// update endpoint. + #[error( + "The update was signed for version {signed} but the update endpoint announced version {announced}. The endpoint response may have been tampered with to force installing a different release." + )] + SignedVersionMismatch { + /// The version read from the signature's trusted comment. + signed: String, + /// The version announced by the update endpoint. + announced: String, + }, + /// `requireSignedVersion` is enabled but the signature does not carry a version. + #[error( + "The update signature does not specify the version it was signed for, which `requireSignedVersion` requires. Re-sign and re-publish this release, or disable `requireSignedVersion`." + )] + MissingSignedVersion, #[error(transparent)] Tauri(#[from] tauri::Error), } diff --git a/plugins/updater/src/updater.rs b/plugins/updater/src/updater.rs index e877820a7..cf65df501 100644 --- a/plugins/updater/src/updater.rs +++ b/plugins/updater/src/updater.rs @@ -737,7 +737,13 @@ impl Update { } on_download_finish(); - verify_signature(&buffer, &self.signature, &self.context.config.pubkey)?; + verify_signature( + &buffer, + &self.signature, + &self.context.config.pubkey, + &self.version, + self.context.config.require_signed_version, + )?; Ok(buffer) } @@ -1521,7 +1527,13 @@ where } // Validate signature -fn verify_signature(data: &[u8], release_signature: &str, pub_key: &str) -> Result<()> { +fn verify_signature( + data: &[u8], + release_signature: &str, + pub_key: &str, + announced_version: &str, + require_signed_version: bool, +) -> Result<()> { // we need to convert the pub key let pub_key_decoded = base64_to_string(pub_key)?; let public_key = PublicKey::decode(&pub_key_decoded)?; @@ -1530,7 +1542,65 @@ fn verify_signature(data: &[u8], release_signature: &str, pub_key: &str) -> Resu // Validate signature or bail out public_key.verify(data, &signature, true)?; - Ok(()) + + // Only now is the trusted comment usable: minisign's global signature covers it, and + // `verify` above is what checks that global signature. Reading it before this point would + // be trusting attacker controlled data. + verify_signed_version( + signature.trusted_comment(), + announced_version, + require_signed_version, + ) +} + +/// Checks the version the artifact was signed for against the version the update endpoint +/// announced. +/// +/// The endpoint response is not signed, so its `version` field on its own does not prove which +/// release the `url` and `signature` actually point at. Comparing it against the signed version +/// is what stops a tampered response from pairing a new version number with an older release. +fn verify_signed_version( + trusted_comment: &str, + announced_version: &str, + require_signed_version: bool, +) -> Result<()> { + let Some(signed_version) = signed_version(trusted_comment) else { + // Signatures produced before the Tauri CLI started recording the version carry none, so + // this can only be enforced when the app opts in. Note that leaving it off means an + // attacker can bypass the check outright by serving one of those older signatures. + return if require_signed_version { + Err(Error::MissingSignedVersion) + } else { + Ok(()) + }; + }; + + // compare as semver so that equivalent spellings like `1.2.3` and `v1.2.3` match, falling + // back to a literal comparison for versions that are not valid semver + let matches = match ( + Version::from_str(signed_version.trim_start_matches('v')), + Version::from_str(announced_version.trim_start_matches('v')), + ) { + (Ok(signed), Ok(announced)) => signed == announced, + _ => signed_version == announced_version, + }; + + if matches { + Ok(()) + } else { + Err(Error::SignedVersionMismatch { + signed: signed_version.to_string(), + announced: announced_version.to_string(), + }) + } +} + +/// Reads the `version` field out of a signature's trusted comment, which the Tauri CLI writes as +/// tab separated `key:value` pairs, e.g. `timestamp:1700000000\tfile:app.tar.gz\tversion:1.2.3`. +fn signed_version(trusted_comment: &str) -> Option<&str> { + trusted_comment + .split('\t') + .find_map(|field| field.strip_prefix("version:")) } fn base64_to_string(base64_string: &str) -> Result { @@ -1636,6 +1706,59 @@ fn escape_msi_property_arg(arg: impl AsRef) -> String { #[cfg(test)] mod tests { + use super::{signed_version, verify_signed_version}; + use crate::error::Error; + + const CURRENT: &str = "timestamp:1700000000\tfile:app_1.2.3_x64.msi.zip\tversion:1.2.3"; + // signatures produced before the CLI started embedding the version + const LEGACY: &str = "timestamp:1600000000\tfile:app_1.0.0_x64.msi.zip"; + + #[test] + fn reads_the_signed_version() { + assert_eq!(signed_version(CURRENT), Some("1.2.3")); + assert_eq!(signed_version(LEGACY), None); + // must not match on a field that merely ends in `version:` + assert_eq!(signed_version("timestamp:1\tfile:app-version:2.zip"), None); + } + + #[test] + fn accepts_a_matching_version() { + assert!(verify_signed_version(CURRENT, "1.2.3", true).is_ok()); + assert!(verify_signed_version(CURRENT, "1.2.3", false).is_ok()); + // the endpoint and the CLI may spell the same version differently + assert!(verify_signed_version(CURRENT, "v1.2.3", true).is_ok()); + } + + #[test] + fn rejects_a_version_the_artifact_was_not_signed_for() { + // the rollback the flag exists to stop: an old artifact announced as a new version + let err = verify_signed_version(CURRENT, "9.9.9", false).unwrap_err(); + assert!( + matches!(err, Error::SignedVersionMismatch { ref signed, ref announced } + if signed == "1.2.3" && announced == "9.9.9"), + "unexpected error: {err}" + ); + // rejected regardless of whether the app opted in, since the signature does say + // which version it covers + assert!(verify_signed_version(CURRENT, "9.9.9", true).is_err()); + assert!(verify_signed_version(CURRENT, "1.2.4", true).is_err()); + } + + #[test] + fn only_requires_a_signed_version_when_configured() { + assert!(verify_signed_version(LEGACY, "9.9.9", false).is_ok()); + assert!(matches!( + verify_signed_version(LEGACY, "9.9.9", true).unwrap_err(), + Error::MissingSignedVersion + )); + } + + #[test] + fn compares_non_semver_versions_literally() { + let comment = "timestamp:1700000000\tfile:app.zip\tversion:2024-01-01"; + assert!(verify_signed_version(comment, "2024-01-01", true).is_ok()); + assert!(verify_signed_version(comment, "2024-01-02", true).is_err()); + } #[test] #[cfg(windows)] diff --git a/plugins/updater/tests/app-updater/tests/update.rs b/plugins/updater/tests/app-updater/tests/update.rs index acaee63c8..34020578d 100644 --- a/plugins/updater/tests/app-updater/tests/update.rs +++ b/plugins/updater/tests/app-updater/tests/update.rs @@ -9,12 +9,16 @@ use std::{ fs::File, path::{Path, PathBuf}, process::Command, - sync::Arc, + sync::{Arc, Mutex}, }; use serde::Serialize; use tauri::utils::config::{Updater, V1Compatible}; +/// Every test here drives `cargo tauri build` against the same crate and target directory, and +/// binds a fixed port, so they cannot overlap. +static BUILD_LOCK: Mutex<()> = Mutex::new(()); + const UPDATER_PRIVATE_KEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IHJzaWduIGVuY3J5cHRlZCBzZWNyZXQga2V5ClJXUlRZMEl5TlFOMFpXYzJFOUdjeHJEVXY4WE1TMUxGNDJVUjNrMmk1WlR3UVJVUWwva0FBQkFBQUFBQUFBQUFBQUlBQUFBQUpVK3ZkM3R3eWhyN3hiUXhQb2hvWFVzUW9FbEs3NlNWYjVkK1F2VGFRU1FEaGxuRUtlell5U0gxYS9DbVRrS0YyZVJGblhjeXJibmpZeGJjS0ZKSUYwYndYc2FCNXpHalM3MHcrODMwN3kwUG9SOWpFNVhCSUd6L0E4TGRUT096TEtLR1JwT1JEVFU9Cg=="; const UPDATED_EXIT_CODE: i32 = 0; const ERROR_EXIT_CODE: i32 = 1; @@ -24,6 +28,10 @@ const UP_TO_DATE_EXIT_CODE: i32 = 2; struct Config { version: &'static str, bundle: BundleConfig, + /// Merged into `plugins` of the app's `tauri.conf.json`. Only set by the signed version + /// tests, which need to flip `requireSignedVersion` and point at their own server. + #[serde(skip_serializing_if = "Option::is_none")] + plugins: Option, } #[derive(Serialize)] @@ -35,13 +43,13 @@ struct BundleConfig { #[derive(Serialize)] struct PlatformUpdate { signature: String, - url: &'static str, + url: String, with_elevated_task: bool, } #[derive(Serialize)] struct Update { - version: &'static str, + version: String, date: String, platforms: HashMap, } @@ -56,6 +64,12 @@ fn build_app(cwd: &Path, config: &Config, target: Option) { .env("TAURI_SIGNING_PRIVATE_KEY_PASSWORD", "") .current_dir(cwd); + // linuxdeploy ships a `strip` that does not know the `.relr.dyn` sections recent distros emit, + // and it aborts the whole bundle when a strip call fails. Nothing here benefits from a smaller + // AppImage, so skip it. + #[cfg(target_os = "linux")] + command.env("NO_STRIP", "1"); + if let Some(target) = target { command.arg("--bundles").arg(target.name()); } else { @@ -107,6 +121,61 @@ impl Default for BundleTarget { } } +/// Copies a bundle out of the bundler output directory, returning the path to run. +/// +/// Every bundler wipes its output before writing — the AppImage one removes `bundle/appimage` +/// entirely, the macOS one the `.app` — so the app built for the initial version only survives +/// until the next `build_app` call. On Linux and macOS the update is also installed over the very +/// bundle being run, which would otherwise leave a 1.0.0 app behind in the bundler output. +/// +/// Windows drives the executable `cargo build` leaves in `target/release`, which no bundler +/// touches, so there is nothing to copy there. +fn stage_app_under_test(root_dir: &Path, target: &str) -> PathBuf { + #[cfg(windows)] + { + let _ = target; + return root_dir.join("target/release/app-updater.exe"); + } + + #[cfg(not(windows))] + { + let bundle_path = test_cases(root_dir, "0.1.0", target.to_string()) + .first() + .unwrap() + .1 + .clone(); + + let staging_dir = root_dir.join("target/release/app-under-test"); + let _ = std::fs::remove_dir_all(&staging_dir); + std::fs::create_dir_all(&staging_dir).expect("failed to create the staging directory"); + + let staged = staging_dir.join(bundle_path.file_name().unwrap()); + copy_recursively(&bundle_path, &staged).unwrap_or_else(|e| { + panic!( + "failed to copy {} to {}: {e}", + bundle_path.display(), + staged.display() + ) + }); + + return staged; + } +} + +/// Copies a file, or a directory such as a macOS `.app`, preserving permissions. +fn copy_recursively(from: &Path, to: &Path) -> std::io::Result<()> { + if from.is_dir() { + std::fs::create_dir_all(to)?; + for entry in std::fs::read_dir(from)? { + let entry = entry?; + copy_recursively(&entry.path(), &to.join(entry.file_name()))?; + } + Ok(()) + } else { + std::fs::copy(from, to).map(|_| ()) + } +} + fn target_to_platforms( update_platform: Option, signature: String, @@ -117,7 +186,7 @@ fn target_to_platforms( platform, PlatformUpdate { signature, - url: "http://localhost:3007/download", + url: "http://localhost:3007/download".into(), with_elevated_task: false, }, ); @@ -270,6 +339,8 @@ fn test_cases( #[test] fn update_app() { + let _lock = BUILD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let target = tauri_plugin_updater::target().expect("running updater test in an unsupported platform"); let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); @@ -281,12 +352,14 @@ fn update_app() { bundle: BundleConfig { create_updater_artifacts: Updater::Bool(true), }, + plugins: None, }, Config { version: "1.0.0", bundle: BundleConfig { create_updater_artifacts: Updater::String(V1Compatible::V1Compatible), }, + plugins: None, }, ] { let v1_compatible = matches!( @@ -359,7 +432,7 @@ fn update_app() { target_to_platforms(update_platform.clone(), signature.clone()); let body = serde_json::to_vec(&Update { - version: "1.0.0", + version: "1.0.0".into(), date: time::OffsetDateTime::now_utc() .format(&time::format_description::well_known::Rfc3339) .unwrap(), @@ -393,41 +466,38 @@ fn update_app() { config.version = "0.1.0"; - // bundle initial app version - build_app(&manifest_dir, &config, None); + // bundle initial app version; Linux and macOS run the bundle itself, so it cannot be + // skipped there + build_app( + &manifest_dir, + &config, + if cfg!(windows) { + None + } else { + Some(bundle_target) + }, + ); + + let app_path = stage_app_under_test(&root_dir, &target); for expected_exit_code in status_checks { - let mut binary_cmd = if cfg!(windows) { - Command::new(root_dir.join("target/release/app-updater.exe")) - } else if cfg!(target_os = "macos") { - Command::new( - test_cases(&root_dir, "0.1.0", target.clone()) - .first() - .unwrap() - .1 - .join("Contents/MacOS/app-updater"), - ) - } else if std::env::var("CI").map(|v| v == "true").unwrap_or_default() { + let mut binary_cmd = if cfg!(target_os = "macos") { + Command::new(app_path.join("Contents/MacOS/app-updater")) + } else if cfg!(target_os = "linux") + && std::env::var("CI").map(|v| v == "true").unwrap_or_default() + { let mut c = Command::new("xvfb-run"); - c.arg("--auto-servernum").arg( - &test_cases(&root_dir, "0.1.0", target.clone()) - .first() - .unwrap() - .1, - ); + c.arg("--auto-servernum").arg(&app_path); c } else { - Command::new( - &test_cases(&root_dir, "0.1.0", target.clone()) - .first() - .unwrap() - .1, - ) + Command::new(&app_path) }; binary_cmd.env("TARGET", bundle_target.name()); - let status = binary_cmd.status().expect("failed to run app"); + let status = binary_cmd + .status() + .unwrap_or_else(|e| panic!("failed to run {}: {e}", app_path.display())); let code = status.code().unwrap_or(-1); if code != expected_exit_code { @@ -447,3 +517,222 @@ fn update_app() { } } } + +const SIGNED_VERSION_PORT: u16 = 3008; +/// Fragment of `Error::SignedVersionMismatch`. The app prints the updater error before exiting, +/// and a rejected update exits with the same code as a failed install, so the message is what +/// tells them apart. +const MISMATCH_ERROR: &str = "was signed for version"; + +struct ServedUpdate { + version: String, +} + +fn app_binary(root_dir: &Path) -> PathBuf { + root_dir.join(if cfg!(windows) { + "target/release/app-updater.exe" + } else { + "target/release/app-updater" + }) +} + +/// Runs the app against the update server, restoring the binary from `pristine` first. +/// +/// A case that clears the version check goes on to actually install, and on Linux installing means +/// writing the downloaded bytes over the running executable. Every case therefore has to start +/// from an untouched binary, or the first one that passes leaves the update behind as the app. +fn run_app(root_dir: &Path, pristine: &Path) -> String { + let binary = app_binary(root_dir); + std::fs::copy(pristine, &binary).expect("failed to restore the app binary"); + + let mut command = if cfg!(target_os = "linux") + && std::env::var("CI").map(|v| v == "true").unwrap_or_default() + { + let mut c = Command::new("xvfb-run"); + c.arg("--auto-servernum").arg(&binary); + c + } else { + Command::new(&binary) + }; + + let output = command.output().expect("failed to run app"); + String::from_utf8_lossy(&output.stdout).into_owned() +} + +/// The update endpoint response is not signed, so its `version` field alone does not prove which +/// release the `url` and `signature` point at. This bundles a genuine 1.0.0 release and then varies +/// only the version the endpoint announces for it, which is the shape of a forced downgrade: a +/// tampered response pairing a new version number with an older release's signature. +/// +/// The check runs in `Update::download`, before anything is installed, so a rejected case never +/// reaches the installer while an accepted one does. Both exit non-zero, which is why these assert +/// on the error message rather than the exit code. +#[test] +fn update_validates_signed_version() { + let _lock = BUILD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + + let target = + tauri_plugin_updater::target().expect("running updater test in an unsupported platform"); + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let root_dir = manifest_dir.join("../../../.."); + + // bundle the release the endpoint will point at; the CLI records 1.0.0 in its signature + let bundle_target = BundleTarget::default(); + build_app( + &manifest_dir, + &Config { + version: "1.0.0", + bundle: BundleConfig { + create_updater_artifacts: Updater::Bool(true), + }, + plugins: None, + }, + Some(bundle_target), + ); + + let out_bundle_path = test_cases(&root_dir, "1.0.0", target.clone()) + .first() + .unwrap() + .1 + .clone(); + let updater_extension = { + let bundle_ext = out_bundle_path + .extension() + .unwrap() + .to_str() + .unwrap() + .to_string(); + if cfg!(target_os = "macos") { + format!("{bundle_ext}.tar.gz") + } else { + bundle_ext + } + }; + let signature = + std::fs::read_to_string(out_bundle_path.with_extension(format!("{updater_extension}.sig"))) + .expect("failed to read signature file"); + + // move it aside so building the running app cannot clobber it + let out_updater_path = out_bundle_path.with_extension(&updater_extension); + let updater_path = root_dir.join(format!( + "target/release/{}", + out_updater_path.file_name().unwrap().to_str().unwrap() + )); + std::fs::rename(&out_updater_path, &updater_path).expect("failed to rename bundle"); + + let served = Arc::new(Mutex::new(ServedUpdate { + version: "1.0.0".into(), + })); + + let server = Arc::new( + tiny_http::Server::http(format!("localhost:{SIGNED_VERSION_PORT}")) + .expect("failed to start updater server"), + ); + + let server_ = server.clone(); + let served_ = served.clone(); + let target_ = target.clone(); + let updater_path_ = updater_path.clone(); + std::thread::spawn(move || { + for request in server_.incoming_requests() { + match request.url() { + "/" => { + let mut platforms = HashMap::new(); + platforms.insert( + target_.clone(), + PlatformUpdate { + // always the genuine 1.0.0 signature; only the version above it moves + signature: signature.clone(), + url: format!("http://localhost:{SIGNED_VERSION_PORT}/download"), + with_elevated_task: false, + }, + ); + + let body = serde_json::to_vec(&Update { + version: served_.lock().unwrap().version.clone(), + date: time::OffsetDateTime::now_utc() + .format(&time::format_description::well_known::Rfc3339) + .unwrap(), + platforms, + }) + .unwrap(); + let len = body.len(); + let _ = request.respond(tiny_http::Response::new( + tiny_http::StatusCode(200), + Vec::new(), + std::io::Cursor::new(body), + Some(len), + None, + )); + } + "/download" => { + let _ = request.respond(tiny_http::Response::from_file( + File::open(&updater_path_).unwrap_or_else(|_| { + panic!("failed to open updater bundle {}", updater_path_.display()) + }), + )); + } + _ => (), + } + } + }); + + // `requireSignedVersion` is baked in by `generate_context!`, so each value needs its own build + let pristine = root_dir.join("target/release/app-updater.pristine"); + let build = |require_signed_version: bool| { + build_app( + &manifest_dir, + &Config { + version: "0.1.0", + bundle: BundleConfig { + create_updater_artifacts: Updater::Bool(true), + }, + plugins: Some(serde_json::json!({ + "updater": { + "endpoints": [format!("http://localhost:{SIGNED_VERSION_PORT}")], + "requireSignedVersion": require_signed_version, + } + })), + }, + None, + ); + std::fs::copy(app_binary(&root_dir), &pristine) + .expect("failed to keep a pristine copy of the app binary"); + }; + + let check = |announced: &str, expected: Option<&str>, unexpected: Option<&str>| { + served.lock().unwrap().version = announced.to_string(); + let output = run_app(&root_dir, &pristine); + + if let Some(expected) = expected { + assert!( + output.contains(expected), + "expected {expected:?} when announcing {announced} for a 1.0.0 signature, got: {output}" + ); + } + if let Some(unexpected) = unexpected { + assert!( + !output.contains(unexpected), + "unexpected {unexpected:?} when announcing {announced} for a 1.0.0 signature, got: {output}" + ); + } + }; + + build(true); + + // the rollback this exists to stop: a 1.0.0 release dressed up as a newer one + check("1.5.0", Some(MISMATCH_ERROR), None); + // the announced version is what the artifact was signed for, so it must go through + check("1.0.0", None, Some(MISMATCH_ERROR)); + + build(false); + + // a signature that names a version is held to it whether or not the option is on + check("1.5.0", Some(MISMATCH_ERROR), None); + + server.unblock(); + + // leave a runnable binary behind rather than whichever update was installed last + let _ = std::fs::copy(&pristine, app_binary(&root_dir)); + let _ = std::fs::remove_file(&pristine); +}