Merge branch 'v2' into v3

This commit is contained in:
Lucas Nogueira
2026-09-21 13:45:38 -03:00
25 changed files with 1142 additions and 259 deletions
+320 -31
View File
@@ -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<serde_json::Value>,
}
#[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<String, PlatformUpdate>,
}
@@ -56,6 +64,12 @@ fn build_app(cwd: &Path, config: &Config, target: Option<BundleTarget>) {
.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<String>,
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);
}