diff --git a/.changes/updater-macos-restore-on-failure.md b/.changes/updater-macos-restore-on-failure.md new file mode 100644 index 000000000..748d79ca8 --- /dev/null +++ b/.changes/updater-macos-restore-on-failure.md @@ -0,0 +1,6 @@ +--- +"updater": patch +"updater-js": patch +--- + +On macOS, a failed install no longer deletes the installed app. The bundles are swapped atomically where the file system supports it; otherwise the previous app is restored, or kept as ` (previous version).app` and reported through the new `Error::PreviousAppNotRestored`. Also fixes installing apps on a volume other than the temp directory's, and the bundle root's permissions (`0755`). diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 7408c9997..7d2bb9385 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -68,5 +68,16 @@ jobs: # between runs, so the cached artifacts stay valid. run: cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch dev --locked --debug --target-dir target + - name: grant admin rights without a prompt + if: startsWith(matrix.platform, 'macos') + # The updater installs into folders the user cannot write to through + # `do shell script ... with administrator privileges`, which asks Authorization + # Services for `system.privilege.admin` and shows a password prompt nobody can answer + # here. Allowing the right outright lets `update_app_with_admin_prompt` cover that + # install; the runner is discarded after the job. + run: | + sudo security authorizationdb write system.privilege.admin allow + echo "TAURI_UPDATER_ADMIN_PROMPT_TEST=1" >> "$GITHUB_ENV" + - name: run integration tests run: cargo test --test '*' -- --ignored diff --git a/Cargo.lock b/Cargo.lock index e9aa91aaa..b86d80b1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6993,6 +6993,7 @@ dependencies = [ "futures-util", "http", "infer", + "libc", "log", "minisign-verify", "osakit", diff --git a/plugins/updater/Cargo.toml b/plugins/updater/Cargo.toml index a788e8b67..cb991f748 100644 --- a/plugins/updater/Cargo.toml +++ b/plugins/updater/Cargo.toml @@ -73,6 +73,7 @@ tar = { version = "0.4", optional = true } flate2 = { version = "1", optional = true } [target."cfg(target_os = \"macos\")".dependencies] +libc = "0.2" tar = "0.4" flate2 = "1" osakit = { version = "0.3", features = ["full"] } diff --git a/plugins/updater/src/error.rs b/plugins/updater/src/error.rs index 6c7c21534..c2e2b597f 100644 --- a/plugins/updater/src/error.rs +++ b/plugins/updater/src/error.rs @@ -65,9 +65,14 @@ pub enum Error { /// `zip` errors. #[error(transparent)] Extract(#[from] zip::result::ZipError), - /// Temp dir is not on same mount mount. This prevents our updater to rename the AppImage to a temp file. - #[error("temp directory is not on the same mount point as the AppImage")] + /// 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, diff --git a/plugins/updater/src/updater.rs b/plugins/updater/src/updater.rs index 2d05e088e..e478f77c2 100644 --- a/plugins/updater/src/updater.rs +++ b/plugins/updater/src/updater.rs @@ -1389,18 +1389,33 @@ impl Update { /// └── ... fn install_inner(&self, bytes: &[u8]) -> Result<()> { use flate2::read::GzDecoder; + use std::os::unix::fs::{MetadataExt, PermissionsExt}; let cursor = Cursor::new(bytes); let mut extracted_files: Vec = Vec::new(); - // Create temp directories for backup and extraction + // The app is moved with renames, which only work within one file system, so the + // temp dirs for backup and extraction go on the app's volume: in the system temp + // dir when it is there, and next to the app otherwise. If neither is, refuse now + // rather than fail after the current app has been moved away. The renames act on + // the install path itself, so a symlink there is not followed. + let app_dev = std::fs::symlink_metadata(&self.extract_path)?.dev(); + let tmp_root = [ + Some(std::env::temp_dir()), + self.extract_path.parent().map(Path::to_path_buf), + ] + .into_iter() + .flatten() + .find(|dir| dir.metadata().is_ok_and(|m| m.dev() == app_dev)) + .ok_or(Error::TempDirNotOnSameMountPoint)?; + let tmp_backup_dir = tempfile::Builder::new() .prefix("tauri_current_app") - .tempdir()?; + .tempdir_in(&tmp_root)?; let tmp_extract_dir = tempfile::Builder::new() .prefix("tauri_updated_app") - .tempdir()?; + .tempdir_in(&tmp_root)?; let decoder = GzDecoder::new(cursor); let mut archive = tar::Archive::new(decoder); @@ -1424,55 +1439,92 @@ impl Update { extracted_files.push(extraction_path); } - // Try to move the current app to backup - let move_result = std::fs::rename( - &self.extract_path, - tmp_backup_dir.path().join("current_app"), - ); - let need_authorization = if let Err(err) = move_result { - if err.kind() == std::io::ErrorKind::PermissionDenied { - true - } else { + // The temp dir becomes the installed bundle's root, and tempfile creates it + // with mode 0700, which stops every other user of the machine from launching + // the updated app. + std::fs::set_permissions( + tmp_extract_dir.path(), + std::fs::Permissions::from_mode(0o755), + )?; + + // Exchange the current app and the new one in a single step where the file + // system supports it, so the install path is never empty; the previous app + // then sits in the extraction temp dir, which is removed on drop. Where the + // swap is not supported, fall back to two renames with a restore on failure. + // File systems report that with different errors (`ENOTSUP` on HFS+, + // `EOPNOTSUPP`, `ENOSYS` or `EINVAL` elsewhere), so anything but a permission + // error, which needs the privileged install instead, gets the fallback. + let backup = tmp_backup_dir.path().join("current_app"); + let moved = match swap_bundle(&self.extract_path, tmp_extract_dir.path()) { + Err(err) if err.kind() != std::io::ErrorKind::PermissionDenied => { + log::debug!("cannot swap the app bundles ({err}), moving them instead"); + replace_bundle(&self.extract_path, tmp_extract_dir.path(), &backup) + } + other => other, + }; + let need_authorization = match moved { + Ok(()) => false, + Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => true, + Err(err) => { + if let Some(kept) = keep_backup_if_not_restored( + &self.extract_path, + tmp_backup_dir, + &backup, + std::env::home_dir().as_deref(), + ) { + log::error!("failed to install the update: {err}"); + return Err(Error::PreviousAppNotRestored(kept)); + } std::fs::remove_dir_all(tmp_extract_dir.path()).ok(); return Err(err.into()); } - } else { - false }; if need_authorization { log::debug!("app installation needs admin privileges"); - // Use AppleScript to perform moves with admin privileges + // Use AppleScript to swap the bundles, or move them where the file system + // cannot swap, with admin privileges: the current app is only deleted once + // the new one is in place. let apple_script = format!( - "do shell script \"rm -rf '{src}' && mv -f '{new}' '{src}'\" with administrator privileges", - src = self.extract_path.display(), - new = tmp_extract_dir.path().display() + "do shell script {} with administrator privileges", + applescript_string(&privileged_install_command( + &self.extract_path, + tmp_extract_dir.path(), + &backup + )) ); let (tx, rx) = std::sync::mpsc::channel(); let res = (self.context.run_on_main_thread)(Box::new(move || { let mut script = osakit::Script::new_from_source(osakit::Language::AppleScript, &apple_script); - script.compile().expect("invalid AppleScript"); - let r = script.execute(); - tx.send(r).unwrap(); + let result = match script.compile() { + Ok(()) => script.execute().map(|_| ()).map_err(|e| e.to_string()), + Err(e) => Err(e.to_string()), + }; + let _ = tx.send(result); })); - let result = rx.recv().unwrap(); + // `recv` only fails when the closure never ran, which `res` then reports + let result = res + .map_err(|e| e.to_string()) + .and_then(|()| rx.recv().map_err(|e| e.to_string())?); - if res.is_err() || result.is_err() { + if let Err(err) = result { + log::error!("failed to move the new app into place: {err}"); + if let Some(kept) = keep_backup_if_not_restored( + &self.extract_path, + tmp_backup_dir, + &backup, + std::env::home_dir().as_deref(), + ) { + return Err(Error::PreviousAppNotRestored(kept)); + } std::fs::remove_dir_all(tmp_extract_dir.path()).ok(); return Err(Error::Io(std::io::Error::new( std::io::ErrorKind::PermissionDenied, "Failed to move the new app into place", ))); } - } else { - // Remove existing directory if it exists - if self.extract_path.exists() { - std::fs::remove_dir_all(&self.extract_path)?; - } - // Move the new app to the target path - std::fs::rename(tmp_extract_dir.path(), &self.extract_path)?; } let _ = std::process::Command::new("touch") @@ -1483,6 +1535,117 @@ impl Update { } } +/// Exchange `target` and `staged` atomically, so there is no instant at which +/// `target` does not exist. APFS supports the swap; other file systems return an +/// error, and the caller falls back to [`replace_bundle`]. +#[cfg(target_os = "macos")] +fn swap_bundle(target: &Path, staged: &Path) -> std::io::Result<()> { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + + let from = CString::new(staged.as_os_str().as_bytes())?; + let to = CString::new(target.as_os_str().as_bytes())?; + // SAFETY: both pointers are valid NUL-terminated strings that outlive the call, + // and `renamex_np` only reads them. + let result = unsafe { libc::renamex_np(from.as_ptr(), to.as_ptr(), libc::RENAME_SWAP) }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +/// Move `staged` into `target`, keeping what was at `target` in `backup` until the +/// move has succeeded. If the second rename fails, the first is undone so `target` +/// is left exactly as it was. Both renames must be within one file system. +#[cfg(any(target_os = "macos", test))] +fn replace_bundle(target: &Path, staged: &Path, backup: &Path) -> std::io::Result<()> { + std::fs::rename(target, backup)?; + if let Err(err) = std::fs::rename(staged, target) { + // Best effort: the error worth reporting is the one from the failed move. + let _ = std::fs::rename(backup, target); + return Err(err); + } + Ok(()) +} + +/// Exchanges the two paths it is given like [`swap_bundle`], for the privileged install: +/// the shell has no command for the swap, but `osascript` running this JavaScript for +/// Automation can call `renamex_np` (`2` is `RENAME_SWAP`), and ships with every macOS. +/// It prints `swapped` or `failed`, so the caller can tell a swap that did not happen +/// from an `osascript` failure that leaves unknown whether it did. +#[cfg(target_os = "macos")] +const SWAP_BUNDLE_JXA: &str = r#"function run(argv) { + ObjC.import("stdio"); + return $.renamex_np(argv[0], argv[1], 2) === 0 ? "swapped" : "failed"; +}"#; + +/// The shell command the privileged install runs: the same swap as [`swap_bundle`], and +/// where the file system cannot swap, the same moves as [`replace_bundle`], deleting the +/// previous app only once the new one is in place. The moves only run once the swap is +/// known not to have happened, since after a swap they would put the previous app back. +#[cfg(target_os = "macos")] +fn privileged_install_command(target: &Path, staged: &Path, backup: &Path) -> String { + let swap = sh_quote(Path::new(SWAP_BUNDLE_JXA)); + let target = sh_quote(target); + let staged = sh_quote(staged); + let backup = sh_quote(backup); + format!( + "case \"$(/usr/bin/osascript -l JavaScript -e {swap} {staged} {target} 2>/dev/null)\" in swapped) rm -rf {staged};; failed) mv -f {target} {backup} && {{ mv -f {staged} {target} || {{ mv -f {backup} {target}; exit 1; }}; }} && rm -rf {backup};; *) exit 1;; esac" + ) +} + +/// Quotes `path` as a single `sh` word, whatever characters it holds. +#[cfg(target_os = "macos")] +fn sh_quote(path: &Path) -> String { + format!("'{}'", path.to_string_lossy().replace('\'', r"'\''")) +} + +/// Quotes `s` as an AppleScript string literal. +#[cfg(target_os = "macos")] +fn applescript_string(s: &str) -> String { + format!("\"{}\"", s.replace('\\', r"\\").replace('"', "\\\"")) +} + +/// After a failed install, returns where the previous app is if it never made it back to +/// `target`. `backup` is then the only copy left, and usually sits in the system temp dir, +/// which macOS empties on its own, so it is moved next to `target`, or to `fallback_dir` +/// (the home folder) when that is not writable, as ` (previous version).app`. If +/// neither works, `backup_dir` is kept rather than deleted on drop as it otherwise is. +#[cfg(any(target_os = "macos", test))] +fn keep_backup_if_not_restored( + target: &Path, + backup_dir: tempfile::TempDir, + backup: &Path, + fallback_dir: Option<&Path>, +) -> Option { + if std::fs::symlink_metadata(target).is_ok() || std::fs::symlink_metadata(backup).is_err() { + return None; + } + + let mut name = target.file_stem().unwrap_or_default().to_os_string(); + name.push(" (previous version)"); + if let Some(extension) = target.extension() { + name.push("."); + name.push(extension); + } + let kept = [target.parent(), fallback_dir] + .into_iter() + .flatten() + .map(|dir| dir.join(&name)) + // `rename` replaces an empty directory, so never move onto anything already there + .filter(|kept| std::fs::symlink_metadata(kept).is_err()) + .find(|kept| std::fs::rename(backup, kept).is_ok()); + + match kept { + Some(kept) => Some(kept), + None => { + let _ = backup_dir.keep(); + Some(backup.to_path_buf()) + } + } +} + /// Gets the base target string used by the updater. If bundle type is available it /// will be added to this string when selecting the download URL and signature. /// `tauri::utils::platform::bundle_type` method is used to obtain current bundle type. @@ -1864,6 +2027,279 @@ mod tests { assert!(verify_signed_version(comment, "2024-01-02", true).is_err()); } + #[test] + fn replace_bundle_moves_the_staged_bundle_into_place() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("App.app"); + let staged = dir.path().join("staged"); + let backup = dir.path().join("backup"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("version"), "old").unwrap(); + std::fs::create_dir(&staged).unwrap(); + std::fs::write(staged.join("version"), "new").unwrap(); + + super::replace_bundle(&target, &staged, &backup).unwrap(); + + assert_eq!( + std::fs::read_to_string(target.join("version")).unwrap(), + "new" + ); + assert_eq!( + std::fs::read_to_string(backup.join("version")).unwrap(), + "old" + ); + assert!(!staged.exists()); + } + + #[cfg(target_os = "macos")] + fn is_apfs(path: &std::path::Path) -> bool { + use std::os::unix::ffi::OsStrExt; + + let path = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap(); + let mut stat: libc::statfs = unsafe { std::mem::zeroed() }; + // SAFETY: `path` is NUL-terminated and `stat` is a valid `statfs` to write to. + assert_eq!(unsafe { libc::statfs(path.as_ptr(), &mut stat) }, 0); + // SAFETY: `f_fstypename` is a NUL-terminated string filled in by `statfs`. + let name = unsafe { std::ffi::CStr::from_ptr(stat.f_fstypename.as_ptr()) }; + name.to_bytes() == b"apfs" + } + + #[test] + #[cfg(target_os = "macos")] + fn swap_bundle_exchanges_the_two_bundles_in_place() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("App.app"); + let staged = dir.path().join("staged"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("version"), "old").unwrap(); + std::fs::create_dir(&staged).unwrap(); + std::fs::write(staged.join("version"), "new").unwrap(); + + // A temp dir on a file system without swap support proves nothing here, but on + // APFS the swap has to work: the install would otherwise always fall back. + if !is_apfs(dir.path()) { + return; + } + super::swap_bundle(&target, &staged).unwrap(); + + assert_eq!( + std::fs::read_to_string(target.join("version")).unwrap(), + "new" + ); + assert_eq!( + std::fs::read_to_string(staged.join("version")).unwrap(), + "old" + ); + } + + #[test] + fn replace_bundle_restores_the_current_bundle_when_the_move_fails() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("App.app"); + let backup = dir.path().join("backup"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("version"), "old").unwrap(); + let missing = dir.path().join("missing"); + + let err = super::replace_bundle(&target, &missing, &backup).unwrap_err(); + + assert_eq!(err.kind(), std::io::ErrorKind::NotFound); + assert_eq!( + std::fs::read_to_string(target.join("version")).unwrap(), + "old" + ); + assert!(!backup.exists()); + } + + /// A directory whose name `sh` and AppleScript would both misread unquoted. + #[cfg(target_os = "macos")] + fn hostile_dir() -> tempfile::TempDir { + tempfile::Builder::new() + .prefix("it's \"a\" \\ $(touch pwned) `dir`\n") + .tempdir() + .unwrap() + } + + #[cfg(target_os = "macos")] + fn assert_privileged_install_command_moved(dir: &std::path::Path) { + let target = dir.join("Bob's \"App\".app"); + assert_eq!( + std::fs::read_to_string(target.join("version")).unwrap(), + "new" + ); + assert!(!dir.join("staged").exists()); + assert!(!dir.join("backup").exists()); + assert!(!dir.join("pwned").exists()); + } + + #[cfg(target_os = "macos")] + fn stage_privileged_install(dir: &std::path::Path) -> String { + let target = dir.join("Bob's \"App\".app"); + let staged = dir.join("staged"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("version"), "old").unwrap(); + std::fs::create_dir(&staged).unwrap(); + std::fs::write(staged.join("version"), "new").unwrap(); + super::privileged_install_command(&target, &staged, &dir.join("backup")) + } + + #[test] + #[cfg(target_os = "macos")] + fn privileged_install_command_quotes_every_path() { + let dir = hostile_dir(); + let command = stage_privileged_install(dir.path()); + + let status = std::process::Command::new("sh") + .arg("-c") + .arg(&command) + .current_dir(dir.path()) + .status() + .unwrap(); + + assert!(status.success()); + assert_privileged_install_command_moved(dir.path()); + } + + /// The command the install runs, wrapped the way it is handed to AppleScript, only without + /// `with administrator privileges` so it runs without a prompt. + #[test] + #[cfg(target_os = "macos")] + fn privileged_install_script_quotes_every_path() { + let dir = hostile_dir(); + let command = stage_privileged_install(dir.path()); + + let output = std::process::Command::new("osascript") + .arg("-e") + .arg(format!( + "do shell script {}", + super::applescript_string(&command) + )) + .current_dir(dir.path()) + .output() + .unwrap(); + + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_privileged_install_command_moved(dir.path()); + } + + /// The privileged install swaps the bundles where the file system can, so on APFS it + /// never touches the backup: one in a directory that does not exist must not matter. + #[test] + #[cfg(target_os = "macos")] + fn privileged_install_command_swaps_the_bundles() { + let dir = tempfile::tempdir().unwrap(); + if !is_apfs(dir.path()) { + return; + } + let target = dir.path().join("App.app"); + let staged = dir.path().join("staged"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("version"), "old").unwrap(); + std::fs::create_dir(&staged).unwrap(); + std::fs::write(staged.join("version"), "new").unwrap(); + let backup = dir.path().join("missing").join("backup"); + + let status = std::process::Command::new("sh") + .arg("-c") + .arg(super::privileged_install_command(&target, &staged, &backup)) + .status() + .unwrap(); + + assert!(status.success()); + assert_eq!( + std::fs::read_to_string(target.join("version")).unwrap(), + "new" + ); + assert!(!staged.exists()); + } + + #[test] + fn keeps_the_backup_when_the_previous_app_was_not_restored() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("App.app"); + let backup_dir = tempfile::tempdir_in(dir.path()).unwrap(); + let backup_dir_path = backup_dir.path().to_path_buf(); + let backup = backup_dir_path.join("current_app"); + std::fs::create_dir(&backup).unwrap(); + std::fs::write(backup.join("version"), "old").unwrap(); + + let kept = super::keep_backup_if_not_restored(&target, backup_dir, &backup, None); + + let expected = dir.path().join("App (previous version).app"); + assert_eq!(kept.as_deref(), Some(expected.as_path())); + assert_eq!( + std::fs::read_to_string(expected.join("version")).unwrap(), + "old" + ); + assert!(!backup_dir_path.exists()); + } + + #[test] + fn moves_the_backup_to_the_fallback_dir_when_it_cannot_go_next_to_the_app() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("App.app"); + std::fs::write(dir.path().join("App (previous version).app"), "other").unwrap(); + let backup_dir = tempfile::tempdir_in(dir.path()).unwrap(); + let backup = backup_dir.path().join("current_app"); + std::fs::create_dir(&backup).unwrap(); + std::fs::write(backup.join("version"), "old").unwrap(); + let fallback = tempfile::tempdir_in(dir.path()).unwrap(); + + let kept = + super::keep_backup_if_not_restored(&target, backup_dir, &backup, Some(fallback.path())); + + let expected = fallback.path().join("App (previous version).app"); + assert_eq!(kept.as_deref(), Some(expected.as_path())); + assert_eq!( + std::fs::read_to_string(expected.join("version")).unwrap(), + "old" + ); + } + + #[test] + fn keeps_the_backup_in_place_when_it_cannot_be_moved() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("App.app"); + // A file where the backup would go next to the app, which must not be replaced + std::fs::write(dir.path().join("App (previous version).app"), "other").unwrap(); + let backup_dir = tempfile::tempdir_in(dir.path()).unwrap(); + let backup_dir_path = backup_dir.path().to_path_buf(); + let backup = backup_dir_path.join("current_app"); + std::fs::create_dir(&backup).unwrap(); + std::fs::write(backup.join("version"), "old").unwrap(); + let missing = dir.path().join("missing"); + + let kept = super::keep_backup_if_not_restored(&target, backup_dir, &backup, Some(&missing)); + + assert_eq!(kept.as_deref(), Some(backup.as_path())); + assert_eq!( + std::fs::read_to_string(backup.join("version")).unwrap(), + "old" + ); + assert_eq!( + std::fs::read_to_string(dir.path().join("App (previous version).app")).unwrap(), + "other" + ); + } + + #[test] + fn drops_the_backup_when_the_previous_app_is_in_place() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("App.app"); + std::fs::create_dir(&target).unwrap(); + let backup_dir = tempfile::tempdir_in(dir.path()).unwrap(); + let backup_dir_path = backup_dir.path().to_path_buf(); + let backup = backup_dir_path.join("current_app"); + std::fs::create_dir(&backup).unwrap(); + + assert!(super::keep_backup_if_not_restored(&target, backup_dir, &backup, None).is_none()); + assert!(!backup_dir_path.exists()); + } + #[test] #[cfg(windows)] fn it_wraps_correctly() { diff --git a/plugins/updater/tests/app-updater/tests/update.rs b/plugins/updater/tests/app-updater/tests/update.rs index 77273836c..d48d61fd8 100644 --- a/plugins/updater/tests/app-updater/tests/update.rs +++ b/plugins/updater/tests/app-updater/tests/update.rs @@ -440,6 +440,46 @@ fn target_to_platforms( platforms } +/// Serves a 1.0.0 update of `updater_path` with `signature` under `update_platform` (none for a +/// manifest without an update) on `UPDATE_PORT`. +fn serve_update( + update_platform: Option, + signature: String, + updater_path: PathBuf, +) -> UpdaterServer { + UpdaterServer::spawn(UPDATE_PORT, move |request| match request.url() { + "/" => { + let platforms = target_to_platforms(update_platform.clone(), signature.clone()); + + let body = serde_json::to_vec(&Update { + version: "1.0.0".into(), + date: time::OffsetDateTime::now_utc() + .format(&time::format_description::well_known::Rfc3339) + .unwrap(), + platforms, + }) + .unwrap(); + let len = body.len(); + let response = tiny_http::Response::new( + tiny_http::StatusCode(200), + Vec::new(), + std::io::Cursor::new(body), + Some(len), + None, + ); + let _ = request.respond(response); + } + "/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()) + }), + )); + } + _ => (), + }) +} + /// A bundle to build, the update platform key the server announces it under (none for a manifest /// without an update) and the exit codes expected from running the app once per entry. type TestCase = (BundleTarget, PathBuf, Option, Vec); @@ -749,37 +789,7 @@ fn run_update_cases( std::fs::rename(&out_updater_path, &updater_path).expect("failed to rename bundle"); // start the updater server, shut down at the end of the iteration even if a case panics - let _server = UpdaterServer::spawn(UPDATE_PORT, move |request| match request.url() { - "/" => { - let platforms = target_to_platforms(update_platform.clone(), signature.clone()); - - let body = serde_json::to_vec(&Update { - version: "1.0.0".into(), - date: time::OffsetDateTime::now_utc() - .format(&time::format_description::well_known::Rfc3339) - .unwrap(), - platforms, - }) - .unwrap(); - let len = body.len(); - let response = tiny_http::Response::new( - tiny_http::StatusCode(200), - Vec::new(), - std::io::Cursor::new(body), - Some(len), - None, - ); - let _ = request.respond(response); - } - "/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()) - }), - )); - } - _ => (), - }); + let _server = serve_update(update_platform, signature, updater_path); config.version = "0.1.0"; @@ -837,6 +847,252 @@ fn run_update_cases( } } +/// A disk image attached at `mount_point`, detached when it goes out of scope. +#[cfg(target_os = "macos")] +struct DiskImage { + mount_point: PathBuf, +} + +#[cfg(target_os = "macos")] +impl DiskImage { + /// Creates a `file_system` image named `name` in `dir` and attaches it at `dir/name`. + fn attach(dir: &Path, name: &str, file_system: &str) -> Self { + let image = dir.join(format!("{name}.dmg")); + let mount_point = dir.join(name); + // whatever an earlier run left attached + Self::detach(&mount_point); + std::fs::create_dir_all(&mount_point).expect("failed to create the mount point"); + + let hdiutil = |args: &[&std::ffi::OsStr]| { + let status = Command::new("hdiutil") + .args(args) + .status() + .expect("failed to run hdiutil"); + assert!(status.success(), "hdiutil {args:?} failed"); + }; + hdiutil(&[ + "create".as_ref(), + "-ov".as_ref(), + "-quiet".as_ref(), + "-size".as_ref(), + "64m".as_ref(), + "-fs".as_ref(), + file_system.as_ref(), + "-volname".as_ref(), + name.as_ref(), + image.as_os_str(), + ]); + hdiutil(&[ + "attach".as_ref(), + "-quiet".as_ref(), + "-nobrowse".as_ref(), + "-mountpoint".as_ref(), + mount_point.as_os_str(), + image.as_os_str(), + ]); + + Self { mount_point } + } + + fn detach(mount_point: &Path) { + let _ = Command::new("hdiutil") + .args(["detach", "-force", "-quiet"]) + .arg(mount_point) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } +} + +#[cfg(target_os = "macos")] +impl Drop for DiskImage { + fn drop(&mut self) { + Self::detach(&self.mount_point); + } +} + +/// Bundles 1.0.0 and serves it as the update, then bundles 0.1.0 and returns the server along with +/// where the 0.1.0 `.app` was left, to copy wherever a test runs it from. +#[cfg(target_os = "macos")] +fn build_and_serve_macos_update(manifest_dir: &Path, root_dir: &Path) -> (UpdaterServer, PathBuf) { + let target = + tauri_plugin_updater::target().expect("running updater test in an unsupported platform"); + + let mut config = Config { + version: "1.0.0", + bundle: BundleConfig { + create_updater_artifacts: Updater::Bool(true), + linux: None, + }, + plugins: None, + }; + build_app(manifest_dir, &config, Some(BundleTarget::App)); + + let bundle_path = root_dir.join("target/release/bundle/macos/app-updater.app"); + let signature = std::fs::read_to_string(bundle_path.with_extension("app.tar.gz.sig")) + .expect("failed to read signature file"); + // move it aside so building the running app cannot clobber it + let updater_path = root_dir.join("target/release/app-updater.app.tar.gz"); + std::fs::rename(bundle_path.with_extension("app.tar.gz"), &updater_path) + .expect("failed to rename bundle"); + let server = serve_update(Some(target), signature, updater_path); + + config.version = "0.1.0"; + build_app(manifest_dir, &config, Some(BundleTarget::App)); + + (server, bundle_path) +} + +/// Runs the app at `app_path` twice, expecting it to install the update and then to find itself +/// up to date. +#[cfg(target_os = "macos")] +fn update_and_check(app_path: &Path, envs: &[(&str, &Path)], context: &str) { + for expected_exit_code in [UPDATED_EXIT_CODE, UP_TO_DATE_EXIT_CODE] { + let output = Command::new(app_path.join("Contents/MacOS/app-updater")) + .env("TARGET", BundleTarget::App.name()) + .envs(envs.iter().copied()) + .output() + .unwrap_or_else(|e| panic!("failed to run {}: {e}", app_path.display())); + let code = output.status.code().unwrap_or(-1); + assert_eq!( + code, + expected_exit_code, + "unexpected exit code running the app {context}: {}", + String::from_utf8_lossy(&output.stdout) + ); + } +} + +/// The entries of `dir` an update staged there and failed to clean up. +#[cfg(target_os = "macos")] +fn update_leftovers(dir: &Path) -> Vec { + std::fs::read_dir(dir) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) + .filter(|name| name.starts_with("tauri_")) + .collect() +} + +/// The updater moves the `.app` with renames, which only work within one volume, so it has to +/// stage the update on the volume the app is on. This runs the app from disk images, which are +/// never the volume the system temp dir is on: an APFS one, where the two bundles are swapped in +/// one step, and an HFS+ one, which does not support the swap and moves them one at a time. +#[cfg(target_os = "macos")] +#[test] +#[ignore = "needs the tauri CLI, a display and minutes of build time; the integration tests workflow runs it with --ignored"] +fn update_app_on_another_volume() { + use std::os::unix::fs::MetadataExt; + + let _lock = BUILD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let root_dir = manifest_dir.join("../../../.."); + let (_server, bundle_path) = build_and_serve_macos_update(&manifest_dir, &root_dir); + + let temp_dir_dev = std::env::temp_dir().metadata().unwrap().dev(); + let volumes_dir = root_dir.join("target/release/app-under-test-volumes"); + for (name, file_system) in [("apfs", "APFS"), ("hfs", "HFS+")] { + let volume = DiskImage::attach(&volumes_dir, name, file_system); + assert_ne!( + volume.mount_point.metadata().unwrap().dev(), + temp_dir_dev, + "the {file_system} image is on the same volume as the temp dir" + ); + + let app_path = volume.mount_point.join("app-updater.app"); + copy_recursively(&bundle_path, &app_path) + .unwrap_or_else(|e| panic!("failed to copy the app to {}: {e}", app_path.display())); + + update_and_check(&app_path, &[], &format!("from an {file_system} volume")); + + // the update is staged next to the app, and nothing of it may be left behind + let leftovers = update_leftovers(&volume.mount_point); + assert!( + leftovers.is_empty(), + "the update left {leftovers:?} on the {file_system} volume" + ); + } +} + +/// Makes a directory read-only, and writable again when it goes out of scope so it can be removed. +#[cfg(target_os = "macos")] +struct ReadOnlyDir(PathBuf); + +#[cfg(target_os = "macos")] +impl ReadOnlyDir { + fn new(dir: PathBuf) -> Self { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555)) + .expect("failed to make the directory read-only"); + Self(dir) + } +} + +#[cfg(target_os = "macos")] +impl Drop for ReadOnlyDir { + fn drop(&mut self) { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o755)); + } +} + +/// When the user cannot write to the folder the app is in, the updater asks for an admin password +/// and installs as root. Someone has to type that password, so this only runs with +/// `TAURI_UPDATER_ADMIN_PROMPT_TEST` set, and asks twice: for a folder on the temp dir's APFS +/// volume, where root swaps the bundles in one step, and for one on an HFS+ disk image, where the +/// swap is not supported and root moves them one at a time. +/// +/// Each run points `TMPDIR` at a directory of its own on the app's volume, so what the install +/// leaves behind there can be checked. +#[cfg(target_os = "macos")] +#[test] +#[ignore = "needs the tauri CLI, a display, someone to type an admin password and minutes of build time"] +fn update_app_with_admin_prompt() { + if std::env::var_os("TAURI_UPDATER_ADMIN_PROMPT_TEST").is_none() { + eprintln!("skipping the admin prompt test: TAURI_UPDATER_ADMIN_PROMPT_TEST is not set"); + return; + } + + let _lock = BUILD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let root_dir = manifest_dir.join("../../../.."); + let (_server, bundle_path) = build_and_serve_macos_update(&manifest_dir, &root_dir); + + let work_dir = root_dir.join("target/release/app-under-test-admin"); + let run = |dir: &Path, file_system: &str| { + let app_dir = dir.join("read-only"); + let tmp_dir = dir.join("tmp"); + // whatever an earlier run left + drop(ReadOnlyDir(app_dir.clone())); + let _ = std::fs::remove_dir_all(&app_dir); + let _ = std::fs::remove_dir_all(&tmp_dir); + std::fs::create_dir_all(&app_dir).expect("failed to create the app directory"); + std::fs::create_dir_all(&tmp_dir).expect("failed to create the temp directory"); + + let app_path = app_dir.join("app-updater.app"); + copy_recursively(&bundle_path, &app_path) + .unwrap_or_else(|e| panic!("failed to copy the app to {}: {e}", app_path.display())); + let _read_only = ReadOnlyDir::new(app_dir); + + update_and_check( + &app_path, + &[("TMPDIR", &tmp_dir)], + &format!("from a read-only folder on an {file_system} volume"), + ); + + let leftovers = update_leftovers(&tmp_dir); + assert!( + leftovers.is_empty(), + "the admin install left {leftovers:?} on the {file_system} volume" + ); + }; + + run(&work_dir.join("apfs"), "APFS"); + let volume = DiskImage::attach(&work_dir, "hfs", "HFS+"); + run(&volume.mount_point, "HFS+"); +} + 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