mirror of
https://github.com/tauri-apps/plugins-workspace.git
synced 2026-09-22 21:30:44 +02:00
Merge branch 'v2' into v3
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user