Files
tauri-plugins-workspace/plugins/updater/src/error.rs
T
AliandLucas Nogueira 2a9c29e004 fix(updater): restore the current app when a macOS install fails (fix #3505, #3506) (#3578)
* fix(updater): restore the current app when a macOS install fails

On macOS `install_inner` moved the installed app into a `TempDir` backup and
then renamed the new bundle into place. Neither of the two steps after that
move restored the app on failure, and the `TempDir` deleted the backup on
every exit path, so a failed final rename left the user with no app at the
install path and no way back. The privileged fallback had the same shape:
`rm -rf` the app, then `mv` the new one in.

Both moves are now `replace_bundle`, which renames the current app to the
backup, renames the staged bundle into place, and renames the backup back if
that second step fails. The privileged script moves the current app aside,
moves the new one in, and restores on failure; it deletes the previous bundle
only after the new one is in place. The temp and install locations are
compared with `st_dev` before anything moves, returning the existing
`TempDirNotOnSameMountPoint` rather than failing after the app has already
been moved, and the staged bundle root is set to 0755 because `tempfile`
creates it 0700 and that mode followed it into `/Applications`.

Two unit tests cover `replace_bundle`: the staged bundle ends up in place with
the previous one in the backup, and a failed second rename leaves the current
bundle where it was and returns that error.

Closes #3505, closes #3506.

* fix(updater): exchange the current and new bundles atomically on APFS

Even with the restore in place there was a moment between the two renames
where the install path held nothing, and a process killed in that moment
left the previous app in the temp dir rather than where it belonged.

`swap_bundle` calls `renamex_np` with `RENAME_SWAP`, which exchanges the two
directories in one step so the install path always holds one of the two. The
previous app ends up in the extraction temp dir and is removed with it. Where
the file system does not support the swap (`ENOTSUP`, or `EINVAL` on older
systems) the two-rename path with the restore is used as before, and a
`PermissionDenied` from either still goes to the privileged fallback.

`libc` is added for the macOS target only; it was already in the lockfile
through tauri. One macOS test checks the exchange and skips on a file system
that reports `ENOTSUP`.

* fix(updater): keep the previous app when it cannot be restored

If the restore rename also failed, the previous app was left only in the backup TempDir and deleted with it. Keep the backup in that case and return Error::PreviousAppNotRestored with its path.

* fix(updater): quote the paths in the privileged macOS install script

The paths went into single-quoted sh words inside an AppleScript string unescaped, so a quote in the app path broke the command (run as root) or panicked on the compile expect. Quote them for sh and AppleScript, and report script failures instead of panicking.

* fix(updater): fall back to moving the bundles on any swap error

Only ENOTSUP and EINVAL got the two-rename fallback, so volumes reporting EOPNOTSUPP or ENOSYS for RENAME_SWAP failed every update. Fall back on anything but a permission error, and have the swap test skip only off APFS.

* fix(updater): stage the macOS update on the app's volume

When the system temp dir was on another volume than the app, every update was refused. Create the temp dirs next to the app in that case, and compare the install path itself rather than what a symlink there points to.

* test(updater): update a macOS app on another volume than the temp dir

Runs the app from APFS and HFS+ disk images, covering the atomic swap and the two-rename fallback with the update staged next to the app.

* fix(updater): swap the bundles atomically in the privileged macOS install

The admin path ran two mv calls, leaving the install path empty between them. Run renamex_np with RENAME_SWAP as root through osascript's JavaScript bridge, and only fall back to the moves where the swap fails.

* add test

* cleanup

---------

Co-authored-by: Lucas Nogueira <lucas@tauri.app>
2026-10-05 09:28:11 -03:00

142 lines
6.0 KiB
Rust

// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use serde::{Serialize, Serializer};
use thiserror::Error;
/// All errors that can occur while running the updater.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum Error {
/// Endpoints are not sent.
#[error("Updater does not have any endpoints set.")]
EmptyEndpoints,
/// IO errors.
#[error(transparent)]
Io(#[from] std::io::Error),
/// Semver errors.
#[error(transparent)]
Semver(#[from] semver::Error),
/// Serialization errors.
#[error(transparent)]
Serialization(#[from] serde_json::Error),
/// Could not fetch a valid response from the server.
#[error("Could not fetch a valid release JSON from the remote")]
ReleaseNotFound,
/// Unsupported app architecture.
#[error(
"Unsupported application architecture, expected one of `x86`, `x86_64`, `arm` or `aarch64`."
)]
UnsupportedArch,
/// Operating system is not supported.
#[error("Unsupported OS, expected one of `linux`, `darwin` or `windows`.")]
UnsupportedOs,
/// Failed to determine updater package extract path
#[error("Failed to determine updater package extract path.")]
FailedToDetermineExtractPath,
/// Url parsing errors.
#[error(transparent)]
UrlParse(#[from] url::ParseError),
/// `reqwest` crate errors.
#[error(transparent)]
Reqwest(#[from] reqwest::Error),
/// The platform was not found in the updater JSON response.
#[error("the platform `{0}` was not found in the response `platforms` object")]
TargetNotFound(String),
/// Neither the platform nor the fallback platform was found in the updater JSON response.
#[error("None of the fallback platforms `{0:?}` were found in the response `platforms` object")]
TargetsNotFound(Vec<String>),
/// Download failed
#[error("`{0}`")]
Network(String),
/// `minisign_verify` errors.
#[error(transparent)]
Minisign(#[from] minisign_verify::Error),
/// `base64` errors.
#[error(transparent)]
Base64(#[from] base64::DecodeError),
/// UTF8 Errors in signature.
#[error(
"The signature {0} could not be decoded, please check if it is a valid base64 string. The signature must be the contents of the `.sig` file generated by the Tauri bundler, as a string."
)]
SignatureUtf8(String),
#[cfg(all(target_os = "windows", feature = "zip"))]
/// `zip` errors.
#[error(transparent)]
Extract(#[from] zip::result::ZipError),
/// No temp dir could be created on the same mount point as the app, which the updater needs
/// to move the app aside and the update into place with renames.
#[error("temp directory is not on the same mount point as the app")]
TempDirNotOnSameMountPoint,
/// Installing the update failed, and the app it was replacing could not be moved back to its
/// install path either. The previous app was kept at the given path instead.
#[error("failed to install the update and to restore the previous app, which was left at {0}")]
PreviousAppNotRestored(std::path::PathBuf),
/// The downloaded archive does not contain a binary for the current target.
#[error("binary for the current target not found in the archive")]
BinaryNotFoundInArchive,
/// Could not create a temporary directory to store the downloaded update.
#[error("failed to create temporary directory")]
TempDirNotFound,
/// The privilege escalation prompt shown before installing a Linux package
/// failed or was dismissed by the user.
#[error("Authentication failed or was cancelled")]
AuthenticationFailed,
/// Installing the downloaded `.deb` package failed.
#[error("Failed to install .deb package")]
DebInstallFailed,
/// The package manager could not install the downloaded Linux package.
#[error("Failed to install package")]
PackageInstallFailed,
/// The downloaded update is not in a format the updater can install on the current platform.
#[error("invalid updater binary format")]
InvalidUpdaterFormat,
/// `http` crate errors.
#[error(transparent)]
Http(#[from] http::Error),
/// A request header value is not valid.
#[error(transparent)]
InvalidHeaderValue(#[from] http::header::InvalidHeaderValue),
/// A request header name is not valid.
#[error(transparent)]
InvalidHeaderName(#[from] http::header::InvalidHeaderName),
/// The release publish date could not be formatted as an RFC 3339 string.
#[error("Failed to format date")]
FormatDate,
/// 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,
/// Tauri errors.
#[error(transparent)]
Tauri(#[from] tauri::Error),
}
impl Serialize for Error {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
/// A [`Result`](std::result::Result) alias whose error type is the updater [`Error`](enum@Error).
pub type Result<T> = std::result::Result<T, Error>;